commit 26ed99fda6f98e151310691b163f0ad289bf8024 Author: TarnaWijaya Date: Thu Jul 9 08:33:57 2026 +0800 up3 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..efc056e --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.go eol=lf \ No newline at end of file diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..958eedd --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,66 @@ +# Contributing to Dragonfly + +First, thank you for your interest in contributing to Dragonfly. :tada: + +The following is a set of guidelines for contributing to Dragonfly. These are guidelines and in +general it is recommended to stick to them when contributing, but you should use your best +judgement. Feel free to propose changes to this document in our Discord. + +### Issues +Issues are very welcome, both in the form of bug reports and feature requests. + +When opening a bug report, please try to provide as much information as you can to reproduce the issue. When opening a +feature request, please be as clear as you can and provide concrete examples of how you think the proposed feature +should work. The more precise you are, the easier it is for someone to pick up your request. Don't be afraid to propose +any ideas you might have, they may help people looking to pick up your feature request! + +As soon as a maintainer sees your issue and has enough information, your issue will be added to a milestone that +indicates when we hope to have your issue resolved. + +### Pull Requests +In general, it is recommended to discuss any changes you would like to make in our Discord to +before making changes, unless the change-set is otherwise small or limited to a specific part of +the code base. + +When reviewing pull requests, we aim to reach the following goals with the code proposed: +* Maintain the quality of the source code. +* Stick to the standard formatting of Go (go fmt). +* Provide a well-documented, simple and clean codebase. + +To make sure your pull request satisfies those points, we recommend you do the following before +opening a pull request: +* Run `go fmt` on any files you have changed to ensure the formatting is correct. Some IDEs have + integration with this tool to run it automatically when committing. GoLand has a box that may be + checked in the bottom-right corner when creating a commit. +* Make sure to provide documentation for symbols where adequate. We generally follow the following + conventions for documentation in pull requests: + - Exported symbols (TypeName, FunctionName) should always have documentation over them, but if + the function exists merely to satisfy an interface, the documentation may read + `// FunctionName ...` or be completely absent if the method has no functional behaviour. + - Unexported symbols (typeName, functionName) _should_ have documentation, but doing so is not + mandatory if the function is very simple and needs no clarification. +* Make sure to use British English and proper punctuation throughout symbol names, variables and + documentation. +* Where possible, try to expose as few exported symbols (functions, types) as possible, unless + strictly necessary. This makes it easier for us to change code in the future and ensures that + users cannot use functions not suitable for the API. +* In big functions it can become difficult to track the execution flow. Try to `return` as quickly + possible in functions so that the main code flow is minimally indented and therefore easy to + track and understand. +* In places where there are three or more sequential variable declarations, these should be grouped + into a single `var ( )` block. +* Try to be conservative with the usage of generics. While these are often useful, they can quickly + pollute code if used in excess. Consider carefully if generics are needed, particularly for + exported types and functions. Don't hesitate on using them if they are able to clean up the code + significantly. +* We strive to have only completely functional features in the codebase. While we recognise that + it is not always possible to provide full functionality for a feature in a single pull request, + you should attempt to do so to the extent that you can. Specific smaller features part of the + pull request that cannot be implemented yet should be marked with a `// TODO: ...` comment so + that we can implement these when the required functionality is present. +* When you open a PR, we assume you have tested your code and made sure it is working as intended. + As a general recommendation, you should enable the Minecraft Content Log in the Profile settings + so that it becomes obvious when invalid data is sent to the client. + +If you run into a problem or otherwise need help with your pull request, please feel free to reach +out to us on Discord, so we can work towards a complete pull request together. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..2dae454 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,22 @@ +name: Build +on: [pull_request] +jobs: + + build: + name: Build + runs-on: ubuntu-latest + steps: + + - name: Check out code into the Go module directory + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Lint + run: make lint + + - name: Test + run: go test ./... diff --git a/.github/workflows/pr_target.yml b/.github/workflows/pr_target.yml new file mode 100644 index 0000000..9d64963 --- /dev/null +++ b/.github/workflows/pr_target.yml @@ -0,0 +1,41 @@ +name: Deploy +on: + pull_request_target: + types: [opened, synchronize, reopened, closed] +jobs: + + deploy: + name: Deploy + if: github.event.action != 'closed' + runs-on: ubuntu-latest + steps: + + - name: Check out code into the Go module directory + uses: actions/checkout@v6 + with: + ref: refs/pull/${{ github.event.pull_request.number }}/merge + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Build + run: go build -o dragonfly_exe -v . + + - name: Deploy test server + run: | + curl -X POST https://df-mc.dev/pullrequest \ + -H "X-API-Key: ${{ secrets.API_KEY }}" \ + -F "pr=${{ github.event.pull_request.number }}" \ + -F "binary=@dragonfly_exe" + + cleanup: + name: Cleanup + if: github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - name: Delete test server + run: | + curl -X DELETE https://df-mc.dev/pullrequest/${{ github.event.pull_request.number }} \ + -H "X-API-Key: ${{ secrets.API_KEY }}" \ No newline at end of file diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 0000000..c0955b2 --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,93 @@ +name: Build and deploy +on: [push] +jobs: + + build: + name: Build + runs-on: ubuntu-latest + steps: + + - name: Check out code into the Go module directory + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Lint + run: make lint + + - name: Test + run: go test ./... + + deploy: + name: Deploy + needs: build + runs-on: ubuntu-latest + if: github.repository == 'df-mc/dragonfly' + steps: + + - name: Check out code into the Go module directory + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Build + run: go build -o dragonfly_exe -v . + + - name: Set SSH info + env: + SSH_KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }} + SSH_PRIVATE_KEY: ${{ secrets.VPS_PRIVATE_KEY }} + run: | + mkdir -p ~/.ssh/ + echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts + echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + + - name: Stop server + env: + HOST: ${{ secrets.VPS_HOST }} + run: | + ssh -i ~/.ssh/id_rsa $HOST screen -d -R -S dragonfly -X stuff '^C' + ssh -i ~/.ssh/id_rsa $HOST rm -f dragonfly_exe + + - name: Transfer executable + env: + HOST: ${{ secrets.VPS_HOST }} + run: | + scp -i ~/.ssh/id_rsa dragonfly_exe $HOST:/home/dragonfly_exe + + - name: Restart server + env: + HOST: ${{ secrets.VPS_HOST }} + run: | + ssh -i ~/.ssh/id_rsa $HOST "screen -d -R -S dragonfly -X stuff '/home/dragonfly_exe\n'" + + update_contributors: + name: Update Contributors + runs-on: ubuntu-latest + if: github.repository == 'df-mc/dragonfly' && github.ref == 'refs/heads/master' + steps: + - uses: actions/create-github-app-token@v3 + id: app-token + with: + client-id: ${{ vars.PUSH_CLIENT_ID }} + private-key: ${{ secrets.PUSH_SECRET }} + - name: Checkout Repository + uses: actions/checkout@v6 + with: + token: ${{ steps.app-token.outputs.token }} + - name: Fetch Contributors + run: | + CONTRIBUTOR_DATA=$(curl -H "Accept: application/vnd.github+json" https://api.github.com/repos/df-mc/dragonfly/contributors?per_page=9999) + echo $CONTRIBUTOR_DATA + if [ $(echo $CONTRIBUTOR_DATA | jq type) == '"array"' ]; then echo -e "// Code generated by .github/workflows/push.yml; DO NOT EDIT\n\npackage session\n\n// enchantNames are names translated to the 'Standard Galactic Alphabet' client-side. The names generally have no meaning\n// on the vanilla server implementation, so we can sneak some easter eggs in here without anyone noticing.\nvar enchantNames = []string{"$(echo $CONTRIBUTOR_DATA | jq .[].login | sed -r -e "s/([^A-Z\"])([A-Z])/\1 \2/g" | sed "s/./\L&/g" | sort | sed -z "s/\n/,/g")"}" > server/session/enchantment_texts.go && gofmt -w server/session/enchantment_texts.go; fi + - name: Push Changes + uses: stefanzweifel/git-auto-commit-action@v7 + with: + commit_message: "updated contributor list" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2502e81 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# IDEs +.idea/ +.zed/ +.vscode/ + +# Dragonfly +/world/ +/players/ +/resources/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..b318bfb --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,21 @@ +version: "2" + +linters: + default: none + enable: [govet, staticcheck, ineffassign, gocritic] + settings: + staticcheck: + checks: [all, -ST1000, -ST1003, -ST1016] + initialisms: [ + ACL, API, ASCII, CPU, CSS, DNS, EOF, GUID, HTML, HTTP, HTTPS, ID, + IP, JSON, QPS, RAM, RPC, SLA, SMTP, SQL, SSH, TCP, TLS, TTL, UDP, + UI, GID, UID, UUID, URI, URL, UTF8, VM, XML, XMPP, XSRF, XSS, XUID, + ] + dot-import-whitelist: [] + http-status-code-whitelist: ["200", "400", "404", "500"] + +formatters: + enable: [gofmt] + settings: + gofmt: + simplify: false diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b7577fd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Dragonfly Tech + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..30cf54b --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +.PHONY: lint + +lint: + go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 run ./... diff --git a/README.md b/README.md new file mode 100644 index 0000000..16e3e5e --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ + +image + +# Dragonfly + +Dragonfly is a heavily asynchronous server software for Minecraft: Bedrock Edition written in Go. It was written with scalability +and simplicity in mind and aims to make the process of setting up a server and modifying it easy. Unlike other +Minecraft server software, Dragonfly is generally used as a library to extend. + +[![Discord Banner 2](https://discordapp.com/api/guilds/623638955262345216/widget.png?style=banner2)](https://discord.gg/U4kFWHhTNR) + +## Getting started +Running Dragonfly requires at least **Go 1.26**. After starting the server through one of the methods below, +**ctrl+c** may be used to shut down the server. Also check out the [wiki](https://github.com/df-mc/dragonfly/wiki) for +more detailed info. + +#### Installation as library +```shell +go mod init github.com/user/module +go get github.com/df-mc/dragonfly +``` + +![SetupLibrary](https://user-images.githubusercontent.com/16114089/121804512-0f843900-cc47-11eb-9320-d195393b5a1f.gif) + +#### Installation of the latest commit +```shell +git clone https://github.com/df-mc/dragonfly +cd dragonfly +go run main.go +``` + +![SetupClone](https://user-images.githubusercontent.com/16114089/121804495-ff6c5980-cc46-11eb-8e31-df4d94782e5b.gif) + +## Developer info +[![Go Reference](https://pkg.go.dev/badge/github.com/df-mc/dragonfly/server.svg)](https://pkg.go.dev/github.com/df-mc/dragonfly/server) + +Dragonfly features a well-documented codebase with an easy-to-use API. Documentation may be found +[here](https://pkg.go.dev/github.com/df-mc/dragonfly/server) and in the subpackages found by clicking *Directories*. + +Publishing your project on GitHub? Consider adding the **[#df-mc](https://github.com/topic/df-mc)** topic to your +repository and opening a pull request at [df-wiki](https://github.com/df-mc/wiki) adding your project as a Community +Project to improve its visibility. + +## Contributing +Contributions are very welcome! Issues, pull requests and feature requests are highly appreciated. Opening a pull +request? Consider joining our [Discord server](https://discord.gg/U4kFWHhTNR) to discuss your changes! Also have a read through the +[CONTRIBUTING.md](https://github.com/df-mc/dragonfly/blob/master/.github/CONTRIBUTING.md) for more info. diff --git a/cmd/blockhash/main.go b/cmd/blockhash/main.go new file mode 100644 index 0000000..3f86174 --- /dev/null +++ b/cmd/blockhash/main.go @@ -0,0 +1,368 @@ +package main + +import ( + "bytes" + "flag" + "fmt" + "go/ast" + "io" + "log" + "os" + "sort" + "strconv" + "strings" + + "golang.org/x/tools/go/packages" +) + +func main() { + out := flag.String("o", "", "output file for hash constants and methods") + flag.Parse() + + if len(flag.Args()) != 1 { + log.Fatalln("Must pass one package to produce block hashes for.") + } + cfg := &packages.Config{ + Mode: packages.NeedName | packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo | packages.NeedFiles, + } + pkgs, err := packages.Load(cfg, flag.Args()[0]) + if err != nil { + log.Fatalln(err) + } + f, err := os.OpenFile(*out, os.O_CREATE|os.O_TRUNC|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + log.Fatalln(err) + } + for _, pkg := range pkgs { + procPackage(pkg, f) + } + _ = f.Close() +} + +func procPackage(pkg *packages.Package, w io.Writer) { + b := &hashBuilder{ + pkg: pkg, + fields: make(map[string][]*ast.Field), + aliases: make(map[string]string), + handled: map[string]struct{}{}, + funcs: map[string]*ast.FuncDecl{}, + blockFields: map[string][]*ast.Field{}, + } + b.readStructFields(pkg) + b.readFuncs(pkg) + b.resolveBlocks() + b.sortNames() + + b.writePackage(w) + b.writeConstants(w) + b.writeNextHash(w) + b.writeMethods(w) +} + +var ( + packageFormat = "// Code generated by cmd/blockhash; DO NOT EDIT.\n\npackage %v\n\n" + methodFormat = "\nfunc (%v%v) Hash() (uint64, uint64) {\n\treturn %v, %v\n}\n" + constFormat = "\thash%v" +) + +type hashBuilder struct { + pkg *packages.Package + fields map[string][]*ast.Field + funcs map[string]*ast.FuncDecl + aliases map[string]string + handled map[string]struct{} + blockFields map[string][]*ast.Field + names []string +} + +// sortNames sorts the names of the blockFields map and stores them in a slice. +func (b *hashBuilder) sortNames() { + b.names = make([]string, 0, len(b.blockFields)) + for name := range b.blockFields { + b.names = append(b.names, name) + } + sort.Slice(b.names, func(i, j int) bool { + return b.names[i] < b.names[j] + }) +} + +// writePackage writes the package at the top of the file. +func (b *hashBuilder) writePackage(w io.Writer) { + if _, err := fmt.Fprintf(w, packageFormat, b.pkg.Name); err != nil { + log.Fatalln(err) + } + if _, err := fmt.Fprintf(w, "import \"github.com/df-mc/dragonfly/server/world\"\n\n"); err != nil { + log.Fatalln(err) + } +} + +// writeConstants writes hash constants for every block to a file. +func (b *hashBuilder) writeConstants(w io.Writer) { + if _, err := fmt.Fprintln(w, "const ("); err != nil { + log.Fatalln(err) + } + + for i, name := range b.names { + c := constFormat + if i == 0 { + c += " = iota" + } + + if _, err := fmt.Fprintf(w, c+"\n", name); err != nil { + log.Fatalln(err) + } + } + + if _, err := fmt.Fprintln(w, "\thashCustomBlockBase\n)"); err != nil { + log.Fatalln(err) + } +} + +func (b *hashBuilder) writeNextHash(w io.Writer) { + if _, err := fmt.Fprintln(w, "\n// customBlockBase represents the base hash for all custom blocks."); err != nil { + log.Fatalln(err) + } + if _, err := fmt.Fprintln(w, "var customBlockBase = uint64(hashCustomBlockBase - 1)"); err != nil { + log.Fatalln(err) + } + if _, err := fmt.Fprintln(w, "\n// NextHash returns the next free hash for custom blocks."); err != nil { + log.Fatalln(err) + } + if _, err := fmt.Fprintln(w, "func NextHash() uint64 {\n\tcustomBlockBase++\n\treturn customBlockBase\n}"); err != nil { + log.Fatalln(err) + } +} + +func (b *hashBuilder) writeMethods(w io.Writer) { + for _, name := range b.names { + fields := b.blockFields[name] + + var h string + var bitSize int + + fun := b.funcs[name] + var recvName string + for _, n := range fun.Recv.List[0].Names { + recvName = n.Name + } + pos := b.pkg.Fset.Position(fun.Body.Pos()) + f, err := os.Open(pos.Filename) + if err != nil { + log.Fatalln(err) + } + body := make([]byte, fun.Body.End()-fun.Body.Pos()) + + if _, err := f.ReadAt(body, int64(pos.Offset)); err != nil { + log.Fatalln(err) + } + _ = f.Close() + + for _, field := range fields { + for _, fieldName := range field.Names { + if !bytes.Contains(body, []byte(fieldName.Name)) { + // Field was not used in the EncodeBlock method, so we can assume it's not a property and thus + // should not be in the Hash method. + continue + } + if !fieldName.IsExported() { + continue + } + directives := make(map[string]string) + if field.Doc != nil { + for _, d := range field.Doc.List { + const k = "//blockhash:" + if index := strings.Index(d.Text, k); index != -1 { + dir := strings.Split(d.Text[index+len(k):], " ") + directives[dir[0]] = strings.Join(dir[1:], " ") + } + } + } + str, v := b.ftype(name, recvName+"."+fieldName.Name, field.Type, directives) + if v == 0 { + // Assume this field is not used in the hash. + continue + } + + if bitSize > 64 { + log.Println("Hash size of block properties of", name, "exceeds 64 bits. Please look at this manually.") + } else { + if h == "" { + h += str + } else { + h += " | " + str + } + if bitSize > 0 { + h += "<<" + strconv.Itoa(bitSize) + } + } + bitSize += v + } + } + if bitSize == 0 { + // No need to have a receiver name if we don't use any of the fields of the block. + recvName = "" + } + + if recvName != "" { + recvName += " " + } + if h == "" { + h = "0" + } + + if _, err := fmt.Fprintf(w, methodFormat, recvName, name, "hash"+name, h); err != nil { + log.Fatalln(err) + } + } +} + +func (b *hashBuilder) ftype(structName, s string, expr ast.Expr, directives map[string]string) (string, int) { + var name string + switch t := expr.(type) { + case *ast.BasicLit: + name = t.Value + case *ast.Ident: + name = t.Name + case *ast.SelectorExpr: + name = t.Sel.Name + case *ast.StarExpr: + return "", 0 // Ignore this field + default: + log.Fatalf("unknown field type %#v\n", expr) + return "", 0 + } + switch name { + case "bool": + return "uint64(boolByte(" + s + "))", 1 + case "int": + return "uint64(" + s + ")", 8 + case "Block": + return "world.BlockHash(" + s + ")", 32 + case "Attachment": + if _, ok := directives["facing_only"]; ok { + log.Println("Found directive: 'facing_only'") + return "uint64(" + s + ".FaceUint8())", 3 + } + return "uint64(" + s + ".Uint8())", 5 + case "GrindstoneAttachment": + return "uint64(" + s + ".Uint8())", 2 + case "WoodType", "LeavesType", "FlowerType", "DoubleFlowerType", "Colour": + // Assuming these were all based on metadata, it should be safe to assume a bit size of 4 for this. + return "uint64(" + s + ".Uint8())", 4 + case "CoralType", "SkullType": + return "uint64(" + s + ".Uint8())", 3 + case "AnvilType", "SandstoneType", "PrismarineType", "StoneBricksType", "NetherBricksType", "FroglightType", + "WallConnectionType", "BlackstoneType", "DeepslateType", "TallGrassType", "CopperType", "OxidationType": + return "uint64(" + s + ".Uint8())", 2 + case "OreType", "FireType", "DoubleTallGrassType": + return "uint64(" + s + ".Uint8())", 1 + case "Direction", "Axis": + return "uint64(" + s + ")", 2 + case "Face": + return "uint64(" + s + ")", 3 + default: + log.Println("Found unhandled field type", "'"+name+"'", "in block", structName+".", "Assuming this field is not included in block states. Please make sure this is correct or add the type to cmd/blockhash.") + } + return "", 0 +} + +func (b *hashBuilder) resolveBlocks() { + for bl, fields := range b.fields { + if _, ok := b.funcs[bl]; ok { + b.blockFields[bl] = fields + } + } +} + +func (b *hashBuilder) readFuncs(pkg *packages.Package) { + for _, f := range pkg.Syntax { + ast.Inspect(f, b.readFuncDecls) + } +} + +func (b *hashBuilder) readFuncDecls(node ast.Node) bool { + if fun, ok := node.(*ast.FuncDecl); ok { + // If the function is called 'EncodeBlock' and the receiver is not nil, meaning the function is a method, this + // is an implementation of the world.Block interface. + if fun.Name.Name == "EncodeBlock" && fun.Recv != nil { + b.funcs[fun.Recv.List[0].Type.(*ast.Ident).Name] = fun + } + } + return true +} + +func (b *hashBuilder) readStructFields(pkg *packages.Package) { + for _, f := range pkg.Syntax { + ast.Inspect(f, b.readStructs) + } + b.resolveEmbedded() + b.resolveAliases() +} + +func (b *hashBuilder) resolveAliases() { + for name, alias := range b.aliases { + b.fields[name] = b.findFields(alias) + } +} + +func (b *hashBuilder) findFields(structName string) []*ast.Field { + for { + if fields, ok := b.fields[structName]; ok { + // Alias found in the fields map, so it referred to a struct directly. + return fields + } + if nested, ok := b.aliases[structName]; ok { + // The alias itself was an alias, so continue with the next. + structName = nested + continue + } + // Neither an alias nor a struct: Break as this isn't going to go anywhere. + return nil + } +} + +func (b *hashBuilder) resolveEmbedded() { + for name, fields := range b.fields { + if _, ok := b.handled[name]; ok { + // Don't handle if a previous run already handled this struct. + continue + } + newFields := make([]*ast.Field, 0, len(fields)) + for _, f := range fields { + if len(f.Names) == 0 { + // We're dealing with an embedded struct here. They're of the type ast.Ident. + if ident, ok := f.Type.(*ast.Ident); ok { + for _, af := range b.findFields(ident.Name) { + if len(af.Names) == 0 { + // The struct this referred is embedding a struct itself which hasn't yet been processed, + // so we need to rerun and hope that struct is handled next. This isn't a very elegant way, + // and could lead to a lot of runs, but in general it's fast enough and does the job. + b.resolveEmbedded() + return + } + } + newFields = append(newFields, b.findFields(ident.Name)...) + } + } else { + newFields = append(newFields, f) + } + } + // Make sure a next run doesn't end up handling this struct again. + b.handled[name] = struct{}{} + b.fields[name] = newFields + } +} + +func (b *hashBuilder) readStructs(node ast.Node) bool { + if s, ok := node.(*ast.TypeSpec); ok { + switch t := s.Type.(type) { + case *ast.StructType: + b.fields[s.Name.Name] = t.Fields.List + case *ast.Ident: + // This is a type created something like 'type Andesite polishable': A type alias. We need to handle + // these later, first parse all struct types. + b.aliases[s.Name.Name] = t.Name + } + } + return true +} diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..5ea66be --- /dev/null +++ b/config.toml @@ -0,0 +1,48 @@ + +[Network] + # The address of the server, including the port. The server will be listening on this address. If another + # server is already running on this port, please select a different port. + Address = ":19132" + +[Server] + # The name as it shows up in the server list. Minecraft colour codes may be used in this name to format the + # name of the server. + Name = "NT Server" + # AuthEnabled controls whether players must be connected to Xbox Live in order to join the server. + AuthEnabled = true + # DisableJoinQuitMessages specifies if join/quit messages should be broadcast when players join the server. + DisableJoinQuitMessages = false + # MuteEmoteChat specifies if the player emote chat should be muted or not. + MuteEmoteChat = false + +[World] + # The folder that the world files (will) reside in, relative to the working directory. If not currently + # present, the folder will be made. + Folder = "world" + # Whether the worlds' data will be saved and loaded. If true, the server will use the + # default LevelDB data provider and if false, an empty provider will be used. To use your + # own provider, turn this value to false, as you will still be able to pass your own provider. + SaveData = true + +[Players] + # The maximum amount of players accepted into the server. If set to 0, there is no player limit. The max + # player count will increase as more players join. + MaxCount = 0 + # The maximum chunk radius that players may set in their settings. If they try to set it above this number, + # it will be capped and set to the max. + MaximumChunkRadius = 32 + # Whether a player's data will be saved and loaded. If true, the server will use the + # default LevelDB data provider and if false, an empty provider will be used. To use your + # own provider, turn this value to false, as you will still be able to pass your own provider. + SaveData = true + # Folder controls where the player data will be stored by the default LevelDB + # player provider if it is enabled. + Folder = "players" + +[Resources] + # AutoBuildPack is if the server should automatically generate a resource pack for custom features. + AutoBuildPack = true + # Folder configures the directory used by the server to load resource packs. + Folder = "resources" + # Required configures whether the server will require players to have a resource pack to join. + Required = true diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..ef5e0bc --- /dev/null +++ b/doc.go @@ -0,0 +1,2 @@ +// https://pkg.go.dev/github.com/df-mc/dragonfly/server +package main diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..381b9df --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module github.com/df-mc/dragonfly + +go 1.26.0 + +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.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.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 + golang.org/x/text v0.23.0 + golang.org/x/tools v0.28.0 +) + +require ( + github.com/coreos/go-oidc/v3 v3.17.0 // indirect + github.com/df-mc/go-playfab v1.0.0 // indirect + github.com/df-mc/go-xsapi v1.0.1 // indirect + github.com/df-mc/jsonc v1.0.5 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/klauspost/compress v1.18.4 // indirect + github.com/sandertv/go-raknet v1.15.1-0.20260112202637-beca0b10c217 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/oauth2 v0.28.0 // indirect + golang.org/x/sync v0.12.0 // indirect + gopkg.in/yaml.v2 v2.3.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4bc90da --- /dev/null +++ b/go.sum @@ -0,0 +1,75 @@ +github.com/brentp/intintmap v0.0.0-20251106190759-56907b1f8479 h1:UZbbt19ACBOFO+CiDQFjaEoPJkBhj7GNGtIq59WR6Os= +github.com/brentp/intintmap v0.0.0-20251106190759-56907b1f8479/go.mod h1:TOk10ahXejq9wkEaym3KPRNeuR/h5Jx+s8QRWIa2oTM= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/df-mc/go-playfab v1.0.0 h1:6gVukk3aQbJ934GJFdcZJHVIw9lhauK+KHOevbwJA10= +github.com/df-mc/go-playfab v1.0.0/go.mod h1:nGOlE+JFGOH5Z0iidEgJapHhndFi/oNk17RN9pKCF+k= +github.com/df-mc/go-xsapi v1.0.1 h1:H1SbxYr4rXOqZSB8MwiODbDUsHRihxbHf+YOljUWgXw= +github.com/df-mc/go-xsapi v1.0.1/go.mod h1:uKC/a/2/JOamgRDezvgVe7OmXdqERUfmCcIWAOp9hPA= +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.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= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +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.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= +golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= +golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= +golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= +golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/main.go b/main.go new file mode 100644 index 0000000..dd11718 --- /dev/null +++ b/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "fmt" + "log/slog" + "os" + + "github.com/df-mc/dragonfly/server" + "github.com/df-mc/dragonfly/server/player/chat" + "github.com/df-mc/dragonfly/server/world" + "github.com/pelletier/go-toml" +) + +func main() { + slog.SetLogLoggerLevel(slog.LevelDebug) + + chat.Global.Subscribe(chat.StdoutSubscriber{}) + + conf, err := readConfig(slog.Default()) + if err != nil { + panic(err) + } + + srv := conf.New() + srv.CloseOnProgramEnd() + + srv.Listen() + + for p := range srv.Accept() { + if p.Name() == "XTarnaWijaya" { + p.SetGameMode(world.GameModeCreative) + p.Message("Kamu adalah owner!") + } else { + p.SetGameMode(world.GameModeSurvival) + } + } +} + +func readConfig(log *slog.Logger) (server.Config, error) { + c := server.DefaultConfig() + var zero server.Config + + if _, err := os.Stat("config.toml"); os.IsNotExist(err) { + data, err := toml.Marshal(c) + if err != nil { + return zero, fmt.Errorf("encode default config: %v", err) + } + + if err := os.WriteFile("config.toml", data, 0644); err != nil { + return zero, fmt.Errorf("create default config: %v", err) + } + + return c.Config(log) + } + + data, err := os.ReadFile("config.toml") + if err != nil { + return zero, fmt.Errorf("read config: %v", err) + } + + if err := toml.Unmarshal(data, &c); err != nil { + return zero, fmt.Errorf("decode config: %v", err) + } + + return c.Config(log) +} diff --git a/server/allower.go b/server/allower.go new file mode 100644 index 0000000..16c4f6f --- /dev/null +++ b/server/allower.go @@ -0,0 +1,27 @@ +package server + +import ( + "github.com/sandertv/gophertunnel/minecraft/protocol/login" + "net" +) + +// Allower may be implemented to specifically allow or disallow players from +// joining a Server, by setting the specific Allower implementation through a +// call to Server.Allow. +type Allower interface { + // Allow filters what connections are allowed to connect to the Server. The + // address, identity data, and client data of the connection are passed. If + // Admit returns false, the connection is closed with the string returned as + // the disconnect message. WARNING: Use the client data at your own risk, it + // cannot be trusted because it can be freely changed by the player + // connecting. + Allow(addr net.Addr, d login.IdentityData, c login.ClientData) (string, bool) +} + +// allower is the standard Allower implementation. It accepts all connections. +type allower struct{} + +// Allow always returns true. +func (allower) Allow(net.Addr, login.IdentityData, login.ClientData) (string, bool) { + return "", true +} diff --git a/server/block/action.go b/server/block/action.go new file mode 100644 index 0000000..f4faa49 --- /dev/null +++ b/server/block/action.go @@ -0,0 +1,42 @@ +package block + +import "time" + +// OpenAction is a world.BlockAction to open a block at a position. It is sent for blocks such as chests. +type OpenAction struct{ action } + +// CloseAction is a world.BlockAction to close a block at a position, complementary to the OpenAction action. +type CloseAction struct{ action } + +// StartCrackAction is a world.BlockAction to make the cracks in a block start forming, following the break time set in +// the action. +type StartCrackAction struct { + action + BreakTime time.Duration +} + +// ContinueCrackAction is a world.BlockAction sent every so often to continue the cracking process of the block. It is +// only ever sent after a StartCrackAction action, and may have an altered break time if the player is not on the +// ground, submerged or is using a different item than at first. +type ContinueCrackAction struct { + action + BreakTime time.Duration +} + +// StopCrackAction is a world.BlockAction to make the cracks forming in a block stop and disappear. +type StopCrackAction struct{ action } + +// DecoratedPotWobbleAction is a world.BlockAction to make a decorated pot wobble when interacted with. +type DecoratedPotWobbleAction struct { + action + DecoratedPot DecoratedPot + // Success is whether an item was successfully inserted into the decorated pot. + Success bool +} + +// action implements the Action interface. Structures in this package may embed it to gets its functionality +// out of the box. +type action struct{} + +// BlockAction serves to implement the world.BlockAction interface. +func (action) BlockAction() {} diff --git a/server/block/air.go b/server/block/air.go new file mode 100644 index 0000000..895946b --- /dev/null +++ b/server/block/air.go @@ -0,0 +1,23 @@ +package block + +// Air is the block present in otherwise empty space. +type Air struct { + empty + replaceable + transparent +} + +// HasLiquidDrops ... +func (Air) HasLiquidDrops() bool { + return false +} + +// EncodeItem ... +func (Air) EncodeItem() (name string, meta int16) { + return "minecraft:air", 0 +} + +// EncodeBlock ... +func (Air) EncodeBlock() (string, map[string]any) { + return "minecraft:air", nil +} diff --git a/server/block/amethyst.go b/server/block/amethyst.go new file mode 100644 index 0000000..2fc091b --- /dev/null +++ b/server/block/amethyst.go @@ -0,0 +1,21 @@ +package block + +// Amethyst is a decorative block crafted from four amethyst shards. +type Amethyst struct { + solid +} + +// BreakInfo ... +func (a Amethyst) BreakInfo() BreakInfo { + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeHarvestable, oneOf(a)) +} + +// EncodeItem ... +func (Amethyst) EncodeItem() (name string, meta int16) { + return "minecraft:amethyst_block", 0 +} + +// EncodeBlock ... +func (Amethyst) EncodeBlock() (string, map[string]any) { + return "minecraft:amethyst_block", nil +} diff --git a/server/block/ancient_debris.go b/server/block/ancient_debris.go new file mode 100644 index 0000000..129c239 --- /dev/null +++ b/server/block/ancient_debris.go @@ -0,0 +1,32 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" +) + +// AncientDebris is a rare ore found within The Nether. +type AncientDebris struct { + solid +} + +// BreakInfo ... +func (a AncientDebris) BreakInfo() BreakInfo { + return newBreakInfo(30, func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierDiamond.HarvestLevel + }, pickaxeEffective, oneOf(a)).withBlastResistance(6000) +} + +// SmeltInfo ... +func (AncientDebris) SmeltInfo() item.SmeltInfo { + return newOreSmeltInfo(item.NewStack(item.NetheriteScrap{}, 1), 2) +} + +// EncodeItem ... +func (AncientDebris) EncodeItem() (name string, meta int16) { + return "minecraft:ancient_debris", 0 +} + +// EncodeBlock ... +func (AncientDebris) EncodeBlock() (string, map[string]any) { + return "minecraft:ancient_debris", nil +} diff --git a/server/block/anvil.go b/server/block/anvil.go new file mode 100644 index 0000000..a4e5f04 --- /dev/null +++ b/server/block/anvil.go @@ -0,0 +1,102 @@ +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/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" +) + +// Anvil is a block that allows players to repair items, rename items, and combine enchantments. +type Anvil struct { + gravityAffected + transparent + + // Type is the type of anvil. + Type AnvilType + // Facing is the direction that the anvil is facing. + Facing cube.Direction +} + +// Model ... +func (a Anvil) Model() world.BlockModel { + return model.Anvil{Facing: a.Facing} +} + +// BreakInfo ... +func (a Anvil) BreakInfo() BreakInfo { + return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(a)).withBlastResistance(6000) +} + +// Activate ... +func (Anvil) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + if opener, ok := u.(ContainerOpener); ok { + opener.OpenBlockContainer(pos, tx) + return true + } + return false +} + +// UseOnBlock ... +func (a Anvil) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, a) + if !used { + return + } + a.Facing = user.Rotation().Direction().RotateLeft() + place(tx, pos, a, user, ctx) + return placed(ctx) +} + +// NeighbourUpdateTick ... +func (a Anvil) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + a.fall(a, pos, tx) +} + +// Damage returns the damage per block fallen of the anvil and the maximum damage the anvil can deal. +func (Anvil) Damage() (damagePerBlock, maxDamage float64) { + return 2, 40 +} + +// Break breaks the anvil and moves it to the next damage stage. If the anvil is at the last damage stage, it will be +// destroyed. +func (a Anvil) Break() world.Block { + switch a.Type { + case UndamagedAnvil(): + a.Type = SlightlyDamagedAnvil() + case SlightlyDamagedAnvil(): + a.Type = VeryDamagedAnvil() + case VeryDamagedAnvil(): + return Air{} + } + return a +} + +// Landed is called when a falling anvil hits the ground, used to, for example, play a sound. +func (Anvil) Landed(tx *world.Tx, pos cube.Pos) { + tx.PlaySound(pos.Vec3Centre(), sound.AnvilLand{}) +} + +// EncodeItem ... +func (a Anvil) EncodeItem() (name string, meta int16) { + return "minecraft:" + a.Type.String(), 0 +} + +// EncodeBlock ... +func (a Anvil) EncodeBlock() (string, map[string]any) { + return "minecraft:" + a.Type.String(), map[string]any{ + "minecraft:cardinal_direction": a.Facing.String(), + } +} + +// allAnvils ... +func allAnvils() (anvils []world.Block) { + for _, t := range AnvilTypes() { + for _, d := range cube.Directions() { + anvils = append(anvils, Anvil{Type: t, Facing: d}) + } + } + return +} diff --git a/server/block/anvil_type.go b/server/block/anvil_type.go new file mode 100644 index 0000000..b42d3d4 --- /dev/null +++ b/server/block/anvil_type.go @@ -0,0 +1,46 @@ +package block + +// AnvilType represents a type of anvil, such as undamaged, slightly damaged, or very damaged. +type AnvilType struct { + anvil +} + +// UndamagedAnvil returns the undamaged anvil type. +func UndamagedAnvil() AnvilType { + return AnvilType{0} +} + +// SlightlyDamagedAnvil returns the slightly damaged anvil type. +func SlightlyDamagedAnvil() AnvilType { + return AnvilType{1} +} + +// VeryDamagedAnvil returns the very damaged anvil type. +func VeryDamagedAnvil() AnvilType { + return AnvilType{2} +} + +// AnvilTypes returns all anvil types. +func AnvilTypes() []AnvilType { + return []AnvilType{UndamagedAnvil(), SlightlyDamagedAnvil(), VeryDamagedAnvil()} +} + +type anvil uint8 + +// Uint8 returns the anvil type as a uint8. +func (a anvil) Uint8() uint8 { + return uint8(a) +} + +// String returns the anvil type as a string. +func (a anvil) String() string { + switch a { + case 0: + return "anvil" + case 1: + return "chipped_anvil" + case 2: + return "damaged_anvil" + } + panic("should never happen") +} diff --git a/server/block/attachment.go b/server/block/attachment.go new file mode 100644 index 0000000..3318644 --- /dev/null +++ b/server/block/attachment.go @@ -0,0 +1,64 @@ +package block + +import "github.com/df-mc/dragonfly/server/block/cube" + +// Attachment describes the attachment of a block to another block. It is either of the type WallAttachment, which can +// only have 90 degree facing values, or StandingAttachment, which has more freedom using a cube.Orientation. +type Attachment struct { + hanging bool + facing cube.Direction + o cube.Orientation +} + +// WallAttachment returns an Attachment to a wall with a facing direction. +func WallAttachment(facing cube.Direction) Attachment { + return Attachment{hanging: true, facing: facing} +} + +// StandingAttachment returns an Attachment to the ground with an orientation. +func StandingAttachment(o cube.Orientation) Attachment { + return Attachment{o: o} +} + +// Uint8 returns the Attachment as a uint8. +func (a Attachment) Uint8() uint8 { + if !a.hanging { + return 1 | (uint8(a.o) << 1) + } + return uint8(a.facing) << 1 +} + +// FaceUint8 returns the facing of the Attachment as a uint8. +func (a Attachment) FaceUint8() uint8 { + if !a.hanging { + return 1 + } + return uint8(a.facing) << 1 +} + +// RotateLeft rotates the Attachment the left way around by 90 degrees. +func (a Attachment) RotateLeft() Attachment { + return Attachment{hanging: a.hanging, facing: a.facing.RotateLeft(), o: a.o.RotateLeft()} +} + +// RotateRight rotates the Attachment the right way around by 90 degrees. +func (a Attachment) RotateRight() Attachment { + return Attachment{hanging: a.hanging, facing: a.facing.RotateLeft(), o: a.o.RotateLeft()} +} + +// Rotation returns the rotation of the Attachment, based on the orientation if it's a StandingAttachment, or the +// facing direction if it is a WallAttachment. +func (a Attachment) Rotation() cube.Rotation { + yaw := a.o.Yaw() + if a.hanging { + switch a.facing { + case cube.West: + yaw = 90 + case cube.East: + yaw = -90 + case cube.North: + yaw = 180 + } + } + return cube.Rotation{yaw} +} diff --git a/server/block/bamboo_block.go b/server/block/bamboo_block.go new file mode 100644 index 0000000..c03a1a0 --- /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 0000000..5bd89fd --- /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/banner.go b/server/block/banner.go new file mode 100644 index 0000000..6efa952 --- /dev/null +++ b/server/block/banner.go @@ -0,0 +1,137 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" + "time" +) + +// Banner is a tall decorative block that can be customised. +type Banner struct { + empty + transparent + + // Colour is the colour of the banner. + Colour item.Colour + // Attach is the attachment of the Banner. It is either of the type WallAttachment or StandingAttachment. + Attach Attachment + // Patterns represents the patterns the Banner should show when rendering. + Patterns []BannerPatternLayer + // Illager returns true if the banner is an illager banner. + Illager bool +} + +// Pick ... +func (b Banner) Pick() item.Stack { + return item.NewStack(Banner{Colour: b.Colour, Patterns: b.Patterns, Illager: b.Illager}, 1) +} + +// MaxCount ... +func (Banner) MaxCount() int { + return 16 +} + +// BreakInfo ... +func (b Banner) BreakInfo() BreakInfo { + return newBreakInfo(1, alwaysHarvestable, axeEffective, oneOf(b)) +} + +// FuelInfo ... +func (Banner) FuelInfo() item.FuelInfo { + return newFuelInfo(time.Second * 15) +} + +// UseOnBlock ... +func (b Banner) 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 || face == cube.FaceDown { + return false + } + + if face == cube.FaceUp { + b.Attach = StandingAttachment(user.Rotation().Orientation().Opposite()) + place(tx, pos, b, user, ctx) + return + } + b.Attach = WallAttachment(face.Direction()) + place(tx, pos, b, user, ctx) + return placed(ctx) +} + +// NeighbourUpdateTick ... +func (b Banner) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if b.Attach.hanging { + if _, ok := tx.Block(pos.Side(b.Attach.facing.Opposite().Face())).(Air); ok { + breakBlock(b, pos, tx) + } + } else if _, ok := tx.Block(pos.Side(cube.FaceDown)).(Air); ok { + breakBlock(b, pos, tx) + } +} + +// EncodeItem ... +func (b Banner) EncodeItem() (name string, meta int16) { + return "minecraft:banner", invertColour(b.Colour) +} + +// EncodeBlock ... +func (b Banner) EncodeBlock() (name string, properties map[string]any) { + if b.Attach.hanging { + return "minecraft:wall_banner", map[string]any{"facing_direction": int32(b.Attach.facing + 2)} + } + return "minecraft:standing_banner", map[string]any{"ground_sign_direction": int32(b.Attach.o)} +} + +// EncodeNBT ... +func (b Banner) EncodeNBT() map[string]any { + patterns := make([]any, 0, len(b.Patterns)) + for _, p := range b.Patterns { + patterns = append(patterns, p.EncodeNBT()) + } + return map[string]any{ + "id": "Banner", + "Patterns": patterns, + "Type": int32(boolByte(b.Illager)), + "Base": int32(invertColour(b.Colour)), + } +} + +// DecodeNBT ... +func (b Banner) DecodeNBT(m map[string]any) any { + if _, ok := m["Base"]; ok { + // Banner items do not have the Base NBT. + b.Colour = invertColourID(int16(nbtconv.Int32(m, "Base"))) + } + b.Illager = nbtconv.Int32(m, "Type") == 1 + if patterns := nbtconv.Slice(m, "Patterns"); patterns != nil { + b.Patterns = make([]BannerPatternLayer, len(patterns)) + for i, p := range b.Patterns { + b.Patterns[i] = p.DecodeNBT(patterns[i].(map[string]any)).(BannerPatternLayer) + } + } + return b +} + +// invertColour converts the item.Colour passed and returns the colour ID inverted. +func invertColour(c item.Colour) int16 { + return ^int16(c.Uint8()) & 0xf +} + +// invertColourID converts the int16 passed the returns the item.Colour inverted. +func invertColourID(id int16) item.Colour { + return item.Colours()[uint8(^id&0xf)] +} + +// allBanners returns all possible banners. +func allBanners() (banners []world.Block) { + for _, d := range cube.Directions() { + banners = append(banners, Banner{Attach: WallAttachment(d)}) + } + for o := cube.Orientation(0); o <= 15; o++ { + banners = append(banners, Banner{Attach: StandingAttachment(o)}) + } + return +} diff --git a/server/block/banner_pattern_layer.go b/server/block/banner_pattern_layer.go new file mode 100644 index 0000000..c47d509 --- /dev/null +++ b/server/block/banner_pattern_layer.go @@ -0,0 +1,36 @@ +package block + +import ( + "fmt" + + "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/item" +) + +// BannerPatternLayer is a wrapper over BannerPatternType with a colour property. +type BannerPatternLayer struct { + // Type represents the type of banner pattern. + Type BannerPatternType + // Colour is the colour the pattern should be rendered in. + Colour item.Colour +} + +// EncodeNBT encodes the given BannerPatternLayer into an NBT map. +func (b BannerPatternLayer) EncodeNBT() map[string]any { + return map[string]any{ + "Pattern": bannerPatternID(b.Type), + "Color": int32(invertColour(b.Colour)), + } +} + +// DecodeNBT decodes the given NBT map into a BannerPatternLayer and returns it. +func (b BannerPatternLayer) DecodeNBT(data map[string]any) any { + id := nbtconv.String(data, "Pattern") + pattern, exists := BannerPatternByID(id) + if !exists { + panic(fmt.Errorf("unknown banner pattern id %q", id)) + } + b.Type = pattern + b.Colour = invertColourID(int16(nbtconv.Int32(data, "Color"))) + return b +} diff --git a/server/block/banner_pattern_type.go b/server/block/banner_pattern_type.go new file mode 100644 index 0000000..b577124 --- /dev/null +++ b/server/block/banner_pattern_type.go @@ -0,0 +1,391 @@ +package block + +import "github.com/df-mc/dragonfly/server/item" + +// BannerPatternType represents a type of banner pattern, used to customise banners. +type BannerPatternType struct { + bannerPatternType +} + +// BorderBannerPattern represents the 'Border' banner pattern type. +func BorderBannerPattern() BannerPatternType { + return BannerPatternType{0} +} + +// BricksBannerPattern represents the 'Bricks' banner pattern type. +func BricksBannerPattern() BannerPatternType { + return BannerPatternType{1} +} + +// CircleBannerPattern represents the 'Circle' banner pattern type. +func CircleBannerPattern() BannerPatternType { + return BannerPatternType{2} +} + +// CreeperBannerPattern represents the 'Creeper' banner pattern type. +func CreeperBannerPattern() BannerPatternType { + return BannerPatternType{3} +} + +// CrossBannerPattern represents the 'Cross' banner pattern type. +func CrossBannerPattern() BannerPatternType { + return BannerPatternType{4} +} + +// CurlyBorderBannerPattern represents the 'Curly Border' banner pattern type. +func CurlyBorderBannerPattern() BannerPatternType { + return BannerPatternType{5} +} + +// DiagonalLeftBannerPattern represents the 'Diagonal Left' banner pattern type. +func DiagonalLeftBannerPattern() BannerPatternType { + return BannerPatternType{6} +} + +// DiagonalRightBannerPattern represents the 'Diagonal Right' banner pattern type. +func DiagonalRightBannerPattern() BannerPatternType { + return BannerPatternType{7} +} + +// DiagonalUpLeftBannerPattern represents the 'Diagonal Up Left' banner pattern type. +func DiagonalUpLeftBannerPattern() BannerPatternType { + return BannerPatternType{8} +} + +// DiagonalUpRightBannerPattern represents the 'Diagonal Up Right' banner pattern type. +func DiagonalUpRightBannerPattern() BannerPatternType { + return BannerPatternType{9} +} + +// FlowerBannerPattern represents the 'Flower' banner pattern type. +func FlowerBannerPattern() BannerPatternType { + return BannerPatternType{10} +} + +// GradientBannerPattern represents the 'Gradient' banner pattern type. +func GradientBannerPattern() BannerPatternType { + return BannerPatternType{11} +} + +// GradientUpBannerPattern represents the 'Gradient Up' banner pattern type. +func GradientUpBannerPattern() BannerPatternType { + return BannerPatternType{12} +} + +// HalfHorizontalBannerPattern represents the 'Half Horizontal' banner pattern type. +func HalfHorizontalBannerPattern() BannerPatternType { + return BannerPatternType{13} +} + +// HalfHorizontalBottomBannerPattern represents the 'Half Horizontal Bottom' banner pattern type. +func HalfHorizontalBottomBannerPattern() BannerPatternType { + return BannerPatternType{14} +} + +// HalfVerticalBannerPattern represents the 'Half Vertical' banner pattern type. +func HalfVerticalBannerPattern() BannerPatternType { + return BannerPatternType{15} +} + +// HalfVerticalRightBannerPattern represents the 'Half Vertical Right' banner pattern type. +func HalfVerticalRightBannerPattern() BannerPatternType { + return BannerPatternType{16} +} + +// MojangBannerPattern represents the 'Mojang' banner pattern type. +func MojangBannerPattern() BannerPatternType { + return BannerPatternType{17} +} + +// RhombusBannerPattern represents the 'Rhombus' banner pattern type. +func RhombusBannerPattern() BannerPatternType { + return BannerPatternType{18} +} + +// SkullBannerPattern represents the 'Skull' banner pattern type. +func SkullBannerPattern() BannerPatternType { + return BannerPatternType{19} +} + +// SmallStripesBannerPattern represents the 'Small Stripes' banner pattern type. +func SmallStripesBannerPattern() BannerPatternType { + return BannerPatternType{20} +} + +// SquareBottomLeftBannerPattern represents the 'Square Bottom Left' banner pattern type. +func SquareBottomLeftBannerPattern() BannerPatternType { + return BannerPatternType{21} +} + +// SquareBottomRightBannerPattern represents the 'Square Bottom Right' banner pattern type. +func SquareBottomRightBannerPattern() BannerPatternType { + return BannerPatternType{22} +} + +// SquareTopLeftBannerPattern represents the 'Square Top Left' banner pattern type. +func SquareTopLeftBannerPattern() BannerPatternType { + return BannerPatternType{23} +} + +// SquareTopRightBannerPattern represents the 'Square Top Right' banner pattern type. +func SquareTopRightBannerPattern() BannerPatternType { + return BannerPatternType{24} +} + +// StraightCrossBannerPattern represents the 'Straight Cross' banner pattern type. +func StraightCrossBannerPattern() BannerPatternType { + return BannerPatternType{25} +} + +// StripeBottomBannerPattern represents the 'Stripe Bottom' banner pattern type. +func StripeBottomBannerPattern() BannerPatternType { + return BannerPatternType{26} +} + +// StripeCentreBannerPattern represents the 'Stripe Center' banner pattern type. +func StripeCentreBannerPattern() BannerPatternType { + return BannerPatternType{27} +} + +// StripeDownLeftBannerPattern represents the 'Stripe Down Left' banner pattern type. +func StripeDownLeftBannerPattern() BannerPatternType { + return BannerPatternType{28} +} + +// StripeDownRightBannerPattern represents the 'Stripe Down Right' banner pattern type. +func StripeDownRightBannerPattern() BannerPatternType { + return BannerPatternType{29} +} + +// StripeLeftBannerPattern represents the 'Stripe Left' banner pattern type. +func StripeLeftBannerPattern() BannerPatternType { + return BannerPatternType{30} +} + +// StripeMiddleBannerPattern represents the 'Stripe Middle' banner pattern type. +func StripeMiddleBannerPattern() BannerPatternType { + return BannerPatternType{31} +} + +// StripeRightBannerPattern represents the 'Stripe Right' banner pattern type. +func StripeRightBannerPattern() BannerPatternType { + return BannerPatternType{32} +} + +// StripeTopBannerPattern represents the 'Stripe Top' banner pattern type. +func StripeTopBannerPattern() BannerPatternType { + return BannerPatternType{33} +} + +// TriangleBottomBannerPattern represents the 'Triangle Bottom' banner pattern type. +func TriangleBottomBannerPattern() BannerPatternType { + return BannerPatternType{34} +} + +// TriangleTopBannerPattern represents the 'Triangle Top' banner pattern type. +func TriangleTopBannerPattern() BannerPatternType { + return BannerPatternType{35} +} + +// TrianglesBottomBannerPattern represents the 'Triangles Bottom' banner pattern type. +func TrianglesBottomBannerPattern() BannerPatternType { + return BannerPatternType{36} +} + +// TrianglesTopBannerPattern represents the 'Triangles Top' banner pattern type. +func TrianglesTopBannerPattern() BannerPatternType { + return BannerPatternType{37} +} + +// GlobeBannerPattern represents the 'Globe' banner pattern type. +func GlobeBannerPattern() BannerPatternType { + return BannerPatternType{38} +} + +// PiglinBannerPattern represents the 'Piglin' banner pattern type. +func PiglinBannerPattern() BannerPatternType { + return BannerPatternType{39} +} + +// FlowBannerPattern represents the 'Flow' banner pattern type. +func FlowBannerPattern() BannerPatternType { + return BannerPatternType{40} +} + +// GusterBannerPattern represents the 'Guster' banner pattern type. +func GusterBannerPattern() BannerPatternType { + return BannerPatternType{41} +} + +// BannerPatternTypes returns all the available banner pattern types. +func BannerPatternTypes() []BannerPatternType { + return []BannerPatternType{ + BorderBannerPattern(), + BricksBannerPattern(), + CircleBannerPattern(), + CreeperBannerPattern(), + CrossBannerPattern(), + CurlyBorderBannerPattern(), + DiagonalLeftBannerPattern(), + DiagonalRightBannerPattern(), + DiagonalUpLeftBannerPattern(), + DiagonalUpRightBannerPattern(), + FlowerBannerPattern(), + GradientBannerPattern(), + GradientUpBannerPattern(), + HalfHorizontalBannerPattern(), + HalfHorizontalBottomBannerPattern(), + HalfVerticalBannerPattern(), + HalfVerticalRightBannerPattern(), + MojangBannerPattern(), + RhombusBannerPattern(), + SkullBannerPattern(), + SmallStripesBannerPattern(), + SquareBottomLeftBannerPattern(), + SquareBottomRightBannerPattern(), + SquareTopLeftBannerPattern(), + SquareTopRightBannerPattern(), + StraightCrossBannerPattern(), + StripeBottomBannerPattern(), + StripeCentreBannerPattern(), + StripeDownLeftBannerPattern(), + StripeDownRightBannerPattern(), + StripeLeftBannerPattern(), + StripeMiddleBannerPattern(), + StripeRightBannerPattern(), + StripeTopBannerPattern(), + TriangleBottomBannerPattern(), + TriangleTopBannerPattern(), + TrianglesBottomBannerPattern(), + TrianglesTopBannerPattern(), + GlobeBannerPattern(), + PiglinBannerPattern(), + FlowBannerPattern(), + GusterBannerPattern(), + } +} + +type bannerPatternType uint8 + +// Uint8 returns the bannerPatternType as a uint8. +func (b bannerPatternType) Uint8() uint8 { + return uint8(b) +} + +// String returns the bannerPatternType as a string. +func (b bannerPatternType) String() string { + switch b { + case 0: + return "border" + case 1: + return "bricks" + case 2: + return "circle" + case 3: + return "creeper" + case 4: + return "cross" + case 5: + return "curly_border" + case 6: + return "diagonal_left" + case 7: + return "diagonal_right" + case 8: + return "diagonal_up_left" + case 9: + return "diagonal_up_right" + case 10: + return "flower" + case 11: + return "gradient" + case 12: + return "gradient_up" + case 13: + return "half_horizontal" + case 14: + return "half_horizontal_bottom" + case 15: + return "half_vertical" + case 16: + return "half_vertical_right" + case 17: + return "mojang" + case 18: + return "rhombus" + case 19: + return "skull" + case 20: + return "small_stripes" + case 21: + return "square_bottom_left" + case 22: + return "square_bottom_right" + case 23: + return "square_top_left" + case 24: + return "square_top_right" + case 25: + return "straight_cross" + case 26: + return "stripe_bottom" + case 27: + return "stripe_center" + case 28: + return "stripe_downleft" + case 29: + return "stripe_downright" + case 30: + return "stripe_left" + case 31: + return "stripe_middle" + case 32: + return "stripe_right" + case 33: + return "stripe_top" + case 34: + return "triangle_bottom" + case 35: + return "triangle_top" + case 36: + return "triangles_bottom" + case 37: + return "triangles_top" + case 38: + return "globe" + case 39: + return "piglin" + case 40: + return "flow" + case 41: + return "guster" + } + panic("should never happen") +} + +// Item returns the equivalent item type for the pattern. If none exists, false is returned. +func (b bannerPatternType) Item() (item.BannerPatternType, bool) { + switch b { + case 1: + return item.FieldMasonedBannerPattern(), true + case 3: + return item.CreeperBannerPattern(), true + case 5: + return item.BordureIndentedBannerPattern(), true + case 10: + return item.FlowerBannerPattern(), true + case 17: + return item.MojangBannerPattern(), true + case 19: + return item.SkullBannerPattern(), true + case 38: + return item.GlobeBannerPattern(), true + case 39: + return item.PiglinBannerPattern(), true + case 40: + return item.FlowBannerPattern(), true + case 41: + return item.GusterBannerPattern(), true + } + return item.BannerPatternType{}, false +} diff --git a/server/block/banner_pattern_type_register.go b/server/block/banner_pattern_type_register.go new file mode 100644 index 0000000..b906b81 --- /dev/null +++ b/server/block/banner_pattern_type_register.go @@ -0,0 +1,74 @@ +package block + +var ( + bannerPatternsMap = map[string]BannerPatternType{} + bannerPatternIDs = map[BannerPatternType]string{} +) + +// init initialises all default banner patterns to the registry. +func init() { + registerBannerPattern("bo", BorderBannerPattern()) + registerBannerPattern("bri", BricksBannerPattern()) + registerBannerPattern("mc", CircleBannerPattern()) + registerBannerPattern("cre", CreeperBannerPattern()) + registerBannerPattern("cr", CrossBannerPattern()) + registerBannerPattern("cbo", CurlyBorderBannerPattern()) + registerBannerPattern("lud", DiagonalLeftBannerPattern()) + registerBannerPattern("rd", DiagonalRightBannerPattern()) + registerBannerPattern("ld", DiagonalUpLeftBannerPattern()) + registerBannerPattern("rud", DiagonalUpRightBannerPattern()) + registerBannerPattern("flo", FlowerBannerPattern()) + registerBannerPattern("gra", GradientBannerPattern()) + registerBannerPattern("gru", GradientUpBannerPattern()) + registerBannerPattern("hh", HalfHorizontalBannerPattern()) + registerBannerPattern("hhb", HalfHorizontalBottomBannerPattern()) + registerBannerPattern("vh", HalfVerticalBannerPattern()) + registerBannerPattern("vhr", HalfVerticalRightBannerPattern()) + registerBannerPattern("moj", MojangBannerPattern()) + registerBannerPattern("mr", RhombusBannerPattern()) + registerBannerPattern("sku", SkullBannerPattern()) + registerBannerPattern("ss", SmallStripesBannerPattern()) + registerBannerPattern("bl", SquareBottomLeftBannerPattern()) + registerBannerPattern("br", SquareBottomRightBannerPattern()) + registerBannerPattern("tl", SquareTopLeftBannerPattern()) + registerBannerPattern("tr", SquareTopRightBannerPattern()) + registerBannerPattern("sc", StraightCrossBannerPattern()) + registerBannerPattern("bs", StripeBottomBannerPattern()) + registerBannerPattern("cs", StripeCentreBannerPattern()) + registerBannerPattern("dls", StripeDownLeftBannerPattern()) + registerBannerPattern("drs", StripeDownRightBannerPattern()) + registerBannerPattern("ls", StripeLeftBannerPattern()) + registerBannerPattern("ms", StripeMiddleBannerPattern()) + registerBannerPattern("rs", StripeRightBannerPattern()) + registerBannerPattern("ts", StripeTopBannerPattern()) + registerBannerPattern("bt", TriangleBottomBannerPattern()) + registerBannerPattern("tt", TriangleTopBannerPattern()) + registerBannerPattern("bts", TrianglesBottomBannerPattern()) + registerBannerPattern("tts", TrianglesTopBannerPattern()) + registerBannerPattern("glb", GlobeBannerPattern()) + registerBannerPattern("pig", PiglinBannerPattern()) + registerBannerPattern("flw", FlowBannerPattern()) + registerBannerPattern("gus", GusterBannerPattern()) +} + +// registerBannerPattern registers a banner pattern with the ID passed. +func registerBannerPattern(id string, pattern BannerPatternType) { + bannerPatternsMap[id] = pattern + bannerPatternIDs[pattern] = id +} + +// BannerPatternByID returns a banner pattern by the ID it was registered with. Second return value describes whether +// a banner pattern with the ID was found. +func BannerPatternByID(id string) (BannerPatternType, bool) { + b, ok := bannerPatternsMap[id] + return b, ok +} + +// bannerPatternID returns the ID a banner pattern was registered with. +func bannerPatternID(pattern BannerPatternType) string { + id, ok := bannerPatternIDs[pattern] + if !ok { + panic("should never happen") + } + return id +} diff --git a/server/block/barrel.go b/server/block/barrel.go new file mode 100644 index 0000000..fb1e39d --- /dev/null +++ b/server/block/barrel.go @@ -0,0 +1,190 @@ +package block + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/inventory" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" + "strings" + "sync" + "time" +) + +// Barrel is a fisherman's job site block, used to store items. It functions like a single chest, although +// it requires no airspace above it to be opened. +type Barrel struct { + solid + bass + + // Facing is the direction that the barrel is facing. + Facing cube.Face + // Open is whether the barrel is open or not. + Open bool + // CustomName is the custom name of the barrel. This name is displayed when the barrel is opened, and may + // include colour codes. + CustomName string + + inventory *inventory.Inventory + viewerMu *sync.RWMutex + viewers map[ContainerViewer]struct{} +} + +// NewBarrel creates a new initialised barrel. The inventory is properly initialised. +func NewBarrel() Barrel { + m := new(sync.RWMutex) + v := make(map[ContainerViewer]struct{}, 1) + return Barrel{ + inventory: inventory.New(27, func(slot int, _, item item.Stack) { + m.RLock() + defer m.RUnlock() + for viewer := range v { + viewer.ViewSlotChange(slot, item) + } + }), + viewerMu: m, + viewers: v, + } +} + +// Inventory returns the inventory of the barrel. The size of the inventory will be 27. +func (b Barrel) Inventory(*world.Tx, cube.Pos) *inventory.Inventory { + return b.inventory +} + +// WithName returns the barrel after applying a specific name to the block. +func (b Barrel) WithName(a ...any) world.Item { + b.CustomName = strings.TrimSuffix(fmt.Sprintln(a...), "\n") + return b +} + +// open opens the barrel, displaying the animation and playing a sound. +func (b Barrel) open(tx *world.Tx, pos cube.Pos) { + b.Open = true + tx.PlaySound(pos.Vec3Centre(), sound.BarrelOpen{}) + tx.SetBlock(pos, b, nil) +} + +// close closes the barrel, displaying the animation and playing a sound. +func (b Barrel) close(tx *world.Tx, pos cube.Pos) { + b.Open = false + tx.PlaySound(pos.Vec3Centre(), sound.BarrelClose{}) + tx.SetBlock(pos, b, nil) +} + +// AddViewer adds a viewer to the barrel, so that it is updated whenever the inventory of the barrel is changed. +func (b Barrel) AddViewer(v ContainerViewer, tx *world.Tx, pos cube.Pos) { + b.viewerMu.Lock() + defer b.viewerMu.Unlock() + if len(b.viewers) == 0 { + b.open(tx, pos) + } + b.viewers[v] = struct{}{} +} + +// RemoveViewer removes a viewer from the barrel, so that slot updates in the inventory are no longer sent to +// it. +func (b Barrel) RemoveViewer(v ContainerViewer, tx *world.Tx, pos cube.Pos) { + b.viewerMu.Lock() + defer b.viewerMu.Unlock() + if len(b.viewers) == 0 { + return + } + delete(b.viewers, v) + if len(b.viewers) == 0 { + b.close(tx, pos) + } +} + +// Activate ... +func (b Barrel) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + if opener, ok := u.(ContainerOpener); ok { + opener.OpenBlockContainer(pos, tx) + return true + } + return false +} + +// UseOnBlock ... +func (b Barrel) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, b) + if !used { + return + } + //noinspection GoAssignmentToReceiver + b = NewBarrel() + b.Facing = calculateFace(user, pos) + + place(tx, pos, b, user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (b Barrel) BreakInfo() BreakInfo { + return newBreakInfo(2.5, alwaysHarvestable, axeEffective, oneOf(b)).withBreakHandler(func(pos cube.Pos, tx *world.Tx, u item.User) { + for _, i := range b.Inventory(tx, pos).Clear() { + dropItem(tx, i, pos.Vec3()) + } + }) +} + +// FlammabilityInfo ... +func (b Barrel) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(0, 0, true) +} + +// FuelInfo ... +func (Barrel) FuelInfo() item.FuelInfo { + return newFuelInfo(time.Second * 15) +} + +// DecodeNBT ... +func (b Barrel) DecodeNBT(data map[string]any) any { + facing := b.Facing + //noinspection GoAssignmentToReceiver + b = NewBarrel() + b.Facing = facing + b.CustomName = nbtconv.String(data, "CustomName") + nbtconv.InvFromNBT(b.inventory, nbtconv.Slice(data, "Items")) + return b +} + +// EncodeNBT ... +func (b Barrel) EncodeNBT() map[string]any { + if b.inventory == nil { + facing, customName := b.Facing, b.CustomName + //noinspection GoAssignmentToReceiver + b = NewBarrel() + b.Facing, b.CustomName = facing, customName + } + m := map[string]any{ + "Items": nbtconv.InvToNBT(b.inventory), + "id": "Barrel", + } + if b.CustomName != "" { + m["CustomName"] = b.CustomName + } + return m +} + +// EncodeBlock ... +func (b Barrel) EncodeBlock() (string, map[string]any) { + return "minecraft:barrel", map[string]any{"open_bit": boolByte(b.Open), "facing_direction": int32(b.Facing)} +} + +// EncodeItem ... +func (b Barrel) EncodeItem() (name string, meta int16) { + return "minecraft:barrel", 0 +} + +// allBarrels ... +func allBarrels() (b []world.Block) { + for i := cube.Face(0); i < 6; i++ { + b = append(b, Barrel{Facing: i}) + b = append(b, Barrel{Facing: i, Open: true}) + } + return +} diff --git a/server/block/barrier.go b/server/block/barrier.go new file mode 100644 index 0000000..b74d4b1 --- /dev/null +++ b/server/block/barrier.go @@ -0,0 +1,28 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Barrier is a transparent solid block used to create invisible boundaries. +type Barrier struct { + sourceWaterDisplacer + transparent + solid +} + +// SideClosed ... +func (Barrier) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// EncodeItem ... +func (Barrier) EncodeItem() (name string, meta int16) { + return "minecraft:barrier", 0 +} + +// EncodeBlock ... +func (Barrier) EncodeBlock() (string, map[string]any) { + return "minecraft:barrier", nil +} diff --git a/server/block/basalt.go b/server/block/basalt.go new file mode 100644 index 0000000..eb66d50 --- /dev/null +++ b/server/block/basalt.go @@ -0,0 +1,61 @@ +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" +) + +// Basalt is a type of igneous rock found in the Nether. +type Basalt struct { + solid + bassDrum + + // Polished specifies if the basalt is its polished variant. + Polished bool + // Axis is the axis which the basalt faces. + Axis cube.Axis +} + +// UseOnBlock ... +func (b Basalt) 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) +} + +// BreakInfo ... +func (b Basalt) BreakInfo() BreakInfo { + return newBreakInfo(1.25, pickaxeHarvestable, pickaxeEffective, oneOf(b)).withBlastResistance(21) +} + +// EncodeItem ... +func (b Basalt) EncodeItem() (name string, meta int16) { + if b.Polished { + return "minecraft:polished_basalt", 0 + } + return "minecraft:basalt", 0 +} + +// EncodeBlock ... +func (b Basalt) EncodeBlock() (name string, properties map[string]any) { + if b.Polished { + return "minecraft:polished_basalt", map[string]any{"pillar_axis": b.Axis.String()} + } + return "minecraft:basalt", map[string]any{"pillar_axis": b.Axis.String()} +} + +// allBasalt ... +func allBasalt() (basalt []world.Block) { + for _, axis := range cube.Axes() { + basalt = append(basalt, Basalt{Axis: axis, Polished: false}) + basalt = append(basalt, Basalt{Axis: axis, Polished: true}) + } + return +} diff --git a/server/block/beacon.go b/server/block/beacon.go new file mode 100644 index 0000000..c489611 --- /dev/null +++ b/server/block/beacon.go @@ -0,0 +1,219 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "math" + "time" +) + +// Beacon is a block that projects a light beam skyward, and can provide status effects such as speed, Jump +// Boost, haste, regeneration, resistance, or strength to nearby players. +type Beacon struct { + solid + transparent + clicksAndSticks + sourceWaterDisplacer + + // Primary and Secondary are the primary and secondary effects broadcast to nearby entities by the + // beacon. + Primary, Secondary effect.LastingType + // level is the amount of the pyramid's levels, it is defined by the mineral blocks which build up the + // pyramid, and can be 0-4. + level int +} + +// BeaconSource represents a block which is capable of contributing to powering a beacon pyramid. +type BeaconSource interface { + // PowersBeacon returns a bool which indicates whether this block can contribute to powering up a + // beacon pyramid. + PowersBeacon() bool +} + +// BreakInfo ... +func (b Beacon) BreakInfo() BreakInfo { + return newBreakInfo(3, alwaysHarvestable, nothingEffective, oneOf(Beacon{})) +} + +// Activate manages the opening of a beacon by activating it. +func (b Beacon) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + if opener, ok := u.(ContainerOpener); ok { + opener.OpenBlockContainer(pos, tx) + return true + } + return true +} + +// DecodeNBT ... +func (b Beacon) DecodeNBT(data map[string]any) any { + b.level = int(nbtconv.Int32(data, "Levels")) + if primary, ok := effect.ByID(int(nbtconv.Int32(data, "Primary"))); ok { + b.Primary = primary.(effect.LastingType) + } + if secondary, ok := effect.ByID(int(nbtconv.Int32(data, "Secondary"))); ok { + b.Secondary = secondary.(effect.LastingType) + } + return b +} + +// EncodeNBT ... +func (b Beacon) EncodeNBT() map[string]any { + m := map[string]any{ + "id": "Beacon", + "Levels": int32(b.level), + } + if primary, ok := effect.ID(b.Primary); ok { + m["Primary"] = int32(primary) + } + if secondary, ok := effect.ID(b.Secondary); ok { + m["Secondary"] = int32(secondary) + } + return m +} + +// SideClosed ... +func (b Beacon) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// LightEmissionLevel ... +func (Beacon) LightEmissionLevel() uint8 { + return 15 +} + +// Level returns an integer 0-4 which defines the current pyramid level of the beacon. +func (b Beacon) Level() int { + return b.level +} + +// Tick recalculates level, recalculates the active state of the beacon, and powers players, +// once every 80 ticks (4 seconds). +func (b Beacon) Tick(currentTick int64, pos cube.Pos, tx *world.Tx) { + if currentTick%80 == 0 { + before := b.level + // Recalculating pyramid level and powering up players in range once every 4 seconds. + b.level = b.recalculateLevel(pos, tx) + if before != b.level { + tx.SetBlock(pos, b, nil) + } + if b.level == 0 { + return + } + if !b.obstructed(pos, tx) { + b.broadcastBeaconEffects(pos, tx) + } + } +} + +// recalculateLevel recalculates the level of the beacon's pyramid and returns it. The level can be 0-4. +func (b Beacon) recalculateLevel(pos cube.Pos, tx *world.Tx) int { + var lvl int + iter := 1 + // This loop goes over all 4 possible pyramid levels. + for y := pos.Y() - 1; y >= pos.Y()-4; y-- { + for x := pos.X() - iter; x <= pos.X()+iter; x++ { + for z := pos.Z() - iter; z <= pos.Z()+iter; z++ { + if s, ok := tx.Block(cube.Pos{x, y, z}).(BeaconSource); !ok || !s.PowersBeacon() { + return lvl + } + } + } + iter++ + lvl++ + } + return lvl +} + +// obstructed determines whether the beacon is currently obstructed. +func (b Beacon) obstructed(pos cube.Pos, tx *world.Tx) bool { + // Fast obstructed light calculation. + if tx.SkyLight(pos.Side(cube.FaceUp)) == 15 { + return false + } + // Slow obstructed light calculation, if the fast way out didn't suffice. + return tx.HighestLightBlocker(pos.X(), pos.Z()) > pos[1] +} + +// broadcastBeaconEffects determines the entities in range which could receive the beacon's powers, and +// determines the powers (effects) that these entities could get. Afterwards, the entities in range that are +// beaconAffected get their according effect(s). +func (b Beacon) broadcastBeaconEffects(pos cube.Pos, tx *world.Tx) { + seconds := 9 + b.level*2 + if b.level == 4 { + seconds-- + } + dur := time.Duration(seconds) * time.Second + + // Establishing what effects are active with the current amount of beacon levels. + primary, secondary := b.Primary, effect.LastingType(nil) + switch b.level { + case 0: + primary = nil + case 1: + switch primary { + case effect.Resistance, effect.JumpBoost, effect.Strength: + primary = nil + } + case 2: + if primary == effect.Strength { + primary = nil + } + case 3: + // Accept all effects for primary, but leave secondary as nil. + default: + secondary = b.Secondary + } + var primaryEff, secondaryEff effect.Effect + // Determining whether the primary power is set. + if primary != nil { + primaryEff = effect.NewAmbient(primary, 1, dur) + // Secondary power can only be set if the primary power is set. + if secondary != nil { + // It is possible to select 2 primary powers if the beacon's level is 4. This then means that the effect + // should get a level of 2. + if primary == secondary { + primaryEff = effect.NewAmbient(primary, 2, dur) + } else { + secondaryEff = effect.NewAmbient(secondary, 1, dur) + } + } + } + + // Finding entities in range. + r := 10 + (b.level * 10) + entitiesInRange := tx.EntitiesWithin(cube.Box( + float64(pos.X()-r), -math.MaxFloat64, float64(pos.Z()-r), + float64(pos.X()+r), math.MaxFloat64, float64(pos.Z()+r), + )) + for e := range entitiesInRange { + if p, ok := e.(beaconAffected); ok { + if primaryEff.Type() != nil { + p.AddEffect(primaryEff) + } + if secondaryEff.Type() != nil { + p.AddEffect(secondaryEff) + } + } + } +} + +// beaconAffected represents an entity that can be powered by a beacon. Only players will implement this. +type beaconAffected interface { + // AddEffect adds a specific effect to the entity that implements this interface. + AddEffect(e effect.Effect) + // BeaconAffected returns whether this entity can be powered by a beacon. + BeaconAffected() bool +} + +// EncodeItem ... +func (Beacon) EncodeItem() (name string, meta int16) { + return "minecraft:beacon", 0 +} + +// EncodeBlock ... +func (Beacon) EncodeBlock() (string, map[string]any) { + return "minecraft:beacon", nil +} diff --git a/server/block/bed.go b/server/block/bed.go new file mode 100644 index 0000000..503d511 --- /dev/null +++ b/server/block/bed.go @@ -0,0 +1,298 @@ +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/player/chat" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// Bed is a dyeable utility block that allows a player in the Overworld to sleep through the night and reset +// their spawn point to within a few blocks of the bed, as long as it is not broken or obstructed. +type Bed struct { + transparent + sourceWaterDisplacer + + // Colour is the colour of the bed. + Colour item.Colour + // Facing is the direction that the bed is Facing. + Facing cube.Direction + // Head is true if the bed is the head side. + Head bool + // Sleeper is the user that is using the bed. It is only set for the Head part of the bed. + Sleeper *world.EntityHandle +} + +// MaxCount always returns 1. +func (Bed) MaxCount() int { + return 1 +} + +// Model ... +func (Bed) Model() world.BlockModel { + return model.Bed{} +} + +// SideClosed ... +func (Bed) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// BreakInfo ... +func (b Bed) BreakInfo() BreakInfo { + return newBreakInfo(0.2, alwaysHarvestable, nothingEffective, oneOf(b)).withBreakHandler(func(pos cube.Pos, tx *world.Tx, _ item.User) { + headSide, _, ok := b.head(pos, tx) + if !ok { + return + } + + s := headSide.Sleeper + if s == nil { + return + } + + ent, ok := s.Entity(tx) + if !ok { + return + } + + sleeper, ok := ent.(world.Sleeper) + if ok { + sleeper.Wake() + } + }) +} + +// UseOnBlock ... +func (b Bed) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + if pos, _, used = firstReplaceable(tx, pos, face, b); !used { + return + } + if !supportedFromBelow(pos, tx) { + return + } + + b.Facing = user.Rotation().Direction() + + side, sidePos := b, pos.Side(b.Facing.Face()) + side.Head = true + + if !replaceableWith(tx, sidePos, side) { + return + } + if !supportedFromBelow(sidePos, tx) { + return + } + + ctx.IgnoreBBox = true + place(tx, sidePos, side, user, ctx) + place(tx, pos, b, user, ctx) + return placed(ctx) +} + +// Activate ... +func (b Bed) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + s, ok := u.(world.Sleeper) + if !ok { + return false + } + + w := tx.World() + if w.Dimension() != world.Overworld { + tx.SetBlock(pos, nil, nil) + ExplosionConfig{ + Size: 5, + SpawnFire: true, + }.Explode(tx, pos.Vec3Centre()) + return true + } + + _, sidePos, ok := b.side(pos, tx) + if !ok { + return false + } + + userPos := s.Position() + if sidePos.Vec3Middle().Sub(userPos).Len() > 2 && pos.Vec3Middle().Sub(userPos).Len() > 2 { + s.Messaget(chat.MessageBedTooFar) + return true + } + + headSide, headPos, ok := b.head(pos, tx) + if !ok { + return false + } + + if _, safeSpawn := b.SafeSpawn(pos, tx); !safeSpawn { + s.Messaget(chat.MessageBedObstructed) + return false + } + + if _, ok = tx.Liquid(headPos); ok { + return false + } + + previousSpawn := w.PlayerSpawn(s.UUID()) + if previousSpawn != headPos { + w.SetPlayerSpawn(s.UUID(), headPos) + s.Messaget(chat.MessageRespawnPointSet) + } + + time := w.Time() % world.TimeFull + if !tx.Thundering() { + if !tx.Raining() && (time <= world.TimeSleep || time >= world.TimeWake) { + s.Messaget(chat.MessageNoSleep) + return true + } + if time <= world.TimeSleepWithRain || time >= world.TimeWakeWithRain { + s.Messaget(chat.MessageNoSleep) + return true + } + } + if headSide.Sleeper != nil { + s.Messaget(chat.MessageBedIsOccupied) + return true + } + + // TODO: add a check for when monsters are nearby + + s.Sleep(headPos) + return true +} + +// EntityLand ... +func (b Bed) EntityLand(_ cube.Pos, _ *world.Tx, e world.Entity, distance *float64) { + if _, ok := e.(fallDistanceEntity); ok { + *distance *= 0.5 + } + if v, ok := e.(velocityEntity); ok { + vel := v.Velocity() + vel[1] = vel[1] * -2 / 3 + v.SetVelocity(vel) + } +} + +// velocityEntity represents an entity that can maintain a velocity. +type velocityEntity interface { + // Velocity returns the current velocity of the entity. + Velocity() mgl64.Vec3 + // SetVelocity sets the velocity of the entity. + SetVelocity(mgl64.Vec3) +} + +// NeighbourUpdateTick ... +func (b Bed) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if _, _, ok := b.side(pos, tx); !ok { + breakBlockNoDrops(b, pos, tx) + } +} + +// EncodeItem ... +func (b Bed) EncodeItem() (name string, meta int16) { + return "minecraft:bed", int16(b.Colour.Uint8()) +} + +// EncodeBlock ... +func (b Bed) EncodeBlock() (name string, properties map[string]interface{}) { + return "minecraft:bed", map[string]interface{}{ + "direction": int32(horizontalDirection(b.Facing)), + "occupied_bit": boolByte(b.Sleeper != nil), + "head_piece_bit": boolByte(b.Head), + } +} + +// EncodeNBT ... +func (b Bed) EncodeNBT() map[string]interface{} { + return map[string]interface{}{ + "id": "Bed", + "color": b.Colour.Uint8(), + } +} + +// DecodeNBT ... +func (b Bed) DecodeNBT(data map[string]interface{}) interface{} { + b.Colour = item.Colours()[nbtconv.Uint8(data, "color")] + return b +} + +// head returns the head side of the bed. If neither side is a head side, the third return value is false. +func (b Bed) head(pos cube.Pos, tx *world.Tx) (Bed, cube.Pos, bool) { + headSide, headPos, ok := b.side(pos, tx) + if !ok { + return Bed{}, cube.Pos{}, false + } + if b.Head { + return b, pos, true + } + return headSide, headPos, true +} + +// side returns the other side of the bed. If the other side is not a bed, the third return value is false. +func (b Bed) side(pos cube.Pos, tx *world.Tx) (Bed, cube.Pos, bool) { + face := b.Facing.Face() + if b.Head { + face = face.Opposite() + } + + sidePos := pos.Side(face) + o, ok := tx.Block(sidePos).(Bed) + return o, sidePos, ok +} + +// allBeds returns all possible beds. +func allBeds() (beds []world.Block) { + for _, d := range cube.Directions() { + beds = append(beds, Bed{Facing: d}) + beds = append(beds, Bed{Facing: d, Head: true}) + } + return +} + +// CanRespawnOn ... +func (Bed) CanRespawnOn() bool { + return true +} + +// bedOffsets is a map of offsets for each face of the bed. The offsets are relative to the heel side of the bed. +var bedOffsets = map[cube.Face][]cube.Pos{ + cube.FaceNorth: {{-1, 0, 0}, {-1, 0, 1}, {0, 0, 1}, {1, 0, 1}, {1, 0, 0}, {1, 0, -1}, {1, 0, -2}, {0, 0, -2}, {-1, 0, -2}, {-1, 0, -1}, {0, 1, -1}, {0, 1, 0}}, + cube.FaceEast: {{0, 0, -1}, {-1, 0, -1}, {-1, 0, 0}, {-1, 0, 1}, {-1, 0, 1}, {0, 0, 1}, {1, 0, 1}, {2, 0, 1}, {2, 0, 0}, {2, 0, -1}, {1, 0, -1}, {1, 1, 0}, {0, 1, 0}}, + cube.FaceSouth: {{1, 0, 0}, {1, 0, -1}, {0, 0, -1}, {-1, 0, -1}, {-1, 0, 0}, {-1, 0, 1}, {-1, 0, 2}, {0, 0, 2}, {1, 0, 2}, {1, 0, 1}, {0, 1, 1}, {0, 1, 0}}, + cube.FaceWest: {{0, 0, 1}, {1, 0, 1}, {1, 0, 0}, {1, 0, -1}, {1, 0, -1}, {0, 0, -1}, {-1, 0, -1}, {-2, 0, -1}, {-2, 0, 0}, {-2, 0, 1}, {-1, 0, 1}, {-1, 1, 0}, {0, 1, 0}}, +} + +// SafeSpawn ... +func (b Bed) SafeSpawn(pos cube.Pos, tx *world.Tx) (cube.Pos, bool) { + _, headPos, ok := b.head(pos, tx) + if !ok { + return cube.Pos{}, false + } + + heelPos := headPos.Side(b.Facing.Opposite().Face()) + + for _, offset := range bedOffsets[b.Facing.Face()] { + offsetPos := heelPos.Add(offset) + + if _, solidBlock := tx.Block(offsetPos).Model().(model.Solid); solidBlock { + if diffuser, ok := tx.Block(offsetPos).(LightDiffuser); !ok || diffuser.LightDiffusionLevel() != 0 { + continue + } + } + + if _, emptyBlock := tx.Block(offsetPos.Side(cube.FaceDown)).Model().(model.Empty); emptyBlock { + continue + } + + return heelPos.Add(offset), true + } + return cube.Pos{}, false +} + +// supportedFromBelow ... +func supportedFromBelow(pos cube.Pos, tx *world.Tx) bool { + below := pos.Side(cube.FaceDown) + return tx.Block(below).Model().FaceSolid(below, cube.FaceUp, tx) +} diff --git a/server/block/bedrock.go b/server/block/bedrock.go new file mode 100644 index 0000000..a152bcf --- /dev/null +++ b/server/block/bedrock.go @@ -0,0 +1,22 @@ +package block + +// Bedrock is a block that is indestructible in survival. +type Bedrock struct { + solid + bassDrum + + // InfiniteBurning specifies if the bedrock block is set aflame and will burn forever. This is the case + // for bedrock found under end crystals on top of the end pillars. + InfiniteBurning bool +} + +// EncodeItem ... +func (Bedrock) EncodeItem() (name string, meta int16) { + return "minecraft:bedrock", 0 +} + +// EncodeBlock ... +func (b Bedrock) EncodeBlock() (name string, properties map[string]any) { + //noinspection SpellCheckingInspection + return "minecraft:bedrock", map[string]any{"infiniburn_bit": b.InfiniteBurning} +} diff --git a/server/block/beetroot_seeds.go b/server/block/beetroot_seeds.go new file mode 100644 index 0000000..f6f13dd --- /dev/null +++ b/server/block/beetroot_seeds.go @@ -0,0 +1,87 @@ +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" +) + +// BeetrootSeeds are a crop that can be harvested to craft soup or red dye. +type BeetrootSeeds struct { + crop +} + +// SameCrop ... +func (BeetrootSeeds) SameCrop(c Crop) bool { + _, ok := c.(BeetrootSeeds) + return ok +} + +// BoneMeal ... +func (b BeetrootSeeds) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { + if b.Growth == 7 { + return item.BoneMealResultNone + } + if rand.Float64() < 0.75 { + b.Growth++ + tx.SetBlock(pos, b, nil) + return item.BoneMealResultSmall + } + return item.BoneMealResultNone +} + +// UseOnBlock ... +func (b BeetrootSeeds) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, b) + if !used { + return false + } + + if _, ok := tx.Block(pos.Side(cube.FaceDown)).(Farmland); !ok { + return false + } + + place(tx, pos, b, user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (b BeetrootSeeds) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, cropSeedDrops(b, item.Beetroot{}, b.Growth)) +} + +// CompostChance ... +func (BeetrootSeeds) CompostChance() float64 { + return 0.3 +} + +// EncodeItem ... +func (b BeetrootSeeds) EncodeItem() (name string, meta int16) { + return "minecraft:beetroot_seeds", 0 +} + +// RandomTick ... +func (b BeetrootSeeds) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + if tx.Light(pos) < 8 { + breakBlock(b, pos, tx) + } else if b.Growth < 7 && r.IntN(3) > 0 && r.Float64() <= b.CalculateGrowthChance(pos, tx) { + b.Growth++ + tx.SetBlock(pos, b, nil) + } +} + +// EncodeBlock ... +func (b BeetrootSeeds) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:beetroot", map[string]any{"growth": int32(b.Growth)} +} + +// allBeetroot ... +func allBeetroot() (beetroot []world.Block) { + for i := 0; i <= 7; i++ { + beetroot = append(beetroot, BeetrootSeeds{crop: crop{Growth: i}}) + } + return +} diff --git a/server/block/blackstone.go b/server/block/blackstone.go new file mode 100644 index 0000000..f842604 --- /dev/null +++ b/server/block/blackstone.go @@ -0,0 +1,60 @@ +package block + +import ( + "math/rand/v2" + + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// Blackstone is a naturally generating block in the nether that can be used to craft stone tools, brewing stands and +// furnaces. Gilded blackstone also has a 10% chance to drop 2-6 golden nuggets. +type Blackstone struct { + solid + bassDrum + + // Type is the type of blackstone of the block. + Type BlackstoneType +} + +// BreakInfo ... +func (b Blackstone) BreakInfo() BreakInfo { + drops := oneOf(b) + hardness := 1.5 + + switch b.Type { + case GildedBlackstone(): + drops = func(t item.Tool, enchantments []item.Enchantment) []item.Stack { + if hasSilkTouch(enchantments) { + return []item.Stack{item.NewStack(b, 1)} + } + nuggetChances := []float64{0.1, 1.0 / 7.0, 0.25, 1.0} + if rand.Float64() < nuggetChances[min(fortuneLevel(enchantments), 3)] { + return []item.Stack{item.NewStack(item.GoldNugget{}, rand.IntN(4)+2)} + } + return []item.Stack{item.NewStack(b, 1)} + } + case PolishedBlackstone(): + hardness = 2 + } + + return newBreakInfo(hardness, pickaxeHarvestable, pickaxeEffective, drops).withBlastResistance(30) +} + +// EncodeItem ... +func (b Blackstone) EncodeItem() (name string, meta int16) { + return "minecraft:" + b.Type.String(), 0 +} + +// EncodeBlock ... +func (b Blackstone) EncodeBlock() (string, map[string]any) { + return "minecraft:" + b.Type.String(), nil +} + +// allBlackstone returns a list of all blackstone block variants. +func allBlackstone() (s []world.Block) { + for _, t := range BlackstoneTypes() { + s = append(s, Blackstone{Type: t}) + } + return +} diff --git a/server/block/blackstone_type.go b/server/block/blackstone_type.go new file mode 100644 index 0000000..e7c8c12 --- /dev/null +++ b/server/block/blackstone_type.go @@ -0,0 +1,68 @@ +package block + +// BlackstoneType represents a type of blackstone. +type BlackstoneType struct { + blackstone +} + +type blackstone uint8 + +// NormalBlackstone is the normal variant of blackstone. +func NormalBlackstone() BlackstoneType { + return BlackstoneType{0} +} + +// GildedBlackstone is the gilded variant of blackstone. +func GildedBlackstone() BlackstoneType { + return BlackstoneType{1} +} + +// PolishedBlackstone is the polished variant of blackstone. +func PolishedBlackstone() BlackstoneType { + return BlackstoneType{2} +} + +// ChiseledPolishedBlackstone is the chiseled polished variant of blackstone. +func ChiseledPolishedBlackstone() BlackstoneType { + return BlackstoneType{3} +} + +// Uint8 returns the blackstone type as a uint8. +func (s blackstone) Uint8() uint8 { + return uint8(s) +} + +// Name ... +func (s blackstone) Name() string { + switch s { + case 0: + return "Blackstone" + case 1: + return "Gilded Blackstone" + case 2: + return "Polished Blackstone" + case 3: + return "Chiseled Polished Blackstone" + } + panic("unknown blackstone type") +} + +// String ... +func (s blackstone) String() string { + switch s { + case 0: + return "blackstone" + case 1: + return "gilded_blackstone" + case 2: + return "polished_blackstone" + case 3: + return "chiseled_polished_blackstone" + } + panic("unknown blackstone type") +} + +// BlackstoneTypes ... +func BlackstoneTypes() []BlackstoneType { + return []BlackstoneType{NormalBlackstone(), GildedBlackstone(), PolishedBlackstone(), ChiseledPolishedBlackstone()} +} diff --git a/server/block/blast_furnace.go b/server/block/blast_furnace.go new file mode 100644 index 0000000..581a03a --- /dev/null +++ b/server/block/blast_furnace.go @@ -0,0 +1,142 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "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/v2" + "time" +) + +// BlastFurnace is a block that smelts ores, raw metals, iron and gold armour and tools, similar to a furnace, but at +// twice the speed. It also serves as an armourer's job site block. +// The empty value of BlastFurnace is not valid. It must be created using block.NewBlastFurnace(cube.Face). +type BlastFurnace struct { + solid + bassDrum + *smelter + + // Facing is the direction the blast furnace is facing. + Facing cube.Direction + // Lit is true if the blast furnace is lit. + Lit bool +} + +// NewBlastFurnace creates a new initialised blast furnace. The smelter is properly initialised. +func NewBlastFurnace(face cube.Direction) BlastFurnace { + return BlastFurnace{ + Facing: face, + smelter: newSmelter(), + } +} + +// Tick is called to check if the blast furnace should update and start or stop smelting. +func (b BlastFurnace) Tick(_ int64, pos cube.Pos, tx *world.Tx) { + if b.Lit && rand.Float64() <= 0.016 { // Every three or so seconds. + tx.PlaySound(pos.Vec3Centre(), sound.BlastFurnaceCrackle{}) + } + if lit := b.tickSmelting(time.Second*5, time.Millisecond*200, b.Lit, func(i item.SmeltInfo) bool { + return i.Ores + }); b.Lit != lit { + b.Lit = lit + tx.SetBlock(pos, b, nil) + } +} + +// LightEmissionLevel ... +func (b BlastFurnace) LightEmissionLevel() uint8 { + if b.Lit { + return 13 + } + return 0 +} + +// EncodeItem ... +func (b BlastFurnace) EncodeItem() (name string, meta int16) { + return "minecraft:blast_furnace", 0 +} + +// EncodeBlock ... +func (b BlastFurnace) EncodeBlock() (name string, properties map[string]interface{}) { + if b.Lit { + return "minecraft:lit_blast_furnace", map[string]interface{}{"minecraft:cardinal_direction": b.Facing.String()} + } + return "minecraft:blast_furnace", map[string]interface{}{"minecraft:cardinal_direction": b.Facing.String()} +} + +// UseOnBlock ... +func (b BlastFurnace) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, b) + if !used { + return false + } + + place(tx, pos, NewBlastFurnace(user.Rotation().Direction().Opposite()), user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (b BlastFurnace) BreakInfo() BreakInfo { + xp := b.Experience() + return newBreakInfo(3.5, alwaysHarvestable, pickaxeEffective, oneOf(BlastFurnace{})).withXPDropRange(xp, xp).withBreakHandler(func(pos cube.Pos, tx *world.Tx, u item.User) { + for _, i := range b.Inventory(tx, pos).Clear() { + dropItem(tx, i, pos.Vec3()) + } + }) +} + +// Activate ... +func (b BlastFurnace) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + if opener, ok := u.(ContainerOpener); ok { + opener.OpenBlockContainer(pos, tx) + return true + } + return false +} + +// EncodeNBT ... +func (b BlastFurnace) EncodeNBT() map[string]interface{} { + if b.smelter == nil { + //noinspection GoAssignmentToReceiver + b = NewBlastFurnace(b.Facing) + } + remaining, maximum, cook := b.Durations() + return map[string]interface{}{ + "BurnTime": int16(remaining.Milliseconds() / 50), + "CookTime": int16(cook.Milliseconds() / 50), + "BurnDuration": int16(maximum.Milliseconds() / 50), + "StoredXPInt": int16(b.Experience()), + "Items": nbtconv.InvToNBT(b.inventory), + "id": "BlastFurnace", + } +} + +// DecodeNBT ... +func (b BlastFurnace) DecodeNBT(data map[string]interface{}) interface{} { + remaining := nbtconv.TickDuration[int16](data, "BurnTime") + maximum := nbtconv.TickDuration[int16](data, "BurnDuration") + cook := nbtconv.TickDuration[int16](data, "CookTime") + + xp := int(nbtconv.Int16(data, "StoredXPInt")) + lit := b.Lit + + //noinspection GoAssignmentToReceiver + b = NewBlastFurnace(b.Facing) + b.Lit = lit + b.setExperience(xp) + b.setDurations(remaining, maximum, cook) + nbtconv.InvFromNBT(b.inventory, nbtconv.Slice(data, "Items")) + return b +} + +// allBlastFurnaces ... +func allBlastFurnaces() (furnaces []world.Block) { + for _, face := range cube.Directions() { + furnaces = append(furnaces, BlastFurnace{Facing: face}) + furnaces = append(furnaces, BlastFurnace{Facing: face, Lit: true}) + } + return +} diff --git a/server/block/block.go b/server/block/block.go new file mode 100644 index 0000000..044f65a --- /dev/null +++ b/server/block/block.go @@ -0,0 +1,376 @@ +package block + +import ( + "math/rand/v2" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/block/customblock" + "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" +) + +// Activatable represents a block that may be activated by a viewer of the world. When activated, the block +// will execute some specific logic. +type Activatable interface { + // Activate activates the block at a specific block position. The face clicked is passed, as well as the + // world in which the block was activated and the viewer that activated it. + // Activate returns a bool indicating if activating the block was used successfully. + Activate(pos cube.Pos, clickedFace cube.Face, tx *world.Tx, u item.User, ctx *item.UseContext) bool +} + +// Pickable represents a block that may give a different item then the block itself when picked. +type Pickable interface { + // Pick returns the item that is picked when the block is picked. + Pick() item.Stack +} + +// Punchable represents a block that may be punched by a viewer of the world. When punched, the block +// will execute some specific logic. +type Punchable interface { + // Punch punches the block at a specific block position. The face clicked is passed, as well as the + // world in which the block was punched and the viewer that punched it. + Punch(pos cube.Pos, clickedFace cube.Face, tx *world.Tx, u item.User) +} + +// LightEmitter represents a block that emits light when placed. Blocks such as torches or lanterns implement +// this interface. +type LightEmitter interface { + // LightEmissionLevel returns the light emission level of the block, a number from 0-15 where 15 is the + // brightest and 0 means it doesn't emit light at all. + LightEmissionLevel() uint8 +} + +// LightDiffuser represents a block that diffuses light. This means that a specific amount of light levels +// will be subtracted when light passes through the block. +// Blocks that do not implement LightDiffuser will be assumed to be solid: Light will not be able to pass +// through these blocks. +type LightDiffuser interface { + // LightDiffusionLevel returns the amount of light levels that is subtracted when light passes through + // this block. Some blocks, such as leaves, have this behaviour. A diffusion level of 15 means that all + // light will be completely blocked when it passes through the block. + LightDiffusionLevel() uint8 +} + +// RedstoneWireStepDowner represents a block with custom behaviour for redstone wire providing power when travelling +// down it. +type RedstoneWireStepDowner interface { + // CanRedstoneWireStepDown returns whether redstone wire may provide power while travelling down the block at + // pos from the wire position passed. + CanRedstoneWireStepDown(pos, from cube.Pos, tx *world.Tx) bool +} + +// Replaceable represents a block that may be replaced by another block automatically. An example is grass, +// which may be replaced by clicking it with another block. +type Replaceable interface { + // ReplaceableBy returns a bool which indicates if the block is replaceableWith by another block. + ReplaceableBy(b world.Block) bool +} + +// EntityLander represents a block that reacts to an entity landing on it after falling. +type EntityLander interface { + // EntityLand is called when an entity lands on the block. + EntityLand(pos cube.Pos, tx *world.Tx, e world.Entity, distance *float64) +} + +// EntityInsider represents a block that reacts to an entity going inside its 1x1x1 axis +// aligned bounding box. +type EntityInsider interface { + // EntityInside is called when an entity goes inside the block's 1x1x1 axis aligned bounding box. + EntityInside(pos cube.Pos, tx *world.Tx, e world.Entity) +} + +// EntityStepper represents a block that reacts to an entity standing on top of it. +type EntityStepper interface { + // EntityStepOn is called every tick while an entity is standing on the top face of the block. + EntityStepOn(pos cube.Pos, tx *world.Tx, e world.Entity) +} + +// ProjectileHitter represents a block that handles being hit by a projectile. +type ProjectileHitter interface { + // ProjectileHit is called when a projectile hits the block. + ProjectileHit(pos cube.Pos, tx *world.Tx, e world.Entity, face cube.Face) +} + +// Frictional represents a block that may have a custom friction value. Friction is used for entity drag when the +// entity is on ground. If a block does not implement this interface, it should be assumed that its friction is 0.6. +type Frictional interface { + // Friction returns the block's friction value. + Friction() float64 +} + +// Permutable represents a custom block that can have more permutations than its default state. +type Permutable interface { + // States returns a map of all the different properties for the block. The key is the property name, and the value + // is a slice of all the possible values for that property. It is important that a block is registered in dragonfly + // for each of the possible combinations of properties and values. + States() map[string][]any + // Permutations returns a slice of all the different permutations for the block. Multiple permutations can be + // applied at once if their conditions are met. + Permutations() []customblock.Permutation +} + +// unknownFace is a face that is used for certain block items. This should not be exposed in the API. +var unknownFace = cube.Face(len(cube.Faces())) + +// unknownDirection is a direction that is used for certain block items. This should not be exposed in the API. +var unknownDirection = cube.Direction(len(cube.Directions())) + +func calculateFace(user item.User, placePos cube.Pos) cube.Face { + userPos := user.Position() + pos := cube.PosFromVec3(userPos) + if abs(pos[0]-placePos[0]) < 2 && abs(pos[2]-placePos[2]) < 2 { + y := userPos[1] + if eyed, ok := user.(interface{ EyeHeight() float64 }); ok { + y += eyed.EyeHeight() + } + + if y-float64(placePos[1]) > 2.0 { + return cube.FaceUp + } else if float64(placePos[1])-y > 0.0 { + return cube.FaceDown + } + } + return user.Rotation().Direction().Opposite().Face() +} + +func abs(x int) int { + if x > 0 { + return x + } + return -x +} + +// replaceableWith checks if the block at the position passed is replaceable with the block passed. +func replaceableWith(tx *world.Tx, pos cube.Pos, with world.Block) bool { + if pos.OutOfBounds(tx.Range()) { + return false + } + b := tx.Block(pos) + if replaceable, ok := b.(Replaceable); ok { + if !replaceable.ReplaceableBy(with) || b == with { + return false + } + if liquid, ok := tx.Liquid(pos); ok { + replaceable, ok := liquid.(Replaceable) + return ok && replaceable.ReplaceableBy(with) + } + return true + } + return false +} + +// firstReplaceable finds the first replaceable block position eligible to have a block placed on it after +// clicking on the position and face passed. +// If none can be found, the bool returned is false. +func firstReplaceable(tx *world.Tx, pos cube.Pos, face cube.Face, with world.Block) (cube.Pos, cube.Face, bool) { + if replaceableWith(tx, pos, with) { + // A replaceableWith block was clicked, so we can replace it. This will then be assumed to be placed on + // the top face. (Torches, for example, will get attached to the floor when clicking tall grass.) + return pos, cube.FaceUp, true + } + side := pos.Side(face) + if replaceableWith(tx, side, with) { + return side, face, true + } + return pos, face, false +} + +// place places the block passed at the position passed. If the user implements the block.Placer interface, it +// will use its PlaceBlock method. If not, the block is placed without interaction from the user. +func place(tx *world.Tx, pos cube.Pos, b world.Block, user item.User, ctx *item.UseContext) { + if placer, ok := user.(Placer); ok { + placer.PlaceBlock(pos, b, ctx) + return + } + tx.SetBlock(pos, b, nil) + tx.PlaySound(pos.Vec3(), sound.BlockPlace{Block: b}) +} + +// horizontalDirection returns the horizontal direction of the given direction. This is a legacy type still used in +// various blocks. +func horizontalDirection(d cube.Direction) cube.Direction { + switch d { + case cube.South: + return cube.North + case cube.West: + return cube.South + case cube.North: + return cube.West + case cube.East: + return cube.East + } + panic("invalid direction") +} + +// placed checks if an item was placed with the use context passed. +func placed(ctx *item.UseContext) bool { + return ctx.CountSub > 0 +} + +// boolByte returns 1 if the bool passed is true, or 0 if it is false. +func boolByte(b bool) uint8 { + if b { + return 1 + } + return 0 +} + +// replaceable is a struct that may be embedded to make a block replaceable by any other block. +type replaceable struct{} + +// ReplaceableBy ... +func (replaceable) ReplaceableBy(world.Block) bool { + return true +} + +// transparent is a struct that may be embedded to make a block transparent to light. Light will be able to +// pass through this block freely. +type transparent struct{} + +// LightDiffusionLevel ... +func (transparent) LightDiffusionLevel() uint8 { + return 0 +} + +// gravityAffected is a struct that may be embedded for blocks affected by gravity. +type gravityAffected struct{} + +// Solidifies ... +func (g gravityAffected) Solidifies(cube.Pos, *world.Tx) bool { + return false +} + +// fall spawns a falling block entity at the given position. +func (g gravityAffected) fall(b world.Block, pos cube.Pos, tx *world.Tx) { + if replaceableWith(tx, pos.Side(cube.FaceDown), b) { + tx.SetBlock(pos, nil, nil) + opts := world.EntitySpawnOpts{Position: pos.Vec3Centre()} + tx.AddEntity(tx.World().EntityRegistry().Config().FallingBlock(opts, b)) + } +} + +// Flammable is an interface for blocks that can catch on fire. +type Flammable interface { + // FlammabilityInfo returns information about a block's behaviour involving fire. + FlammabilityInfo() FlammabilityInfo +} + +// FlammabilityInfo contains values related to block behaviours involving fire. +type FlammabilityInfo struct { + // Encouragement is the chance a block will catch on fire during attempted fire spread. + Encouragement int + // Flammability is the chance a block will burn away during a fire block tick. + Flammability int + // LavaFlammable returns whether it can catch on fire from lava. + LavaFlammable bool +} + +// newFlammabilityInfo creates a FlammabilityInfo struct with the properties passed. +func newFlammabilityInfo(encouragement, flammability int, lavaFlammable bool) FlammabilityInfo { + return FlammabilityInfo{ + Encouragement: encouragement, + Flammability: flammability, + LavaFlammable: lavaFlammable, + } +} + +// livingEntity ... +type livingEntity interface { + // Hurt hurts the entity for a given amount of damage. The source passed represents the cause of the + // damage, for example damage.SourceEntityAttack if the entity is attacked by another entity. + // If the final damage exceeds the health that the entity currently has, the entity is killed. + // Hurt returns the final amount of damage dealt to the Living entity and returns whether the Living entity + // was vulnerable to the damage at all. + Hurt(damage float64, src world.DamageSource) (n float64, vulnerable bool) +} + +// flammableEntity ... +type flammableEntity interface { + // OnFireDuration returns duration of fire in ticks. + OnFireDuration() time.Duration + // SetOnFire sets the entity on fire for the specified duration. + SetOnFire(duration time.Duration) + // Extinguish extinguishes the entity. + Extinguish() +} + +// dropItem ... +func dropItem(tx *world.Tx, it item.Stack, pos mgl64.Vec3) { + create := tx.World().EntityRegistry().Config().Item + opts := world.EntitySpawnOpts{Position: pos, Velocity: mgl64.Vec3{rand.Float64()*0.2 - 0.1, 0.2, rand.Float64()*0.2 - 0.1}} + tx.AddEntity(create(opts, it)) +} + +// bass is a struct that may be embedded for blocks that create a bass sound. +type bass struct{} + +// Instrument ... +func (bass) Instrument() sound.Instrument { + return sound.Bass() +} + +// snare is a struct that may be embedded for blocks that create a snare drum sound. +type snare struct{} + +// Instrument ... +func (snare) Instrument() sound.Instrument { + return sound.Snare() +} + +// clicksAndSticks is a struct that may be embedded for blocks that create a clicks and sticks sound. +type clicksAndSticks struct{} + +// Instrument ... +func (clicksAndSticks) Instrument() sound.Instrument { + return sound.ClicksAndSticks() +} + +// bassDrum is a struct that may be embedded for blocks that create a bass drum sound. +type bassDrum struct{} + +// Instrument ... +func (bassDrum) Instrument() sound.Instrument { + return sound.BassDrum() +} + +// flute is a struct that may be embedded for blocks that create a flute sound. +type flute struct{} + +// Instrument ... +func (flute) Instrument() sound.Instrument { + return sound.Flute() +} + +// newSmeltInfo returns a new SmeltInfo with the given values. +func newSmeltInfo(product item.Stack, experience float64) item.SmeltInfo { + return item.SmeltInfo{ + Product: product, + Experience: experience, + } +} + +// newFoodSmeltInfo returns a new SmeltInfo with the given values that allows smelting in a smelter. +func newFoodSmeltInfo(product item.Stack, experience float64) item.SmeltInfo { + return item.SmeltInfo{ + Product: product, + Experience: experience, + Food: true, + } +} + +// newOreSmeltInfo returns a new SmeltInfo with the given values that allows smelting in a blast furnace. +func newOreSmeltInfo(product item.Stack, experience float64) item.SmeltInfo { + return item.SmeltInfo{ + Product: product, + Experience: experience, + Ores: true, + } +} + +// newFuelInfo returns a new FuelInfo with the given values. +func newFuelInfo(duration time.Duration) item.FuelInfo { + return item.FuelInfo{Duration: duration} +} diff --git a/server/block/block_behaviour_test.go b/server/block/block_behaviour_test.go new file mode 100644 index 0000000..d5c0a04 --- /dev/null +++ b/server/block/block_behaviour_test.go @@ -0,0 +1,32 @@ +package block_test + +import ( + "testing" + + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/entity" + "github.com/df-mc/dragonfly/server/world" +) + +// TestTorchBreaksWithoutSupport verifies that a torch is broken by a neighbour +// update on the tick after its supporting block is removed, using a +// synchronous World to make the tick deterministic. +func TestTorchBreaksWithoutSupport(t *testing.T) { + w := world.Config{Synchronous: true, Entities: entity.DefaultRegistry}.New() + defer w.Close() + + support, torch := cube.Pos{0, 0, 0}, cube.Pos{0, 1, 0} + <-w.Exec(func(tx *world.Tx) { + tx.SetBlock(support, block.Stone{}, nil) + tx.SetBlock(torch, block.Torch{Facing: cube.FaceDown}, nil) + tx.SetBlock(support, block.Air{}, nil) + }) + w.AdvanceTick() + + <-w.Exec(func(tx *world.Tx) { + if b := tx.Block(torch); b != (block.Air{}) { + t.Errorf("expected torch to break after removing its support, got %v", b) + } + }) +} diff --git a/server/block/blue_ice.go b/server/block/blue_ice.go new file mode 100644 index 0000000..51526b5 --- /dev/null +++ b/server/block/blue_ice.go @@ -0,0 +1,26 @@ +package block + +// BlueIce is a solid block similar to packed ice. +type BlueIce struct { + solid +} + +// BreakInfo ... +func (b BlueIce) BreakInfo() BreakInfo { + return newBreakInfo(2.8, alwaysHarvestable, pickaxeEffective, silkTouchOnlyDrop(b)) +} + +// Friction ... +func (b BlueIce) Friction() float64 { + return 0.989 +} + +// EncodeItem ... +func (BlueIce) EncodeItem() (name string, meta int16) { + return "minecraft:blue_ice", 0 +} + +// EncodeBlock ... +func (BlueIce) EncodeBlock() (string, map[string]any) { + return "minecraft:blue_ice", nil +} diff --git a/server/block/bone.go b/server/block/bone.go new file mode 100644 index 0000000..346d6ad --- /dev/null +++ b/server/block/bone.go @@ -0,0 +1,57 @@ +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/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" +) + +// Bone is a decorative block that can face different directions. +type Bone struct { + solid + + // Axis is the axis which the bone block faces. + Axis cube.Axis +} + +// Instrument ... +func (b Bone) Instrument() sound.Instrument { + return sound.Xylophone() +} + +// UseOnBlock handles the rotational placing of bone blocks. +func (b Bone) 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) +} + +// BreakInfo ... +func (b Bone) BreakInfo() BreakInfo { + return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(b)) +} + +// EncodeItem ... +func (b Bone) EncodeItem() (name string, meta int16) { + return "minecraft:bone_block", 0 +} + +// EncodeBlock ... +func (b Bone) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:bone_block", map[string]any{"pillar_axis": b.Axis.String(), "deprecated": int32(0)} +} + +// allBoneBlock ... +func allBoneBlock() (boneBlocks []world.Block) { + for _, axis := range cube.Axes() { + boneBlocks = append(boneBlocks, Bone{Axis: axis}) + } + return +} diff --git a/server/block/bookshelf.go b/server/block/bookshelf.go new file mode 100644 index 0000000..ee0bd8d --- /dev/null +++ b/server/block/bookshelf.go @@ -0,0 +1,37 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" + "time" +) + +// Bookshelf is a decorative block that primarily serves to enhance enchanting with an enchanting table. +type Bookshelf struct { + solid + bass +} + +// BreakInfo ... +func (b Bookshelf) BreakInfo() BreakInfo { + return newBreakInfo(1.5, alwaysHarvestable, axeEffective, silkTouchDrop(item.NewStack(item.Book{}, 3), item.NewStack(b, 1))) +} + +// FlammabilityInfo ... +func (Bookshelf) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(30, 20, true) +} + +// FuelInfo ... +func (Bookshelf) FuelInfo() item.FuelInfo { + return newFuelInfo(time.Second * 15) +} + +// EncodeItem ... +func (Bookshelf) EncodeItem() (name string, meta int16) { + return "minecraft:bookshelf", 0 +} + +// EncodeBlock ... +func (Bookshelf) EncodeBlock() (string, map[string]any) { + return "minecraft:bookshelf", nil +} diff --git a/server/block/break_info.go b/server/block/break_info.go new file mode 100644 index 0000000..cc6004a --- /dev/null +++ b/server/block/break_info.go @@ -0,0 +1,384 @@ +package block + +import ( + "math" + "math/rand/v2" + "slices" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/enchantment" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/particle" +) + +// Breakable represents a block that may be broken by a player in survival mode. Blocks not include are blocks +// such as bedrock. +type Breakable interface { + // BreakInfo returns information of the block related to the breaking of it. Callers that execute the BreakHandler + // must call BreakInfo on the concrete block value being broken, as handlers may need block state after the world + // position has been cleared. + BreakInfo() BreakInfo +} + +// BreakDuration returns the base duration that breaking the block passed takes when being broken using the +// item passed. +func BreakDuration(b world.Block, i item.Stack) time.Duration { + breakable, ok := b.(Breakable) + if !ok { + return math.MaxInt64 + } + t, ok := i.Item().(item.Tool) + if !ok { + t = item.ToolNone{} + } + info := breakable.BreakInfo() + + breakTime := info.Hardness * 5 + if info.Harvestable(t) { + breakTime = info.Hardness * 1.5 + } + if info.Effective(t) { + eff := t.BaseMiningEfficiency(b) + if e, ok := i.Enchantment(enchantment.Efficiency); ok { + eff += enchantment.Efficiency.Addend(e.Level()) + } + breakTime /= eff + } + // TODO: Account for haste etc here. + timeInTicksAccurate := math.Round(breakTime/0.05) * 0.05 + + return (time.Duration(math.Round(timeInTicksAccurate*20)) * time.Second) / 20 +} + +// BreaksInstantly checks if the block passed can be broken instantly using the item stack passed to break +// it. +func BreaksInstantly(b world.Block, i item.Stack) bool { + breakable, ok := b.(Breakable) + if !ok { + return false + } + hardness := breakable.BreakInfo().Hardness + if hardness == 0 { + return true + } + t, ok := i.Item().(item.Tool) + if !ok || !breakable.BreakInfo().Effective(t) { + return false + } + + // TODO: Account for haste etc here. + efficiencyVal := 0.0 + if e, ok := i.Enchantment(enchantment.Efficiency); ok { + efficiencyVal += enchantment.Efficiency.Addend(e.Level()) + } + hasteVal := 0.0 + return (t.BaseMiningEfficiency(b)+efficiencyVal)*hasteVal >= hardness*30 +} + +// BreakInfo is a struct returned by every block. It holds information on block breaking related data, such as +// the tool type and tier required to break it. +type BreakInfo struct { + // Hardness is the hardness of the block, which influences the speed with which the block may be mined. + Hardness float64 + // Harvestable is a function called to check if the block is harvestable using the tool passed. If the + // item used to break the block is not a tool, a tool.ToolNone is passed. + Harvestable func(t item.Tool) bool + // Effective is a function called to check if the block can be mined more effectively with the tool passed + // than with an empty hand. + Effective func(t item.Tool) bool + // Drops is a function called to get the drops of the block if it is broken using the item passed. + Drops func(t item.Tool, enchantments []item.Enchantment) []item.Stack + // BreakHandler is called after the block has broken. + BreakHandler func(pos cube.Pos, w *world.Tx, u item.User) + // XPDrops is the range of XP a block can drop when broken. + XPDrops XPDropRange + // BlastResistance is the blast resistance of the block, which influences the block's ability to withstand an + // explosive blast. + BlastResistance float64 +} + +// newBreakInfo creates a BreakInfo struct with the properties passed. The XPDrops field is 0 by default. The blast +// resistance is set to the block's hardness*5 by default. +func newBreakInfo(hardness float64, harvestable func(item.Tool) bool, effective func(item.Tool) bool, drops func(item.Tool, []item.Enchantment) []item.Stack) BreakInfo { + return BreakInfo{ + Hardness: hardness, + BlastResistance: hardness * 5, + Harvestable: harvestable, + Effective: effective, + Drops: drops, + } +} + +// withXPDropRange sets the XPDropRange field of the BreakInfo struct to the passed value. +func (b BreakInfo) withXPDropRange(min, max int) BreakInfo { + b.XPDrops = XPDropRange{min, max} + return b +} + +// withBlastResistance sets the BlastResistance field of the BreakInfo struct to the passed value. +func (b BreakInfo) withBlastResistance(res float64) BreakInfo { + b.BlastResistance = res + return b +} + +// withBreakHandler sets the BreakHandler field of the BreakInfo struct to the passed value. +func (b BreakInfo) withBreakHandler(handler func(pos cube.Pos, w *world.Tx, u item.User)) BreakInfo { + b.BreakHandler = handler + return b +} + +// XPDropRange holds the min & max XP drop amounts of blocks. +type XPDropRange [2]int + +// RandomValue returns a random XP value that falls within the drop range. +func (r XPDropRange) RandomValue() int { + diff := r[1] - r[0] + // Add one because it's a [r[0], r[1]] interval. + return rand.IntN(diff+1) + r[0] +} + +// pickaxeEffective is a convenience function for blocks that are effectively mined with a pickaxe. +var pickaxeEffective = func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe +} + +// axeEffective is a convenience function for blocks that are effectively mined with an axe. +var axeEffective = func(t item.Tool) bool { + return t.ToolType() == item.TypeAxe +} + +// shearsEffective is a convenience function for blocks that are effectively mined with shears. +var shearsEffective = func(t item.Tool) bool { + return t.ToolType() == item.TypeShears +} + +// swordEffective is a convenience function for blocks that are effectively mined with a sword. +var swordEffective = func(t item.Tool) bool { + return t.ToolType() == item.TypeSword +} + +// shovelEffective is a convenience function for blocks that are effectively mined with a shovel. +var shovelEffective = func(t item.Tool) bool { + return t.ToolType() == item.TypeShovel +} + +// hoeEffective is a convenience function for blocks that are effectively mined with a hoe. +var hoeEffective = func(t item.Tool) bool { + return t.ToolType() == item.TypeHoe +} + +// nothingEffective is a convenience function for blocks that cannot be mined efficiently with any tool. +var nothingEffective = func(item.Tool) bool { + return false +} + +// alwaysHarvestable is a convenience function for blocks that are harvestable using any item. +var alwaysHarvestable = func(t item.Tool) bool { + return true +} + +// neverHarvestable is a convenience function for blocks that are not harvestable by any item. +var neverHarvestable = func(t item.Tool) bool { + return false +} + +// pickaxeHarvestable is a convenience function for blocks that are harvestable using any kind of pickaxe. +var pickaxeHarvestable = pickaxeEffective + +// simpleDrops returns a drops function that returns the items passed. +func simpleDrops(s ...item.Stack) func(item.Tool, []item.Enchantment) []item.Stack { + return func(item.Tool, []item.Enchantment) []item.Stack { + return s + } +} + +// oneOf returns a drops function that returns one of each of the item types passed. +func oneOf(i ...world.Item) func(item.Tool, []item.Enchantment) []item.Stack { + return func(item.Tool, []item.Enchantment) []item.Stack { + var s []item.Stack + for _, it := range i { + s = append(s, item.NewStack(it, 1)) + } + return s + } +} + +// hasSilkTouch checks if an item has the silk touch enchantment. +func hasSilkTouch(enchantments []item.Enchantment) bool { + return slices.IndexFunc(enchantments, func(i item.Enchantment) bool { + return i.Type() == enchantment.SilkTouch + }) != -1 +} + +// silkTouchOneOf returns a drop function that returns 1x of the silk touch drop when silk touch exists, or 1x of the +// normal drop when it does not. +func silkTouchOneOf(normal, silkTouch world.Item) func(item.Tool, []item.Enchantment) []item.Stack { + return func(t item.Tool, enchantments []item.Enchantment) []item.Stack { + if hasSilkTouch(enchantments) { + return []item.Stack{item.NewStack(silkTouch, 1)} + } + return []item.Stack{item.NewStack(normal, 1)} + } +} + +// silkTouchDrop returns a drop function that returns the silk touch drop when silk touch exists, or the +// normal drop when it does not. +func silkTouchDrop(normal, silkTouch item.Stack) func(item.Tool, []item.Enchantment) []item.Stack { + return func(t item.Tool, enchantments []item.Enchantment) []item.Stack { + if hasSilkTouch(enchantments) { + return []item.Stack{silkTouch} + } + return []item.Stack{normal} + } +} + +// silkTouchOnlyDrop returns a drop function that returns the drop when silk touch exists. +func silkTouchOnlyDrop(it world.Item) func(t item.Tool, enchantments []item.Enchantment) []item.Stack { + return func(t item.Tool, enchantments []item.Enchantment) []item.Stack { + if hasSilkTouch(enchantments) { + return []item.Stack{item.NewStack(it, 1)} + } + return nil + } +} + +// fortuneLevel returns the level of the fortune enchantment in enchantments, or 0 if it isn't present. +func fortuneLevel(enchantments []item.Enchantment) int { + index := slices.IndexFunc(enchantments, func(i item.Enchantment) bool { + return i.Type() == enchantment.Fortune + }) + if index == -1 { + return 0 + } + return enchantments[index].Level() +} + +// fortuneOreCount computes the drop count for an ore after applying the Fortune ore multiplier to a given base +// drop count. The Fortune enchantment has a 2/(level + 2) chance of applying an integer bonus multiplier between +// 2x up to (level + 1)x to the drop count. +func fortuneOreCount(base int, enchantments []item.Enchantment) int { + fortune := fortuneLevel(enchantments) + if fortune == 0 || rand.IntN(fortune+2) < 2 { + return base + } + multiplier := rand.IntN(fortune) + 2 + return base * multiplier +} + +// fortuneDiscreteCount computes the drop count for a block with a discrete uniform distribution. A drop count is +// chosen with equal likelihood between min and max. Every level of Fortune will increase the max by one. The final +// drop count is then limited by the cap count. +func fortuneDiscreteCount(minCount, maxCount, capCount int, enchantments []item.Enchantment) int { + fortune := fortuneLevel(enchantments) + maxWithFortune := maxCount + fortune + return min(capCount, rand.IntN(maxWithFortune-minCount+1)+minCount) +} + +// fortuneBinomial computes the binomial distribution B(n=attempts, p=8/15) for crop seed drops. +func fortuneBinomial(attempts int) int { + count := 0 + for range attempts { + if rand.IntN(15) < 8 { + count++ + } + } + return count +} + +// oreDrops returns a drop function for ores that drop a single item, such as diamond. Silk touch tools will +// cause the ore block itself to always drop. Otherwise, a single item is dropped. The Fortune enchantment has a +// 2/(level + 2) chance of applying an integer bonus multiplier between 2x up to (level + 1)x to the drop count. +func oreDrops(drop, block world.Item) func(item.Tool, []item.Enchantment) []item.Stack { + return func(t item.Tool, enchantments []item.Enchantment) []item.Stack { + if hasSilkTouch(enchantments) { + return []item.Stack{item.NewStack(block, 1)} + } + return []item.Stack{item.NewStack(drop, fortuneOreCount(1, enchantments))} + } +} + +// multiOreDrops returns a drop function for ores that drop multiple items, such as copper. Silk touch tools will +// cause the ore block itself to always drop. Otherwise, a drop count is chosen with equal likelihood between min +// and max. The Fortune enchantment has a 2/(level + 2) chance of applying an integer bonus multiplier between 2x +// up to (level + 1)x to the drop count. +func multiOreDrops(drop, block world.Item, minCount, maxCount int) func(item.Tool, []item.Enchantment) []item.Stack { + return func(t item.Tool, enchantments []item.Enchantment) []item.Stack { + if hasSilkTouch(enchantments) { + return []item.Stack{item.NewStack(block, 1)} + } + baseCount := rand.IntN(maxCount-minCount+1) + minCount + return []item.Stack{item.NewStack(drop, fortuneOreCount(baseCount, enchantments))} + } +} + +// discreteDrops returns a drop function for blocks with discrete uniform random drops, such as glowstone or melon +// blocks. Silk touch tools will cause the block itself to always drop. Otherwise, a drop count is chosen with equal +// likelihood between min and max. Every level of Fortune will increase the max by one. The final drop count is then +// limited by the cap count. +func discreteDrops(drop, block world.Item, minCount, maxCount, capCount int) func(item.Tool, []item.Enchantment) []item.Stack { + return func(t item.Tool, enchantments []item.Enchantment) []item.Stack { + if hasSilkTouch(enchantments) { + return []item.Stack{item.NewStack(block, 1)} + } + return []item.Stack{item.NewStack(drop, fortuneDiscreteCount(minCount, maxCount, capCount, enchantments))} + } +} + +// grassDrops returns a drop function for grass/fern blocks. Shears or silk touch tools will cause the grass block +// itself to always drop. Otherwise, there is a 12.5% chance of dropping a wheat seed. Every level of Fortune will +// increase the max drop count by 2, with each possible drop count being equally likely. +func grassDrops(grass world.Item) func(item.Tool, []item.Enchantment) []item.Stack { + return func(t item.Tool, enchantments []item.Enchantment) []item.Stack { + if t.ToolType() == item.TypeShears || hasSilkTouch(enchantments) { + return []item.Stack{item.NewStack(grass, 1)} + } + if rand.Float32() < 0.125 { + count := 1 + if fortune := fortuneLevel(enchantments); fortune > 0 { + count += rand.IntN(fortune*2 + 1) + } + return []item.Stack{item.NewStack(WheatSeeds{}, count)} + } + return nil + } +} + +// cropSeedDrops returns a drop function for wheat/beetroot seeds. +// Uses binomial distribution B(3+fortune, 8/15), seeds may not drop. +func cropSeedDrops(seed, crop world.Item, growth int) func(item.Tool, []item.Enchantment) []item.Stack { + return func(t item.Tool, enchantments []item.Enchantment) []item.Stack { + if growth < 7 { + return []item.Stack{item.NewStack(seed, 1)} + } + seedCount := fortuneBinomial(3 + fortuneLevel(enchantments)) + if seedCount == 0 { + return []item.Stack{item.NewStack(crop, 1)} + } + return []item.Stack{item.NewStack(crop, 1), item.NewStack(seed, seedCount)} + } +} + +// breakBlock removes a block, shows breaking particles and drops the drops of +// the block as items. +func breakBlock(b world.Block, pos cube.Pos, tx *world.Tx) { + breakBlockNoDrops(b, pos, tx) + if breakable, ok := b.(Breakable); ok { + for _, drop := range breakable.BreakInfo().Drops(item.ToolNone{}, nil) { + dropItem(tx, drop, pos.Vec3Centre()) + } + } +} + +func breakBlockNoDrops(b world.Block, pos cube.Pos, tx *world.Tx) { + // Clear the block first so neighbour-sensitive break handlers observe the post-break world state. + tx.SetBlock(pos, nil, nil) + if breakable, ok := b.(Breakable); ok { + breakHandler := breakable.BreakInfo().BreakHandler + if breakHandler != nil { + breakHandler(pos, tx, nil) + } + } + tx.AddParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) +} diff --git a/server/block/brewer.go b/server/block/brewer.go new file mode 100644 index 0000000..f1540b0 --- /dev/null +++ b/server/block/brewer.go @@ -0,0 +1,247 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/inventory" + "github.com/df-mc/dragonfly/server/item/recipe" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "sync" + "time" +) + +// brewer is a struct that may be embedded by blocks that can brew potions, such as brewing stands. +type brewer struct { + mu sync.Mutex + + viewers map[ContainerViewer]struct{} + inventory *inventory.Inventory + + duration time.Duration + fuelAmount int32 + fuelTotal int32 +} + +// newBrewer creates a new initialised brewer. The inventory is properly initialised. +func newBrewer() *brewer { + b := &brewer{viewers: make(map[ContainerViewer]struct{})} + b.inventory = inventory.New(5, func(slot int, _, item item.Stack) { + b.mu.Lock() + defer b.mu.Unlock() + for viewer := range b.viewers { + viewer.ViewSlotChange(slot, item) + } + }) + return b +} + +// InsertItem ... +func (b *brewer) InsertItem(h Hopper, pos cube.Pos, tx *world.Tx) bool { + for sourceSlot, sourceStack := range h.inventory.Slots() { + var slot int + + if sourceStack.Empty() { + continue + } + + if h.Facing == cube.FaceDown { + if !recipe.ValidBrewingReagent(sourceStack.Item()) { + // This item is not a valid brewing reagent. + continue + } + slot = 0 + } else if _, ok := sourceStack.Item().(item.BlazePowder); ok { + slot = 4 + } else { + _, okPotion := sourceStack.Item().(item.Potion) + _, okSplash := sourceStack.Item().(item.SplashPotion) + _, okLingering := sourceStack.Item().(item.LingeringPotion) + _, okBottle := sourceStack.Item().(item.GlassBottle) + if !okPotion && !okSplash && !okLingering && !okBottle { + continue + } + for brewingSlot, brewingStack := range b.inventory.Slots() { + if brewingSlot == 0 || brewingSlot == 4 { + continue + } + if brewingStack.Count() == brewingStack.MaxCount() || !brewingStack.Comparable(sourceStack) { + continue + } + + slot = brewingSlot + break + } + // Could not find an empty slot + if slot == 0 { + continue + } + } + + stack := sourceStack.Grow(-sourceStack.Count() + 1) + it, _ := b.Inventory(tx, pos).Item(slot) + + if !sourceStack.Comparable(it) { + // The items are not the same. + continue + } + if it.Count() == it.MaxCount() { + // The item has the maximum count that the stack is able to hold. + continue + } + if !it.Empty() { + stack = it.Grow(1) + } + + _ = b.Inventory(tx, pos).SetItem(slot, stack) + _ = h.inventory.SetItem(sourceSlot, sourceStack.Grow(-1)) + return true + + } + return false +} + +// ExtractItem ... +func (b *brewer) ExtractItem(h Hopper, pos cube.Pos, tx *world.Tx) bool { + for sourceSlot, sourceStack := range b.inventory.Slots() { + if sourceStack.Empty() || sourceSlot == 0 || sourceSlot == 4 { + continue + } + _, err := h.inventory.AddItem(sourceStack.Grow(-sourceStack.Count() + 1)) + if err != nil { + // The hopper is full. + continue + } + _ = b.Inventory(tx, pos).SetItem(sourceSlot, sourceStack.Grow(-1)) + return true + } + return false +} + +// Duration returns the remaining duration of the brewing process. +func (b *brewer) Duration() time.Duration { + b.mu.Lock() + defer b.mu.Unlock() + return b.duration +} + +// Fuel returns the fuel and maximum fuel of the brewer. +func (b *brewer) Fuel() (fuel, maxFuel int32) { + b.mu.Lock() + defer b.mu.Unlock() + return b.fuelAmount, b.fuelTotal +} + +// Inventory returns the inventory of the brewer. +func (b *brewer) Inventory(*world.Tx, cube.Pos) *inventory.Inventory { + return b.inventory +} + +// AddViewer adds a viewer to the brewer, so that it is updated whenever the inventory of the brewer is changed. +func (b *brewer) AddViewer(v ContainerViewer, _ *world.Tx, _ cube.Pos) { + b.mu.Lock() + defer b.mu.Unlock() + b.viewers[v] = struct{}{} +} + +// RemoveViewer removes a viewer from the brewer, so that slot updates in the inventory are no longer sent to +// it. +func (b *brewer) RemoveViewer(v ContainerViewer, _ *world.Tx, _ cube.Pos) { + b.mu.Lock() + defer b.mu.Unlock() + delete(b.viewers, v) +} + +// setDuration sets the brew duration of the brewer to the given duration. +func (b *brewer) setDuration(duration time.Duration) { + b.mu.Lock() + defer b.mu.Unlock() + b.duration = duration +} + +// setFuel sets the fuel of the brewer to the given fuel and maximum fuel. +func (b *brewer) setFuel(fuel, maxFuel int32) { + b.mu.Lock() + defer b.mu.Unlock() + b.fuelAmount, b.fuelTotal = fuel, maxFuel +} + +// tickBrewing ticks the brewer, ensuring the necessary items exist in the brewer, and then processing all inputted +// items for the necessary duration. +func (b *brewer) tickBrewing(block string, pos cube.Pos, tx *world.Tx) { + b.mu.Lock() + + // Get each item in the brewer. We don't need to validate errors here since we know the bounds of the brewer. + left, _ := b.inventory.Item(1) + middle, _ := b.inventory.Item(2) + right, _ := b.inventory.Item(3) + + // Keep track of our past durations, since if any of them change, we need to be able to tell they did and then + // update the viewers on the change. + prevDuration := b.duration + prevFuelAmount := b.fuelAmount + prevFuelTotal := b.fuelTotal + + // If we need fuel, try and burn some. + fuel, _ := b.inventory.Item(4) + + if _, ok := fuel.Item().(item.BlazePowder); ok && b.fuelAmount <= 0 { + defer b.inventory.SetItem(4, fuel.Grow(-1)) + b.fuelAmount, b.fuelTotal = 20, 20 + } + + // Now get the ingredient item. + ingredient, _ := b.inventory.Item(0) + + // Check each input and see if it is affected by the ingredient. + leftOutput, leftAffected := recipe.Perform(block, left.Item(), ingredient.Item()) + middleOutput, middleAffected := recipe.Perform(block, middle.Item(), ingredient.Item()) + rightOutput, rightAffected := recipe.Perform(block, right.Item(), ingredient.Item()) + + // Ensure that we have enough fuel to continue. + if b.fuelAmount > 0 { + // Now make sure that we have at least one potion that is affected by the ingredient. + if leftAffected || middleAffected || rightAffected { + // Tick our duration. If we have no brew duration, set it to the default of twenty seconds. + if b.duration == 0 { + b.duration = time.Second * 20 + } + b.duration -= time.Millisecond * 50 + + // If we have no duration, we are done. + if b.duration <= 0 { + // Create the output items. + if leftAffected { + defer b.inventory.SetItem(1, leftOutput[0]) + } + if middleAffected { + defer b.inventory.SetItem(2, middleOutput[0]) + } + if rightAffected { + defer b.inventory.SetItem(3, rightOutput[0]) + } + + // Reduce the ingredient by one. + defer b.inventory.SetItem(0, ingredient.Grow(-1)) + tx.PlaySound(pos.Vec3Centre(), sound.PotionBrewed{}) + + // Decrement the fuel, and reset the duration. + b.fuelAmount-- + b.duration = 0 + } + } else { + // None of the potions are affected by the ingredient, so reset the duration. + b.duration = 0 + } + } else { + // We don't have enough fuel, so reset our progress. + b.duration, b.fuelAmount, b.fuelTotal = 0, 0, 0 + } + + // Update the viewers on the new durations. + for v := range b.viewers { + v.ViewBrewingUpdate(prevDuration, b.duration, prevFuelAmount, b.fuelAmount, prevFuelTotal, b.fuelTotal) + } + + b.mu.Unlock() +} diff --git a/server/block/brewing_stand.go b/server/block/brewing_stand.go new file mode 100644 index 0000000..bc54092 --- /dev/null +++ b/server/block/brewing_stand.go @@ -0,0 +1,148 @@ +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/go-gl/mathgl/mgl64" + "time" +) + +// BrewingStand is a block used for brewing potions, splash potions, and lingering potions. It also serves as a cleric's +// job site block generated in village churches. +type BrewingStand struct { + sourceWaterDisplacer + transparent + *brewer + + // LeftSlot is true if the left slot is filled. + LeftSlot bool + // MiddleSlot is true if the middle slot is filled. + MiddleSlot bool + // RightSlot is true if the right slot is filled. + RightSlot bool +} + +// NewBrewingStand creates a new initialised brewing stand. The inventory is properly initialised. +func NewBrewingStand() BrewingStand { + return BrewingStand{brewer: newBrewer()} +} + +// Model ... +func (b BrewingStand) Model() world.BlockModel { + return model.BrewingStand{} +} + +// SideClosed ... +func (b BrewingStand) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// Tick is called to check if the brewing stand should update and start or stop brewing. +func (b BrewingStand) Tick(_ int64, pos cube.Pos, tx *world.Tx) { + // Get each item in the brewing stand. We don't need to validate errors here since we know the bounds of the stand. + left, _ := b.inventory.Item(1) + middle, _ := b.inventory.Item(2) + right, _ := b.inventory.Item(3) + + // If any of the slots in the inventory got updated, update the appearance of the brewing stand. + displayLeft, displayMiddle, displayRight := b.LeftSlot, b.MiddleSlot, b.RightSlot + b.LeftSlot, b.MiddleSlot, b.RightSlot = !left.Empty(), !middle.Empty(), !right.Empty() + if b.LeftSlot != displayLeft || b.MiddleSlot != displayMiddle || b.RightSlot != displayRight { + tx.SetBlock(pos, b, nil) + } + + // Tick brewing. + b.tickBrewing("brewing_stand", pos, tx) +} + +// Activate ... +func (b BrewingStand) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + if opener, ok := u.(ContainerOpener); ok { + opener.OpenBlockContainer(pos, tx) + return true + } + return false +} + +// UseOnBlock ... +func (b BrewingStand) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, b) + if !used { + return + } + + //noinspection GoAssignmentToReceiver + b = NewBrewingStand() + place(tx, pos, b, user, ctx) + return placed(ctx) +} + +// EncodeNBT ... +func (b BrewingStand) EncodeNBT() map[string]any { + if b.brewer == nil { + //noinspection GoAssignmentToReceiver + b = NewBrewingStand() + } + duration := b.Duration() + fuel, totalFuel := b.Fuel() + return map[string]any{ + "id": "BrewingStand", + "Items": nbtconv.InvToNBT(b.inventory), + "CookTime": int16(duration.Milliseconds() / 50), + "FuelTotal": int16(totalFuel), + "FuelAmount": int16(fuel), + } +} + +// DecodeNBT ... +func (b BrewingStand) DecodeNBT(data map[string]any) any { + brew := time.Duration(nbtconv.Int16(data, "CookTime")) * time.Millisecond * 50 + + fuel := int32(nbtconv.Int16(data, "FuelAmount")) + maxFuel := int32(nbtconv.Int16(data, "FuelTotal")) + + //noinspection GoAssignmentToReceiver + b = NewBrewingStand() + b.setDuration(brew) + b.setFuel(fuel, maxFuel) + nbtconv.InvFromNBT(b.inventory, nbtconv.Slice(data, "Items")) + return b +} + +// BreakInfo ... +func (b BrewingStand) BreakInfo() BreakInfo { + return newBreakInfo(0.5, alwaysHarvestable, pickaxeEffective, oneOf(BrewingStand{})).withBreakHandler(func(pos cube.Pos, tx *world.Tx, u item.User) { + for _, i := range b.Inventory(tx, pos).Clear() { + dropItem(tx, i, pos.Vec3Centre()) + } + }) +} + +// EncodeBlock ... +func (b BrewingStand) EncodeBlock() (string, map[string]any) { + return "minecraft:brewing_stand", map[string]any{ + "brewing_stand_slot_a_bit": b.LeftSlot, + "brewing_stand_slot_b_bit": b.MiddleSlot, + "brewing_stand_slot_c_bit": b.RightSlot, + } +} + +// EncodeItem ... +func (b BrewingStand) EncodeItem() (name string, meta int16) { + return "minecraft:brewing_stand", 0 +} + +// allBrewingStands ... +func allBrewingStands() (stands []world.Block) { + for _, left := range []bool{false, true} { + for _, middle := range []bool{false, true} { + for _, right := range []bool{false, true} { + stands = append(stands, BrewingStand{LeftSlot: left, MiddleSlot: middle, RightSlot: right}) + } + } + } + return +} diff --git a/server/block/bricks.go b/server/block/bricks.go new file mode 100644 index 0000000..3266513 --- /dev/null +++ b/server/block/bricks.go @@ -0,0 +1,22 @@ +package block + +// Bricks are decorative building blocks. +type Bricks struct { + solid + bassDrum +} + +// BreakInfo ... +func (b Bricks) BreakInfo() BreakInfo { + return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(b)).withBlastResistance(30) +} + +// EncodeItem ... +func (Bricks) EncodeItem() (name string, meta int16) { + return "minecraft:brick_block", 0 +} + +// EncodeBlock ... +func (Bricks) EncodeBlock() (string, map[string]any) { + return "minecraft:brick_block", nil +} diff --git a/server/block/cactus.go b/server/block/cactus.go new file mode 100644 index 0000000..b8a0562 --- /dev/null +++ b/server/block/cactus.go @@ -0,0 +1,121 @@ +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/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" + "math/rand/v2" +) + +// Cactus is a plant block that generates naturally in dry areas and causes damage. +type Cactus struct { + transparent + + // Age is the growth state of cactus. Values range from 0 to 15. + Age int +} + +// UseOnBlock handles making sure the neighbouring blocks are air. +func (c Cactus) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, c) + if !used || !c.canGrowHere(pos, tx, true) { + return false + } + + place(tx, pos, c, user, ctx) + return placed(ctx) +} + +// NeighbourUpdateTick ... +func (c Cactus) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if !c.canGrowHere(pos, tx, true) { + breakBlock(c, pos, tx) + } +} + +// RandomTick ... +func (c Cactus) RandomTick(pos cube.Pos, tx *world.Tx, _ *rand.Rand) { + if c.Age < 15 { + c.Age++ + } else if c.Age == 15 { + c.Age = 0 + if c.canGrowHere(pos.Side(cube.FaceDown), tx, false) { + for y := 1; y < 3; y++ { + if _, ok := tx.Block(pos.Add(cube.Pos{0, y})).(Air); ok { + tx.SetBlock(pos.Add(cube.Pos{0, y}), Cactus{Age: 0}, nil) + break + } else if _, ok := tx.Block(pos.Add(cube.Pos{0, y})).(Cactus); !ok { + break + } + } + } + } + tx.SetBlock(pos, c, nil) +} + +// canGrowHere implements logic to check if cactus can live/grow here. +func (c Cactus) canGrowHere(pos cube.Pos, tx *world.Tx, recursive bool) bool { + for _, face := range cube.HorizontalFaces() { + if _, ok := tx.Block(pos.Side(face)).(Air); !ok { + return false + } + } + if _, ok := tx.Block(pos.Side(cube.FaceDown)).(Cactus); ok && recursive { + return c.canGrowHere(pos.Side(cube.FaceDown), tx, recursive) + } + return supportsVegetation(c, tx.Block(pos.Sub(cube.Pos{0, 1}))) +} + +// EntityInside ... +func (c Cactus) EntityInside(_ cube.Pos, _ *world.Tx, e world.Entity) { + if l, ok := e.(livingEntity); ok { + l.Hurt(0.5, DamageSource{Block: c}) + } +} + +// BreakInfo ... +func (c Cactus) BreakInfo() BreakInfo { + return newBreakInfo(0.4, alwaysHarvestable, nothingEffective, oneOf(c)) +} + +// CompostChance ... +func (Cactus) CompostChance() float64 { + return 0.5 +} + +// EncodeItem ... +func (c Cactus) EncodeItem() (name string, meta int16) { + return "minecraft:cactus", 0 +} + +// EncodeBlock ... +func (c Cactus) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:cactus", map[string]any{"age": int32(c.Age)} +} + +// Model ... +func (c Cactus) Model() world.BlockModel { + return model.Cactus{} +} + +// allCactus returns all possible states of a cactus block. +func allCactus() (b []world.Block) { + for i := 0; i < 16; i++ { + b = append(b, Cactus{Age: i}) + } + return +} + +// DamageSource is passed as world.DamageSource for damage caused by a block, +// such as a cactus or a falling anvil. +type DamageSource struct { + // Block is the block that caused the damage. + Block world.Block +} + +func (DamageSource) ReducedByResistance() bool { return true } +func (DamageSource) ReducedByArmour() bool { return true } +func (DamageSource) Fire() bool { return false } +func (DamageSource) IgnoreTotem() bool { return false } diff --git a/server/block/cake.go b/server/block/cake.go new file mode 100644 index 0000000..2122c94 --- /dev/null +++ b/server/block/cake.go @@ -0,0 +1,92 @@ +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/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" +) + +// Cake is an edible block. +type Cake struct { + transparent + sourceWaterDisplacer + + // Bites is the amount of bites taken out of the cake. + Bites int +} + +// SideClosed ... +func (c Cake) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// UseOnBlock ... +func (c Cake) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, c) + if !used { + return false + } + + if _, air := tx.Block(pos.Side(cube.FaceDown)).(Air); air { + return false + } + + place(tx, pos, c, user, ctx) + return placed(ctx) +} + +// NeighbourUpdateTick ... +func (c Cake) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if _, air := tx.Block(pos.Side(cube.FaceDown)).(Air); air { + breakBlock(c, pos, tx) + } +} + +// Activate ... +func (c Cake) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + if i, ok := u.(interface { + Saturate(food int, saturation float64) + }); ok { + i.Saturate(2, 0.4) + tx.PlaySound(u.Position().Add(mgl64.Vec3{0, 1.5}), sound.Burp{}) + c.Bites++ + if c.Bites > 6 { + tx.SetBlock(pos, nil, nil) + return true + } + tx.SetBlock(pos, c, nil) + return true + } + return false +} + +// BreakInfo ... +func (c Cake) BreakInfo() BreakInfo { + return newBreakInfo(0.5, neverHarvestable, nothingEffective, simpleDrops()) +} + +// EncodeItem ... +func (c Cake) EncodeItem() (name string, meta int16) { + return "minecraft:cake", 0 +} + +// EncodeBlock ... +func (c Cake) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:cake", map[string]any{"bite_counter": int32(c.Bites)} +} + +// Model ... +func (c Cake) Model() world.BlockModel { + return model.Cake{Bites: c.Bites} +} + +// allCake ... +func allCake() (cake []world.Block) { + for bites := 0; bites < 7; bites++ { + cake = append(cake, Cake{Bites: bites}) + } + return +} diff --git a/server/block/calcite.go b/server/block/calcite.go new file mode 100644 index 0000000..82f62a3 --- /dev/null +++ b/server/block/calcite.go @@ -0,0 +1,22 @@ +package block + +// Calcite is a carbonate mineral found as part of amethyst geodes. +type Calcite struct { + solid + bassDrum +} + +// BreakInfo ... +func (c Calcite) BreakInfo() BreakInfo { + return newBreakInfo(0.75, pickaxeHarvestable, pickaxeEffective, oneOf(c)) +} + +// EncodeItem ... +func (c Calcite) EncodeItem() (name string, meta int16) { + return "minecraft:calcite", 0 +} + +// EncodeBlock ... +func (c Calcite) EncodeBlock() (string, map[string]any) { + return "minecraft:calcite", nil +} diff --git a/server/block/campfire.go b/server/block/campfire.go new file mode 100644 index 0000000..88a1439 --- /dev/null +++ b/server/block/campfire.go @@ -0,0 +1,299 @@ +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/v2" + "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.Tx) bool { + return false +} + +// BreakInfo ... +func (c Campfire) BreakInfo() BreakInfo { + return newBreakInfo(2, alwaysHarvestable, axeEffective, func(t item.Tool, enchantments []item.Enchantment) []item.Stack { + if hasSilkTouch(enchantments) { + return []item.Stack{item.NewStack(Campfire{Type: c.Type}, 1)} + } + switch c.Type { + case NormalFire(): + return []item.Stack{item.NewStack(item.Charcoal{}, 2)} + case SoulFire(): + return []item.Stack{item.NewStack(SoulSoil{}, 1)} + } + panic("should never happen") + }).withBreakHandler(func(pos cube.Pos, tx *world.Tx, u item.User) { + for _, v := range c.Items { + if !v.Item.Empty() { + dropItem(tx, v.Item, pos.Vec3Centre()) + } + } + }) +} + +// LightEmissionLevel ... +func (c Campfire) LightEmissionLevel() uint8 { + if c.Extinguished { + return 0 + } + return c.Type.LightLevel() +} + +// Ignite ... +func (c Campfire) Ignite(pos cube.Pos, tx *world.Tx, _ world.Entity) bool { + tx.PlaySound(pos.Vec3(), sound.Ignite{}) + if !c.Extinguished { + return false + } + if _, ok := tx.Liquid(pos); ok { + return false + } + + c.Extinguished = false + tx.SetBlock(pos, c, nil) + return true +} + +// Splash ... +func (c Campfire) Splash(tx *world.Tx, pos cube.Pos) { + if c.Extinguished { + return + } + + c.extinguish(pos, tx) +} + +// extinguish extinguishes the campfire. +func (c Campfire) extinguish(pos cube.Pos, tx *world.Tx) { + tx.PlaySound(pos.Vec3Centre(), sound.FireExtinguish{}) + c.Extinguished = true + + for i := range c.Items { + c.Items[i].Time = time.Second * 30 + } + + tx.SetBlock(pos, c, nil) +} + +// Activate ... +func (c Campfire) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, 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, tx) + ctx.DamageItem(1) + return true + } + + rawFood, ok := held.Item().(item.Smeltable) + if !ok || !rawFood.SmeltInfo().Food { + return false + } + + if _, ok = tx.Liquid(pos); ok { + 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) + + tx.PlaySound(pos.Vec3Centre(), sound.ItemAdd{}) + tx.SetBlock(pos, c, nil) + return true + } + } + return false +} + +// UseOnBlock ... +func (c Campfire) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, c) + if !used { + return + } + if _, ok := tx.Block(pos.Side(cube.FaceDown)).(Campfire); ok { + return false + } + c.Facing = user.Rotation().Direction().Opposite() + place(tx, 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, tx *world.Tx) { + if c.Extinguished { + // Extinguished, do nothing. + return + } + if rand.Float64() <= 0.016 { // Every three or so seconds. + tx.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(tx, food.SmeltInfo().Product, pos.Vec3Middle()) + } + c.Items[i].Item = item.Stack{} + } + if updated { + tx.SetBlock(pos, c, nil) + } +} + +// NeighbourUpdateTick ... +func (c Campfire) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if _, ok := tx.Liquid(pos); ok { + var updated bool + for i, it := range c.Items { + if !it.Item.Empty() { + dropItem(tx, it.Item, pos.Vec3Centre()) + c.Items[i].Item, updated = item.Stack{}, true + } + } + if !c.Extinguished { + c.extinguish(pos, tx) + } else if updated { + tx.SetBlock(pos, c, nil) + } + return + } + if liquid, ok := tx.Liquid(pos.Side(cube.FaceUp)); ok && liquid.LiquidType() == "water" && !c.Extinguished { + c.extinguish(pos, tx) + } +} + +// EntityInside ... +func (c Campfire) EntityInside(pos cube.Pos, tx *world.Tx, e world.Entity) { + if flammable, ok := e.(flammableEntity); ok { + if flammable.OnFireDuration() > 0 && c.Extinguished { + c.Extinguished = false + tx.PlaySound(pos.Vec3(), sound.Ignite{}) + tx.SetBlock(pos, c, nil) + } + if !c.Extinguished { + if l, ok := e.(livingEntity); ok { + 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/carpet.go b/server/block/carpet.go new file mode 100644 index 0000000..54a675d --- /dev/null +++ b/server/block/carpet.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" +) + +// Carpet is a colourful block that can be obtained by killing/shearing sheep, or crafted using four string. +type Carpet struct { + carpet + transparent + sourceWaterDisplacer + + // Colour is the colour of the carpet. + Colour item.Colour +} + +// FlammabilityInfo ... +func (c Carpet) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(30, 20, true) +} + +// SideClosed ... +func (Carpet) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// BreakInfo ... +func (c Carpet) BreakInfo() BreakInfo { + return newBreakInfo(0.1, alwaysHarvestable, nothingEffective, oneOf(c)) +} + +// EncodeItem ... +func (c Carpet) EncodeItem() (name string, meta int16) { + return "minecraft:" + c.Colour.String() + "_carpet", 0 +} + +// EncodeBlock ... +func (c Carpet) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:" + c.Colour.String() + "_carpet", nil +} + +// HasLiquidDrops ... +func (Carpet) HasLiquidDrops() bool { + return true +} + +// NeighbourUpdateTick ... +func (c Carpet) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if _, ok := tx.Block(pos.Side(cube.FaceDown)).(Air); ok { + breakBlock(c, pos, tx) + } +} + +// UseOnBlock handles not placing carpets on top of air blocks. +func (c Carpet) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, c) + if !used { + return + } + + if _, ok := tx.Block(pos.Side(cube.FaceDown)).(Air); ok { + return + } + + place(tx, pos, c, user, ctx) + return placed(ctx) +} + +// allCarpet ... +func allCarpet() (carpets []world.Block) { + for _, c := range item.Colours() { + carpets = append(carpets, Carpet{Colour: c}) + } + return +} diff --git a/server/block/carrot.go b/server/block/carrot.go new file mode 100644 index 0000000..834fa35 --- /dev/null +++ b/server/block/carrot.go @@ -0,0 +1,108 @@ +package block + +import ( + "math/rand/v2" + "time" + + "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" +) + +// Carrot is a crop that can be consumed raw. +type Carrot struct { + crop +} + +// SameCrop ... +func (Carrot) SameCrop(c Crop) bool { + _, ok := c.(Carrot) + return ok +} + +// AlwaysConsumable ... +func (c Carrot) AlwaysConsumable() bool { + return false +} + +// ConsumeDuration ... +func (c Carrot) ConsumeDuration() time.Duration { + return item.DefaultConsumeDuration +} + +// Consume ... +func (c Carrot) Consume(_ *world.Tx, co item.Consumer) item.Stack { + co.Saturate(3, 3.6) + return item.Stack{} +} + +// BoneMeal ... +func (c Carrot) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { + if c.Growth == 7 { + return item.BoneMealResultNone + } + c.Growth = min(c.Growth+rand.IntN(4)+2, 7) + tx.SetBlock(pos, c, nil) + return item.BoneMealResultSmall +} + +// UseOnBlock ... +func (c Carrot) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, c) + if !used { + return false + } + + if _, ok := tx.Block(pos.Side(cube.FaceDown)).(Farmland); !ok { + return false + } + + place(tx, pos, c, user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (c Carrot) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, func(t item.Tool, enchantments []item.Enchantment) []item.Stack { + if c.Growth < 7 { + return []item.Stack{item.NewStack(c, 1)} + } + fortune := fortuneLevel(enchantments) + count := rand.IntN(fortune+1) + 1 + fortuneBinomial(3+fortune) + return []item.Stack{item.NewStack(c, count)} + }) +} + +// CompostChance ... +func (Carrot) CompostChance() float64 { + return 0.65 +} + +// EncodeItem ... +func (c Carrot) EncodeItem() (name string, meta int16) { + return "minecraft:carrot", 0 +} + +// RandomTick ... +func (c Carrot) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + if tx.Light(pos) < 8 { + breakBlock(c, pos, tx) + } else if c.Growth < 7 && r.Float64() <= c.CalculateGrowthChance(pos, tx) { + c.Growth++ + tx.SetBlock(pos, c, nil) + } +} + +// EncodeBlock ... +func (c Carrot) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:carrots", map[string]any{"growth": int32(c.Growth)} +} + +// allCarrots ... +func allCarrots() (carrots []world.Block) { + for growth := 0; growth < 8; growth++ { + carrots = append(carrots, Carrot{crop{Growth: growth}}) + } + return +} diff --git a/server/block/chest.go b/server/block/chest.go new file mode 100644 index 0000000..136b37e --- /dev/null +++ b/server/block/chest.go @@ -0,0 +1,359 @@ +package block + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/inventory" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" + "strings" + "sync" + "time" +) + +// Chest is a container block which may be used to store items. Chests may also be paired to create a bigger +// single container. +// The empty value of Chest is not valid. It must be created using block.NewChest(). +type Chest struct { + chest + transparent + bass + sourceWaterDisplacer + + // Facing is the direction that the chest is facing. + Facing cube.Direction + // CustomName is the custom name of the chest. This name is displayed when the chest is opened, and may + // include colour codes. + CustomName string + + paired bool + pairX, pairZ int + pairInv *inventory.Inventory + + inventory *inventory.Inventory + viewerMu *sync.RWMutex + viewers map[ContainerViewer]struct{} +} + +// NewChest creates a new initialised chest. The inventory is properly initialised. +func NewChest() Chest { + c := Chest{ + viewerMu: new(sync.RWMutex), + viewers: make(map[ContainerViewer]struct{}, 1), + } + + c.inventory = inventory.New(27, func(slot int, _, after item.Stack) { + c.viewerMu.RLock() + defer c.viewerMu.RUnlock() + for viewer := range c.viewers { + viewer.ViewSlotChange(slot, after) + } + }) + return c +} + +// Inventory returns the inventory of the chest. The size of the inventory will be 27 or 54, depending on +// whether the chest is single or double. +func (c Chest) Inventory(tx *world.Tx, pos cube.Pos) *inventory.Inventory { + inv, _ := c.tryPair(tx, pos) + return inv +} + +// tryPair attempts to pair the inventories of this chest with a potential +// paired chest next to it. The (shared) inventory is returned and a bool is +// returned indicating if the chest changed its pairing state. +func (c Chest) tryPair(tx *world.Tx, pos cube.Pos) (*inventory.Inventory, bool) { + if c.paired { + if c.pairInv == nil { + if ch, pair, ok := c.pair(tx, pos, c.pairPos(pos)); ok { + tx.SetBlock(pos, ch, nil) + tx.SetBlock(c.pairPos(pos), pair, nil) + return ch.pairInv, true + } + c.paired = false + tx.SetBlock(pos, c, nil) + return c.inventory, true + } + return c.pairInv, false + } + return c.inventory, false +} + +// WithName returns the chest after applying a specific name to the block. +func (c Chest) WithName(a ...any) world.Item { + c.CustomName = strings.TrimSuffix(fmt.Sprintln(a...), "\n") + return c +} + +// SideClosed ... +func (Chest) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// open opens the chest, displaying the animation and playing a sound. +func (c Chest) open(tx *world.Tx, pos cube.Pos) { + for _, v := range tx.Viewers(pos.Vec3()) { + if c.paired { + v.ViewBlockAction(c.pairPos(pos), OpenAction{}) + } + v.ViewBlockAction(pos, OpenAction{}) + } + tx.PlaySound(pos.Vec3Centre(), sound.ChestOpen{}) +} + +// close closes the chest, displaying the animation and playing a sound. +func (c Chest) close(tx *world.Tx, pos cube.Pos) { + for _, v := range tx.Viewers(pos.Vec3()) { + if c.paired { + v.ViewBlockAction(c.pairPos(pos), CloseAction{}) + } + v.ViewBlockAction(pos, CloseAction{}) + } + tx.PlaySound(pos.Vec3Centre(), sound.ChestClose{}) +} + +// AddViewer adds a viewer to the chest, so that it is updated whenever the inventory of the chest is changed. +func (c Chest) AddViewer(v ContainerViewer, tx *world.Tx, pos cube.Pos) { + if _, changed := c.tryPair(tx, pos); changed { + c = tx.Block(pos).(Chest) + } + c.viewerMu.Lock() + defer c.viewerMu.Unlock() + if len(c.viewers) == 0 { + c.open(tx, pos) + } + c.viewers[v] = struct{}{} +} + +// RemoveViewer removes a viewer from the chest, so that slot updates in the inventory are no longer sent to +// it. +func (c Chest) RemoveViewer(v ContainerViewer, tx *world.Tx, pos cube.Pos) { + if _, changed := c.tryPair(tx, pos); changed { + c = tx.Block(pos).(Chest) + } + c.viewerMu.Lock() + defer c.viewerMu.Unlock() + if len(c.viewers) == 0 { + return + } + delete(c.viewers, v) + if len(c.viewers) == 0 { + c.close(tx, pos) + } +} + +// Activate ... +func (c Chest) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + if opener, ok := u.(ContainerOpener); ok { + if c.paired { + if d, ok := tx.Block(c.pairPos(pos).Side(cube.FaceUp)).(LightDiffuser); !ok || d.LightDiffusionLevel() > 2 { + return false + } + } + if d, ok := tx.Block(pos.Side(cube.FaceUp)).(LightDiffuser); ok && d.LightDiffusionLevel() <= 2 { + opener.OpenBlockContainer(pos, tx) + } + return true + } + return false +} + +// UseOnBlock ... +func (c Chest) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, c) + if !used { + return + } + //noinspection GoAssignmentToReceiver + c = NewChest() + c.Facing = user.Rotation().Direction().Opposite() + + // Check both sides of the chest to see if it is possible to pair with another chest. + for _, dir := range []cube.Direction{c.Facing.RotateLeft(), c.Facing.RotateRight()} { + if ch, pair, ok := c.pair(tx, pos, pos.Side(dir.Face())); ok { + place(tx, pos, ch, user, ctx) + tx.SetBlock(ch.pairPos(pos), pair, nil) + return placed(ctx) + } + } + + place(tx, pos, c, user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (c Chest) BreakInfo() BreakInfo { + return newBreakInfo(2.5, alwaysHarvestable, axeEffective, oneOf(c)).withBreakHandler(func(pos cube.Pos, tx *world.Tx, u item.User) { + if c.paired { + pairPos := c.pairPos(pos) + if _, pair, ok := c.unpair(tx, pos); ok { + c.paired = false + tx.SetBlock(pairPos, pair, nil) + } + } + + for _, i := range c.Inventory(tx, pos).Clear() { + dropItem(tx, i, pos.Vec3Centre()) + } + }) +} + +// FuelInfo ... +func (Chest) FuelInfo() item.FuelInfo { + return newFuelInfo(time.Second * 15) +} + +// FlammabilityInfo ... +func (c Chest) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(0, 0, true) +} + +// Paired returns whether the chest is paired with another chest. +func (c Chest) Paired() bool { + return c.paired +} + +// pair pairs this chest with the given chest position. +func (c Chest) pair(tx *world.Tx, pos, pairPos cube.Pos) (ch, pair Chest, ok bool) { + pair, ok = tx.Block(pairPos).(Chest) + if !ok || c.Facing != pair.Facing || pair.paired && (pair.pairX != pos[0] || pair.pairZ != pos[2]) { + return c, pair, false + } + m := new(sync.RWMutex) + v := make(map[ContainerViewer]struct{}) + left, right := c.inventory.Clone(nil), pair.inventory.Clone(nil) + if pos.Side(c.Facing.RotateRight().Face()) == pairPos { + left, right = right, left + } + double := left.Merge(right, func(slot int, _, item item.Stack) { + if slot < 27 { + _ = left.SetItem(slot, item) + } else { + _ = right.SetItem(slot-27, item) + } + m.RLock() + defer m.RUnlock() + for viewer := range v { + viewer.ViewSlotChange(slot, item) + } + }) + + c.inventory, pair.inventory = left, right + if pos.Side(c.Facing.RotateRight().Face()) == pairPos { + c.inventory, pair.inventory = right, left + } + c.pairX, c.pairZ, c.paired = pairPos[0], pairPos[2], true + pair.pairX, pair.pairZ, pair.paired = pos[0], pos[2], true + c.viewerMu, pair.viewerMu = m, m + c.viewers, pair.viewers = v, v + c.pairInv, pair.pairInv = double, double + return c, pair, true +} + +// unpair unpairs this chest from the chest it is currently paired with. +func (c Chest) unpair(tx *world.Tx, pos cube.Pos) (ch, pair Chest, ok bool) { + if !c.paired { + return c, Chest{}, false + } + + pair, ok = tx.Block(c.pairPos(pos)).(Chest) + if !ok || c.Facing != pair.Facing || pair.paired && (pair.pairX != pos[0] || pair.pairZ != pos[2]) { + return c, pair, false + } + + if len(c.viewers) != 0 { + c.close(tx, pos) + } + + c.inventory = c.inventory.Clone(func(slot int, _, after item.Stack) { + c.viewerMu.RLock() + defer c.viewerMu.RUnlock() + for viewer := range c.viewers { + viewer.ViewSlotChange(slot, after) + } + }) + pair.inventory = pair.inventory.Clone(func(slot int, _, after item.Stack) { + pair.viewerMu.RLock() + defer pair.viewerMu.RUnlock() + for viewer := range pair.viewers { + viewer.ViewSlotChange(slot, after) + } + }) + c.paired, pair.paired = false, false + c.viewerMu, pair.viewerMu = new(sync.RWMutex), new(sync.RWMutex) + c.viewers, pair.viewers = make(map[ContainerViewer]struct{}, 1), make(map[ContainerViewer]struct{}, 1) + c.pairInv, pair.pairInv = nil, nil + return c, pair, true +} + +// pairPos returns the position of the chest that this chest is paired with. +func (c Chest) pairPos(pos cube.Pos) cube.Pos { + return cube.Pos{c.pairX, pos[1], c.pairZ} +} + +// DecodeNBT ... +func (c Chest) DecodeNBT(data map[string]any) any { + facing := c.Facing + //noinspection GoAssignmentToReceiver + c = NewChest() + c.Facing = facing + c.CustomName = nbtconv.String(data, "CustomName") + + pairX, ok := data["pairx"] + pairZ, ok2 := data["pairz"] + if ok && ok2 { + pairX, ok := pairX.(int32) + pairZ, ok2 := pairZ.(int32) + if ok && ok2 { + c.paired = true + c.pairX, c.pairZ = int(pairX), int(pairZ) + } + } + + nbtconv.InvFromNBT(c.inventory, nbtconv.Slice(data, "Items")) + return c +} + +// EncodeNBT ... +func (c Chest) EncodeNBT() map[string]any { + if c.inventory == nil { + facing, customName := c.Facing, c.CustomName + //noinspection GoAssignmentToReceiver + c = NewChest() + c.Facing, c.CustomName = facing, customName + } + m := map[string]any{ + "Items": nbtconv.InvToNBT(c.inventory), + "id": "Chest", + } + if c.CustomName != "" { + m["CustomName"] = c.CustomName + } + + if c.paired { + m["pairx"] = int32(c.pairX) + m["pairz"] = int32(c.pairZ) + } + return m +} + +// EncodeItem ... +func (Chest) EncodeItem() (name string, meta int16) { + return "minecraft:chest", 0 +} + +// EncodeBlock ... +func (c Chest) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:chest", map[string]any{"minecraft:cardinal_direction": c.Facing.String()} +} + +// allChests ... +func allChests() (chests []world.Block) { + for _, direction := range cube.Directions() { + chests = append(chests, Chest{Facing: direction}) + } + return +} diff --git a/server/block/clay.go b/server/block/clay.go new file mode 100644 index 0000000..c864db8 --- /dev/null +++ b/server/block/clay.go @@ -0,0 +1,36 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world/sound" +) + +// Clay is a block that can be found underwater. +type Clay struct { + solid +} + +// Instrument ... +func (c Clay) Instrument() sound.Instrument { + return sound.Flute() +} + +// BreakInfo ... +func (c Clay) BreakInfo() BreakInfo { + return newBreakInfo(0.6, alwaysHarvestable, shovelEffective, silkTouchDrop(item.NewStack(item.ClayBall{}, 4), item.NewStack(c, 1))) +} + +// SmeltInfo ... +func (Clay) SmeltInfo() item.SmeltInfo { + return newSmeltInfo(item.NewStack(Terracotta{}, 1), 0.35) +} + +// EncodeItem ... +func (c Clay) EncodeItem() (name string, meta int16) { + return "minecraft:clay", 0 +} + +// EncodeBlock ... +func (c Clay) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:clay", nil +} diff --git a/server/block/coal.go b/server/block/coal.go new file mode 100644 index 0000000..870e0a1 --- /dev/null +++ b/server/block/coal.go @@ -0,0 +1,37 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" + "time" +) + +// Coal is a precious mineral block made from 9 coal. +type Coal struct { + solid + bassDrum +} + +// BreakInfo ... +func (c Coal) BreakInfo() BreakInfo { + return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(c)).withBlastResistance(30) +} + +// FlammabilityInfo ... +func (Coal) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(5, 5, false) +} + +// FuelInfo ... +func (Coal) FuelInfo() item.FuelInfo { + return newFuelInfo(time.Second * 800) +} + +// EncodeItem ... +func (Coal) EncodeItem() (name string, meta int16) { + return "minecraft:coal_block", 0 +} + +// EncodeBlock ... +func (Coal) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:coal_block", nil +} diff --git a/server/block/coal_ore.go b/server/block/coal_ore.go new file mode 100644 index 0000000..13d3d62 --- /dev/null +++ b/server/block/coal_ore.go @@ -0,0 +1,33 @@ +package block + +import "github.com/df-mc/dragonfly/server/item" + +// CoalOre is a common ore. +type CoalOre struct { + solid + bassDrum + + // Type is the type of coal ore. + Type OreType +} + +// BreakInfo ... +func (c CoalOre) BreakInfo() BreakInfo { + return newBreakInfo(c.Type.Hardness(), pickaxeHarvestable, pickaxeEffective, oreDrops(item.Coal{}, c)).withXPDropRange(0, 2).withBlastResistance(15) +} + +// SmeltInfo ... +func (CoalOre) SmeltInfo() item.SmeltInfo { + return newOreSmeltInfo(item.NewStack(item.Coal{}, 1), 0.1) +} + +// EncodeItem ... +func (c CoalOre) EncodeItem() (name string, meta int16) { + return "minecraft:" + c.Type.Prefix() + "coal_ore", 0 +} + +// EncodeBlock ... +func (c CoalOre) EncodeBlock() (string, map[string]any) { + return "minecraft:" + c.Type.Prefix() + "coal_ore", nil + +} diff --git a/server/block/cobblestone.go b/server/block/cobblestone.go new file mode 100644 index 0000000..3abff59 --- /dev/null +++ b/server/block/cobblestone.go @@ -0,0 +1,44 @@ +package block + +import "github.com/df-mc/dragonfly/server/item" + +// Cobblestone is a common block, obtained from mining stone. +type Cobblestone struct { + solid + bassDrum + + // Mossy specifies if the cobblestone is mossy. This variant of cobblestone is typically found in + // dungeons or in small clusters in the giant tree taiga biome. + Mossy bool +} + +// BreakInfo ... +func (c Cobblestone) BreakInfo() BreakInfo { + return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(c)).withBlastResistance(30) +} + +// SmeltInfo ... +func (Cobblestone) SmeltInfo() item.SmeltInfo { + return newSmeltInfo(item.NewStack(Stone{}, 1), 0.1) +} + +// RepairsStoneTools ... +func (c Cobblestone) RepairsStoneTools() bool { + return !c.Mossy +} + +// EncodeItem ... +func (c Cobblestone) EncodeItem() (name string, meta int16) { + if c.Mossy { + return "minecraft:mossy_cobblestone", 0 + } + return "minecraft:cobblestone", 0 +} + +// EncodeBlock ... +func (c Cobblestone) EncodeBlock() (string, map[string]any) { + if c.Mossy { + return "minecraft:mossy_cobblestone", nil + } + return "minecraft:cobblestone", nil +} diff --git a/server/block/cobweb.go b/server/block/cobweb.go new file mode 100644 index 0000000..b18ac0d --- /dev/null +++ b/server/block/cobweb.go @@ -0,0 +1,66 @@ +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" +) + +// Cobweb is a non-solid block that drastically slows entities passing through it. It is broken +// quickly with a sword or shears and drops string when broken without silk touch. +type Cobweb struct { + empty + transparent +} + +// Cobweb is implemented because the item package needs to identify this block but cannot implement the block package. +func (Cobweb) Cobweb() {} + +// EntityInside slows the entity's velocity and resets its fall distance while it is inside the cobweb. +func (Cobweb) EntityInside(_ cube.Pos, _ *world.Tx, e world.Entity) { + if fallEntity, ok := e.(fallDistanceEntity); ok { + fallEntity.ResetFallDistance() + } + if v, ok := e.(velocityEntity); ok { + vel := v.Velocity() + vel[0] *= 0.25 + vel[1] *= 0.05 + vel[2] *= 0.25 + v.SetVelocity(vel) + } +} + +// BreakInfo ... +func (c Cobweb) BreakInfo() BreakInfo { + return newBreakInfo( + 4, + alwaysHarvestable, + func(t item.Tool) bool { + return swordEffective(t) || shearsEffective(t) + }, + func(t item.Tool, enchantments []item.Enchantment) []item.Stack { + if t.ToolType() == item.TypeShears { + return oneOf(c)(t, enchantments) + } + if t.ToolType() == item.TypeSword { + return oneOf(String{})(t, enchantments) + } + return nil + }, + ).withBlastResistance(4) +} + +// HasLiquidDrops ... +func (Cobweb) HasLiquidDrops() bool { + return true +} + +// EncodeItem ... +func (Cobweb) EncodeItem() (name string, meta int16) { + return "minecraft:web", 0 +} + +// EncodeBlock ... +func (Cobweb) EncodeBlock() (string, map[string]any) { + return "minecraft:web", nil +} diff --git a/server/block/cocoa_bean.go b/server/block/cocoa_bean.go new file mode 100644 index 0000000..bf19b07 --- /dev/null +++ b/server/block/cocoa_bean.go @@ -0,0 +1,127 @@ +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" +) + +// CocoaBean is a crop block found in jungle biomes. +type CocoaBean struct { + transparent + + // Facing is the direction from the cocoa bean to the log. + Facing cube.Direction + // Age is the stage of the cocoa bean's growth. 2 is fully grown. + Age int +} + +// BoneMeal ... +func (c CocoaBean) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { + if c.Age == 2 { + return item.BoneMealResultNone + } + c.Age++ + tx.SetBlock(pos, c, nil) + return item.BoneMealResultSmall +} + +// HasLiquidDrops ... +func (c CocoaBean) HasLiquidDrops() bool { + return true +} + +// NeighbourUpdateTick ... +func (c CocoaBean) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + var woodType WoodType + switch b := tx.Block(pos.Side(c.Facing.Face())).(type) { + case Log: + woodType = b.Wood + case Wood: + woodType = b.Wood + } + if woodType != JungleWood() { + breakBlock(c, pos, tx) + } +} + +// UseOnBlock ... +func (c CocoaBean) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, c) + if !used { + return false + } + + if face == cube.FaceUp || face == cube.FaceDown { + return false + } + + var woodType WoodType + oppositePos := pos.Side(face.Opposite()) + if log, ok := tx.Block(oppositePos).(Log); ok { + woodType = log.Wood + } else if wood, ok := tx.Block(oppositePos).(Wood); ok { + woodType = wood.Wood + } + if woodType == JungleWood() { + c.Facing = face.Opposite().Direction() + ctx.IgnoreBBox = true + + place(tx, pos, c, user, ctx) + return placed(ctx) + } + + return false +} + +// RandomTick ... +func (c CocoaBean) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + if c.Age < 2 && r.IntN(5) == 0 { + c.Age++ + tx.SetBlock(pos, c, nil) + } +} + +// BreakInfo ... +func (c CocoaBean) BreakInfo() BreakInfo { + return newBreakInfo(0.2, alwaysHarvestable, axeEffective, func(item.Tool, []item.Enchantment) []item.Stack { + if c.Age == 2 { + return []item.Stack{item.NewStack(c, rand.IntN(2)+2)} + } + return []item.Stack{item.NewStack(c, 1)} + }).withBlastResistance(15) +} + +// CompostChance ... +func (CocoaBean) CompostChance() float64 { + return 0.65 +} + +// EncodeItem ... +func (c CocoaBean) EncodeItem() (name string, meta int16) { + return "minecraft:cocoa_beans", 0 +} + +// EncodeBlock ... +func (c CocoaBean) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:cocoa", map[string]any{"age": int32(c.Age), "direction": int32(horizontalDirection(c.Facing))} +} + +// Model ... +func (c CocoaBean) Model() world.BlockModel { + return model.CocoaBean{Facing: c.Facing, Age: c.Age} +} + +// allCocoaBeans ... +func allCocoaBeans() (cocoa []world.Block) { + for i := cube.Direction(0); i <= 3; i++ { + cocoa = append(cocoa, CocoaBean{Facing: i, Age: 0}) + cocoa = append(cocoa, CocoaBean{Facing: i, Age: 1}) + cocoa = append(cocoa, CocoaBean{Facing: i, Age: 2}) + } + return +} diff --git a/server/block/composter.go b/server/block/composter.go new file mode 100644 index 0000000..e6c0200 --- /dev/null +++ b/server/block/composter.go @@ -0,0 +1,157 @@ +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/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/particle" + "github.com/df-mc/dragonfly/server/world/sound" + "math/rand/v2" + "time" +) + +// Composter is a block that can turn biological matter in to compost which can then produce bone meal. It is also the +// work station for a farming villager. +type Composter struct { + bass + transparent + sourceWaterDisplacer + + // Level is the level of compost inside the composter. At level 8 it can be collected in the form of bone meal. + Level int +} + +// InsertItem ... +func (c Composter) InsertItem(h Hopper, pos cube.Pos, tx *world.Tx) bool { + if c.Level >= 7 || h.Facing != cube.FaceDown { + return false + } + + for sourceSlot, sourceStack := range h.inventory.Slots() { + if sourceStack.Empty() { + continue + } + + if c.fill(sourceStack, pos, tx) { + _ = h.inventory.SetItem(sourceSlot, sourceStack.Grow(-1)) + return true + } + } + + return false +} + +// ExtractItem ... +func (c Composter) ExtractItem(h Hopper, pos cube.Pos, tx *world.Tx) bool { + if c.Level == 8 { + _, err := h.inventory.AddItem(item.NewStack(item.BoneMeal{}, 1)) + if err != nil { + // The hopper is full. + return false + } + + c.Level = 0 + tx.SetBlock(pos, c, nil) + tx.PlaySound(pos.Vec3(), sound.ComposterEmpty{}) + return true + } + + return false +} + +// Model ... +func (c Composter) Model() world.BlockModel { + return model.Composter{Level: c.Level} +} + +// FuelInfo ... +func (c Composter) FuelInfo() item.FuelInfo { + return newFuelInfo(time.Second * 15) +} + +// FlammabilityInfo ... +func (c Composter) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(5, 20, true) +} + +// SideClosed ... +func (c Composter) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// BreakInfo ... +func (c Composter) BreakInfo() BreakInfo { + return newBreakInfo(0.6, alwaysHarvestable, axeEffective, oneOf(c)).withBreakHandler(func(pos cube.Pos, tx *world.Tx, u item.User) { + if c.Level == 8 { + dropItem(tx, item.NewStack(item.BoneMeal{}, 1), pos.Side(cube.FaceUp).Vec3Middle()) + } + }) +} + +// Activate ... +func (c Composter) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, ctx *item.UseContext) bool { + if c.Level >= 7 { + if c.Level == 8 { + c.Level = 0 + tx.SetBlock(pos, c, nil) + dropItem(tx, item.NewStack(item.BoneMeal{}, 1), pos.Side(cube.FaceUp).Vec3Middle()) + tx.PlaySound(pos.Vec3(), sound.ComposterEmpty{}) + } + return false + } + it, _ := u.HeldItems() + if c.fill(it, pos, tx) { + ctx.SubtractFromCount(1) + return true + } + return false +} + +// Fill fills up the composter. +func (c Composter) fill(it item.Stack, pos cube.Pos, tx *world.Tx) bool { + compostable, ok := it.Item().(item.Compostable) + if !ok { + return false + } + tx.AddParticle(pos.Vec3(), particle.BoneMeal{}) + if rand.Float64() > compostable.CompostChance() { + tx.PlaySound(pos.Vec3(), sound.ComposterFill{}) + return true + } + c.Level++ + tx.SetBlock(pos, c, nil) + tx.PlaySound(pos.Vec3(), sound.ComposterFillLayer{}) + if c.Level == 7 { + tx.ScheduleBlockUpdate(pos, c, time.Second) + } + + return true +} + +// ScheduledTick ... +func (c Composter) ScheduledTick(pos cube.Pos, tx *world.Tx, _ *rand.Rand) { + if c.Level == 7 { + c.Level = 8 + tx.SetBlock(pos, c, nil) + tx.PlaySound(pos.Vec3(), sound.ComposterReady{}) + } +} + +// EncodeItem ... +func (c Composter) EncodeItem() (name string, meta int16) { + return "minecraft:composter", 0 +} + +// EncodeBlock ... +func (c Composter) EncodeBlock() (string, map[string]any) { + return "minecraft:composter", map[string]any{"composter_fill_level": int32(c.Level)} +} + +// allComposters ... +func allComposters() (all []world.Block) { + for i := 0; i < 9; i++ { + all = append(all, Composter{Level: i}) + } + return +} diff --git a/server/block/concrete.go b/server/block/concrete.go new file mode 100644 index 0000000..706906b --- /dev/null +++ b/server/block/concrete.go @@ -0,0 +1,40 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// Concrete is a solid block which comes in the 16 regular dye colors, created by placing concrete powder +// adjacent to water. +type Concrete struct { + solid + bassDrum + + // Colour is the colour of the concrete block. + Colour item.Colour +} + +// BreakInfo ... +func (c Concrete) BreakInfo() BreakInfo { + return newBreakInfo(1.8, pickaxeHarvestable, pickaxeEffective, oneOf(c)) +} + +// EncodeItem ... +func (c Concrete) EncodeItem() (name string, meta int16) { + return "minecraft:" + c.Colour.String() + "_concrete", 0 +} + +// EncodeBlock ... +func (c Concrete) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:" + c.Colour.String() + "_concrete", nil +} + +// allConcrete returns concrete blocks with all possible colours. +func allConcrete() []world.Block { + b := make([]world.Block, 0, 16) + for _, c := range item.Colours() { + b = append(b, Concrete{Colour: c}) + } + return b +} diff --git a/server/block/concrete_powder.go b/server/block/concrete_powder.go new file mode 100644 index 0000000..4ce97bc --- /dev/null +++ b/server/block/concrete_powder.go @@ -0,0 +1,59 @@ +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" +) + +// ConcretePowder is a gravity affected block that comes in 16 different colours. When interacting with water, +// it becomes concrete. +type ConcretePowder struct { + gravityAffected + solid + snare + + // Colour is the colour of the concrete powder. + Colour item.Colour +} + +// Solidifies ... +func (c ConcretePowder) Solidifies(pos cube.Pos, tx *world.Tx) bool { + _, water := tx.Block(pos).(Water) + return water +} + +// NeighbourUpdateTick ... +func (c ConcretePowder) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + for i := cube.Face(0); i < 6; i++ { + if _, ok := tx.Block(pos.Side(i)).(Water); ok { + tx.SetBlock(pos, Concrete{Colour: c.Colour}, nil) + return + } + } + c.fall(c, pos, tx) +} + +// BreakInfo ... +func (c ConcretePowder) BreakInfo() BreakInfo { + return newBreakInfo(0.5, alwaysHarvestable, shovelEffective, oneOf(c)) +} + +// EncodeItem ... +func (c ConcretePowder) EncodeItem() (name string, meta int16) { + return "minecraft:" + c.Colour.String() + "_concrete_powder", 0 +} + +// EncodeBlock ... +func (c ConcretePowder) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:" + c.Colour.String() + "_concrete_powder", nil +} + +// allConcretePowder returns concrete powder with all possible colours. +func allConcretePowder() []world.Block { + b := make([]world.Block, 0, 16) + for _, c := range item.Colours() { + b = append(b, ConcretePowder{Colour: c}) + } + return b +} diff --git a/server/block/container.go b/server/block/container.go new file mode 100644 index 0000000..ff39257 --- /dev/null +++ b/server/block/container.go @@ -0,0 +1,30 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/inventory" + "github.com/df-mc/dragonfly/server/world" +) + +// ContainerViewer represents a viewer that is able to view a container and its inventory. +type ContainerViewer interface { + world.Viewer + // ViewSlotChange views a change of a single slot in the inventory, in which the item was changed to the + // new item passed. + ViewSlotChange(slot int, newItem item.Stack) +} + +// ContainerOpener represents an entity that is able to open a container. +type ContainerOpener interface { + // OpenBlockContainer opens a block container at the position passed. + OpenBlockContainer(pos cube.Pos, tx *world.Tx) +} + +// Container represents a container of items, typically a block such as a chest. Containers may have their +// inventory opened by viewers. +type Container interface { + AddViewer(v ContainerViewer, tx *world.Tx, pos cube.Pos) + RemoveViewer(v ContainerViewer, tx *world.Tx, pos cube.Pos) + Inventory(tx *world.Tx, pos cube.Pos) *inventory.Inventory +} diff --git a/server/block/copper.go b/server/block/copper.go new file mode 100644 index 0000000..c6671d1 --- /dev/null +++ b/server/block/copper.go @@ -0,0 +1,115 @@ +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/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" + "math/rand/v2" +) + +// Copper is a solid block commonly found in deserts and beaches underneath sand. +type Copper struct { + solid + bassDrum + + // Type is the type of copper of the block. + Type CopperType + // Oxidation is the level of oxidation of the copper block. + Oxidation OxidationType + // Waxed bool is whether the copper block has been waxed with honeycomb. + Waxed bool +} + +func (c Copper) Strip() (world.Block, world.Sound, bool) { + if c.Waxed { + c.Waxed = false + return c, sound.WaxRemoved{}, true + } else if ot, ok := c.Oxidation.Decrease(); ok { + c.Oxidation = ot + return c, sound.CopperScraped{}, true + } + return c, nil, false +} + +// BreakInfo ... +func (c Copper) BreakInfo() BreakInfo { + return newBreakInfo(3, func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierStone.HarvestLevel + }, pickaxeEffective, oneOf(c)).withBlastResistance(30) +} + +// Wax waxes the copper block to stop it from oxidising further. +func (c Copper) Wax(cube.Pos, mgl64.Vec3) (world.Block, bool) { + before := c.Waxed + c.Waxed = true + return c, !before +} + +func (c Copper) CanOxidate() bool { + return !c.Waxed +} + +func (c Copper) OxidationLevel() OxidationType { + return c.Oxidation +} + +func (c Copper) WithOxidationLevel(o OxidationType) Oxidisable { + c.Oxidation = o + return c +} + +func (c Copper) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + attemptOxidation(pos, tx, r, c) +} + +// EncodeItem ... +func (c Copper) EncodeItem() (name string, meta int16) { + if c.Type == NormalCopper() && c.Oxidation == UnoxidisedOxidation() && !c.Waxed { + return "minecraft:copper_block", 0 + } + name = "copper" + if c.Type != NormalCopper() { + name = c.Type.String() + "_" + name + } + if c.Oxidation != UnoxidisedOxidation() { + name = c.Oxidation.String() + "_" + name + } + if c.Waxed { + name = "waxed_" + name + } + return "minecraft:" + name, 0 +} + +// EncodeBlock ... +func (c Copper) EncodeBlock() (string, map[string]any) { + if c.Type == NormalCopper() && c.Oxidation == UnoxidisedOxidation() && !c.Waxed { + return "minecraft:copper_block", nil + } + name := "copper" + if c.Type != NormalCopper() { + name = c.Type.String() + "_" + name + } + if c.Oxidation != UnoxidisedOxidation() { + name = c.Oxidation.String() + "_" + name + } + if c.Waxed { + name = "waxed_" + name + } + return "minecraft:" + name, nil +} + +// allCopper returns a list of all copper block variants. +func allCopper() (c []world.Block) { + f := func(waxed bool) { + for _, t := range CopperTypes() { + for _, o := range OxidationTypes() { + c = append(c, Copper{Type: t, Oxidation: o, Waxed: waxed}) + } + } + } + f(true) + f(false) + return +} diff --git a/server/block/copper_bars.go b/server/block/copper_bars.go new file mode 100644 index 0000000..87539cf --- /dev/null +++ b/server/block/copper_bars.go @@ -0,0 +1,96 @@ +package block + +import ( + "math/rand/v2" + + "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" +) + +// CopperBars are blocks that serve a similar purpose to glass panes, but made of copper instead of glass. +type CopperBars struct { + transparent + thin + sourceWaterDisplacer + + // Oxidation is the level of oxidation of the copper bars. + Oxidation OxidationType + // Waxed bool is whether the copper bars has been waxed with honeycomb. + Waxed bool +} + +// BreakInfo ... +func (c CopperBars) BreakInfo() BreakInfo { + return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(c)).withBlastResistance(30) +} + +// SideClosed ... +func (c CopperBars) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// Wax waxes the copper bars to stop it from oxidising further. +func (c CopperBars) Wax(cube.Pos, mgl64.Vec3) (world.Block, bool) { + if c.Waxed { + return c, false + } + c.Waxed = true + return c, true +} + +// Strip ... +func (c CopperBars) Strip() (world.Block, world.Sound, bool) { + if c.Waxed { + c.Waxed = false + return c, sound.WaxRemoved{}, true + } else if ot, ok := c.Oxidation.Decrease(); ok { + c.Oxidation = ot + return c, sound.CopperScraped{}, true + } + return c, nil, false +} + +// CanOxidate ... +func (c CopperBars) CanOxidate() bool { + return !c.Waxed +} + +// OxidationLevel ... +func (c CopperBars) OxidationLevel() OxidationType { + return c.Oxidation +} + +// WithOxidationLevel ... +func (c CopperBars) WithOxidationLevel(o OxidationType) Oxidisable { + c.Oxidation = o + return c +} + +// RandomTick ... +func (c CopperBars) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + attemptOxidation(pos, tx, r, c) +} + +// EncodeItem ... +func (c CopperBars) EncodeItem() (name string, meta int16) { + return copperBlockName("copper_bars", c.Oxidation, c.Waxed), 0 +} + +// EncodeBlock ... +func (c CopperBars) EncodeBlock() (name string, properties map[string]any) { + return copperBlockName("copper_bars", c.Oxidation, c.Waxed), nil +} + +// allCopperBars ... +func allCopperBars() (bars []world.Block) { + f := func(waxed bool) { + for _, o := range OxidationTypes() { + bars = append(bars, CopperBars{Oxidation: o, Waxed: waxed}) + } + } + f(true) + f(false) + return +} diff --git a/server/block/copper_chain.go b/server/block/copper_chain.go new file mode 100644 index 0000000..5f553ce --- /dev/null +++ b/server/block/copper_chain.go @@ -0,0 +1,118 @@ +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/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" +) + +// CopperChain is a metallic decoration block. +type CopperChain struct { + transparent + sourceWaterDisplacer + + // Axis is the axis which the chain faces. + Axis cube.Axis + // Oxidation is the level of oxidation of the copper chain. + Oxidation OxidationType + // Waxed bool is whether the copper chain has been waxed with honeycomb. + Waxed bool +} + +// SideClosed ... +func (CopperChain) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// UseOnBlock ... +func (c CopperChain) 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, c) + if !used { + return + } + c.Axis = face.Axis() + + place(tx, pos, c, user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (c CopperChain) BreakInfo() BreakInfo { + return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(c)).withBlastResistance(30) +} + +// Wax waxes the copper chain to stop it from oxidising further. +func (c CopperChain) Wax(cube.Pos, mgl64.Vec3) (world.Block, bool) { + if c.Waxed { + return c, false + } + c.Waxed = true + return c, true +} + +// Strip ... +func (c CopperChain) Strip() (world.Block, world.Sound, bool) { + if c.Waxed { + c.Waxed = false + return c, sound.WaxRemoved{}, true + } else if ot, ok := c.Oxidation.Decrease(); ok { + c.Oxidation = ot + return c, sound.CopperScraped{}, true + } + return c, nil, false +} + +// CanOxidate ... +func (c CopperChain) CanOxidate() bool { + return !c.Waxed +} + +// OxidationLevel ... +func (c CopperChain) OxidationLevel() OxidationType { + return c.Oxidation +} + +// WithOxidationLevel ... +func (c CopperChain) WithOxidationLevel(o OxidationType) Oxidisable { + c.Oxidation = o + return c +} + +// RandomTick ... +func (c CopperChain) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + attemptOxidation(pos, tx, r, c) +} + +// EncodeItem ... +func (c CopperChain) EncodeItem() (name string, meta int16) { + return copperBlockName("copper_chain", c.Oxidation, c.Waxed), 0 +} + +// EncodeBlock ... +func (c CopperChain) EncodeBlock() (name string, properties map[string]any) { + return copperBlockName("copper_chain", c.Oxidation, c.Waxed), map[string]any{"pillar_axis": c.Axis.String()} +} + +// Model ... +func (c CopperChain) Model() world.BlockModel { + return model.Chain{Axis: c.Axis} +} + +// allCopperChains ... +func allCopperChains() (chains []world.Block) { + f := func(waxed bool) { + for _, o := range OxidationTypes() { + for _, axis := range cube.Axes() { + chains = append(chains, CopperChain{Axis: axis, Oxidation: o, Waxed: waxed}) + } + } + } + f(true) + f(false) + return +} diff --git a/server/block/copper_door.go b/server/block/copper_door.go new file mode 100644 index 0000000..c73f3f3 --- /dev/null +++ b/server/block/copper_door.go @@ -0,0 +1,198 @@ +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/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" +) + +// CopperDoor is a block that can be used as an openable 1x2 barrier. +type CopperDoor struct { + transparent + bass + sourceWaterDisplacer + + // Oxidation is the level of oxidation of the copper door. + Oxidation OxidationType + // Waxed bool is whether the copper door has been waxed with honeycomb. + Waxed bool + // Facing is the direction that the door opens towards. When closed, the door sits on the side of its + // block on the opposite direction. + Facing cube.Direction + // Open is whether the door is open. + Open bool + // Top is whether the block is the top or bottom half of a door. + Top bool + // Right is whether the door hinge is on the right side. + Right bool +} + +func (d CopperDoor) Strip() (world.Block, world.Sound, bool) { + if d.Waxed { + d.Waxed = false + return d, sound.WaxRemoved{}, true + } else if ot, ok := d.Oxidation.Decrease(); ok { + d.Oxidation = ot + return d, sound.CopperScraped{}, true + } + return d, nil, false +} + +// Model ... +func (d CopperDoor) Model() world.BlockModel { + return model.Door{Facing: d.Facing, Open: d.Open, Right: d.Right} +} + +// Wax waxes the copper door to stop it from oxidising further. +func (d CopperDoor) Wax(cube.Pos, mgl64.Vec3) (world.Block, bool) { + if d.Waxed { + return d, false + } + d.Waxed = true + return d, true +} + +func (d CopperDoor) CanOxidate() bool { + return !d.Waxed +} + +func (d CopperDoor) OxidationLevel() OxidationType { + return d.Oxidation +} + +func (d CopperDoor) WithOxidationLevel(o OxidationType) Oxidisable { + d.Oxidation = o + return d +} + +// NeighbourUpdateTick ... +func (d CopperDoor) NeighbourUpdateTick(pos, changedNeighbour cube.Pos, tx *world.Tx) { + if pos == changedNeighbour { + return + } + if d.Top { + if b, ok := tx.Block(pos.Side(cube.FaceDown)).(CopperDoor); !ok { + breakBlockNoDrops(d, pos, tx) + } else if d.Oxidation != b.Oxidation || d.Waxed != b.Waxed { + d.Oxidation = b.Oxidation + d.Waxed = b.Waxed + tx.SetBlock(pos, d, nil) + } + } else if solid := tx.Block(pos.Side(cube.FaceDown)).Model().FaceSolid(pos.Side(cube.FaceDown), cube.FaceUp, tx); !solid { + // CopperDoor is pickaxeHarvestable, so don't use breakBlock() here. + breakBlockNoDrops(d, pos, tx) + dropItem(tx, item.NewStack(d, 1), pos.Vec3Centre()) + } else if b, ok := tx.Block(pos.Side(cube.FaceUp)).(CopperDoor); !ok { + breakBlockNoDrops(d, pos, tx) + } else if d.Oxidation != b.Oxidation || d.Waxed != b.Waxed { + d.Oxidation = b.Oxidation + d.Waxed = b.Waxed + tx.SetBlock(pos, d, nil) + } +} + +// UseOnBlock handles the directional placing of doors +func (d CopperDoor) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + if face != cube.FaceUp { + // Doors can only be placed when clicking the top face. + return false + } + below := pos + pos = pos.Side(cube.FaceUp) + if !replaceableWith(tx, pos, d) || !replaceableWith(tx, pos.Side(cube.FaceUp), d) { + return false + } + if !tx.Block(below).Model().FaceSolid(below, cube.FaceUp, tx) { + return false + } + d.Facing = user.Rotation().Direction() + left := tx.Block(pos.Side(d.Facing.RotateLeft().Face())) + right := tx.Block(pos.Side(d.Facing.RotateRight().Face())) + if _, ok := left.Model().(model.Door); ok { + d.Right = true + } + // The side the door hinge is on can be affected by the blocks to the left and right of the door. In particular, + // opaque blocks on the right side of the door with transparent blocks on the left side result in a right sided + // door hinge. + if diffuser, ok := right.(LightDiffuser); !ok || diffuser.LightDiffusionLevel() != 0 { + if diffuser, ok := left.(LightDiffuser); ok && diffuser.LightDiffusionLevel() == 0 { + d.Right = true + } + } + + ctx.IgnoreBBox = true + place(tx, pos, d, user, ctx) + place(tx, pos.Side(cube.FaceUp), CopperDoor{Oxidation: d.Oxidation, Waxed: d.Waxed, Facing: d.Facing, Top: true, Right: d.Right}, user, ctx) + ctx.CountSub = 1 + return placed(ctx) +} + +func (d CopperDoor) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, _ item.User, _ *item.UseContext) bool { + d.Open = !d.Open + tx.SetBlock(pos, d, nil) + + otherPos := pos.Side(cube.Face(boolByte(!d.Top))) + other := tx.Block(otherPos) + if door, ok := other.(CopperDoor); ok { + door.Open = d.Open + tx.SetBlock(otherPos, door, nil) + } + if d.Open { + tx.PlaySound(pos.Vec3Centre(), sound.DoorOpen{Block: d}) + return true + } + tx.PlaySound(pos.Vec3Centre(), sound.DoorClose{Block: d}) + return true +} + +func (d CopperDoor) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + attemptOxidation(pos, tx, r, d) +} + +// BreakInfo ... +func (d CopperDoor) BreakInfo() BreakInfo { + return newBreakInfo(3, func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierStone.HarvestLevel + }, pickaxeEffective, oneOf(d)) +} + +// SideClosed ... +func (d CopperDoor) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// EncodeItem ... +func (d CopperDoor) EncodeItem() (name string, meta int16) { + return copperBlockName("copper_door", d.Oxidation, d.Waxed), 0 +} + +// EncodeBlock ... +func (d CopperDoor) EncodeBlock() (name string, properties map[string]any) { + return copperBlockName("copper_door", d.Oxidation, d.Waxed), map[string]any{"minecraft:cardinal_direction": d.Facing.RotateRight().String(), "door_hinge_bit": d.Right, "open_bit": d.Open, "upper_block_bit": d.Top} +} + +// allCopperDoors returns a list of all copper door types +func allCopperDoors() (doors []world.Block) { + f := func(waxed bool) { + for _, o := range OxidationTypes() { + for i := cube.Direction(0); i <= 3; i++ { + doors = append(doors, CopperDoor{Oxidation: o, Waxed: waxed, Facing: i, Open: false, Top: false, Right: false}) + doors = append(doors, CopperDoor{Oxidation: o, Waxed: waxed, Facing: i, Open: false, Top: true, Right: false}) + doors = append(doors, CopperDoor{Oxidation: o, Waxed: waxed, Facing: i, Open: true, Top: true, Right: false}) + doors = append(doors, CopperDoor{Oxidation: o, Waxed: waxed, Facing: i, Open: true, Top: false, Right: false}) + doors = append(doors, CopperDoor{Oxidation: o, Waxed: waxed, Facing: i, Open: false, Top: false, Right: true}) + doors = append(doors, CopperDoor{Oxidation: o, Waxed: waxed, Facing: i, Open: false, Top: true, Right: true}) + doors = append(doors, CopperDoor{Oxidation: o, Waxed: waxed, Facing: i, Open: true, Top: true, Right: true}) + doors = append(doors, CopperDoor{Oxidation: o, Waxed: waxed, Facing: i, Open: true, Top: false, Right: true}) + } + } + } + f(false) + f(true) + return +} diff --git a/server/block/copper_golem_pose.go b/server/block/copper_golem_pose.go new file mode 100644 index 0000000..1015713 --- /dev/null +++ b/server/block/copper_golem_pose.go @@ -0,0 +1,53 @@ +package block + +// CopperGolemPose represents a pose of a copper golem statue. +type CopperGolemPose struct { + pose +} + +type pose uint8 + +// StandingPose is the standing pose. +func StandingPose() CopperGolemPose { + return CopperGolemPose{0} +} + +// SittingPose is the sitting pose. +func SittingPose() CopperGolemPose { + return CopperGolemPose{1} +} + +// RunningPose is the running pose. +func RunningPose() CopperGolemPose { + return CopperGolemPose{2} +} + +// StarPose is the head button pressing pose. +func StarPose() CopperGolemPose { + return CopperGolemPose{3} +} + +// Uint8 returns the pose as a uint8. +func (p pose) Uint8() uint8 { + return uint8(p) +} + +// Name returns the pose as a string. +func (p pose) Name() string { + switch p { + case 0: + return "Standing" + case 1: + return "Sitting" + case 2: + return "Running" + case 3: + return "Star" + } + panic("unknown copper golem pose") +} + +// CopperGolemPoses returns all copper golem poses. +func CopperGolemPoses() []CopperGolemPose { + return []CopperGolemPose{StandingPose(), SittingPose(), RunningPose(), StarPose()} +} diff --git a/server/block/copper_golem_statue.go b/server/block/copper_golem_statue.go new file mode 100644 index 0000000..7198a9b --- /dev/null +++ b/server/block/copper_golem_statue.go @@ -0,0 +1,144 @@ +package block + +import ( + "math/rand/v2" + + "github.com/df-mc/dragonfly/server/block/cube" + "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" +) + +// CopperGolemStatue is the result of a copper golem fully oxidizing and petrifying into a block. +// It can be posed in four different ways. +type CopperGolemStatue struct { + transparent + sourceWaterDisplacer + solid + + // Facing is the direction the copper golem statue is facing. + Facing cube.Direction + // Pose is the pose of the copper golem statue. + Pose CopperGolemPose + // Oxidation is the level of oxidation of the copper lantern. + Oxidation OxidationType + // Waxed bool is whether the copper lantern has been waxed with honeycomb. + Waxed bool +} + +// BreakInfo ... +func (c CopperGolemStatue) BreakInfo() BreakInfo { + return newBreakInfo(3, alwaysHarvestable, pickaxeEffective, oneOf(c)).withBlastResistance(30) +} + +// Activate ... +func (c CopperGolemStatue) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + held, _ := u.HeldItems() + if !held.Empty() { + // copper golems can't be activated while holding an item. + return false + } + poses := CopperGolemPoses() + nextIndex := int(c.Pose.Uint8()) + 1 + if nextIndex >= len(poses) { + nextIndex = 0 + } + c.Pose = poses[nextIndex] + tx.SetBlock(pos, c, nil) + return true +} + +// UseOnBlock ... +func (c CopperGolemStatue) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, c) + if !used { + return + } + + c.Facing = user.Rotation().Direction().Opposite() + + place(tx, pos, c, user, ctx) + return placed(ctx) +} + +// Wax waxes the copper lantern to stop it from oxidising further. +func (c CopperGolemStatue) Wax(cube.Pos, mgl64.Vec3) (world.Block, bool) { + if c.Waxed { + return c, false + } + c.Waxed = true + return c, true +} + +// Strip ... +func (c CopperGolemStatue) Strip() (world.Block, world.Sound, bool) { + if c.Waxed { + c.Waxed = false + return c, sound.WaxRemoved{}, true + } else if ot, ok := c.Oxidation.Decrease(); ok { + c.Oxidation = ot + return c, sound.CopperScraped{}, true + } + return c, nil, false +} + +// CanOxidate ... +func (c CopperGolemStatue) CanOxidate() bool { + return !c.Waxed +} + +// OxidationLevel ... +func (c CopperGolemStatue) OxidationLevel() OxidationType { + return c.Oxidation +} + +// WithOxidationLevel ... +func (c CopperGolemStatue) WithOxidationLevel(o OxidationType) Oxidisable { + c.Oxidation = o + return c +} + +// RandomTick ... +func (c CopperGolemStatue) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + attemptOxidation(pos, tx, r, c) +} + +// DecodeNBT ... +func (c CopperGolemStatue) DecodeNBT(data map[string]any) any { + c.Pose = CopperGolemPose{pose(nbtconv.Int32(data, "Pose"))} + return c +} + +// EncodeNBT ... +func (c CopperGolemStatue) EncodeNBT() map[string]any { + return map[string]any{ + "Pose": int32(c.Pose.Uint8()), + "id": "CopperGolemStatue", + } +} + +// EncodeItem ... +func (c CopperGolemStatue) EncodeItem() (name string, meta int16) { + return copperBlockName("copper_golem_statue", c.Oxidation, c.Waxed), 0 +} + +// EncodeBlock ... +func (c CopperGolemStatue) EncodeBlock() (string, map[string]any) { + return copperBlockName("copper_golem_statue", c.Oxidation, c.Waxed), map[string]any{"minecraft:cardinal_direction": c.Facing.String()} +} + +// allCopperGolemStatues ... +func allCopperGolemStatues() (golems []world.Block) { + f := func(waxed bool) { + for _, o := range OxidationTypes() { + for _, direction := range cube.Directions() { + golems = append(golems, CopperGolemStatue{Facing: direction, Oxidation: o, Waxed: waxed}) + } + } + } + f(true) + f(false) + return +} diff --git a/server/block/copper_grate.go b/server/block/copper_grate.go new file mode 100644 index 0000000..cfdaea5 --- /dev/null +++ b/server/block/copper_grate.go @@ -0,0 +1,90 @@ +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/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" +) + +// CopperGrate is a solid block commonly found in deserts and beaches underneath sand. +type CopperGrate struct { + sourceWaterDisplacer + solid + transparent + bassDrum + + // Oxidation is the level of oxidation of the copper grate. + Oxidation OxidationType + // Waxed bool is whether the copper grate has been waxed with honeycomb. + Waxed bool +} + +// BreakInfo ... +func (c CopperGrate) BreakInfo() BreakInfo { + return newBreakInfo(3, func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierStone.HarvestLevel + }, pickaxeEffective, oneOf(c)).withBlastResistance(30) +} + +// Wax waxes the copper grate to stop it from oxidising further. +func (c CopperGrate) Wax(cube.Pos, mgl64.Vec3) (world.Block, bool) { + if c.Waxed { + return c, false + } + c.Waxed = true + return c, true +} + +func (c CopperGrate) Strip() (world.Block, world.Sound, bool) { + if c.Waxed { + c.Waxed = false + return c, sound.WaxRemoved{}, true + } else if ot, ok := c.Oxidation.Decrease(); ok { + c.Oxidation = ot + return c, sound.CopperScraped{}, true + } + return c, nil, false +} + +func (c CopperGrate) CanOxidate() bool { + return !c.Waxed +} + +func (c CopperGrate) OxidationLevel() OxidationType { + return c.Oxidation +} + +func (c CopperGrate) WithOxidationLevel(o OxidationType) Oxidisable { + c.Oxidation = o + return c +} + +func (c CopperGrate) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + attemptOxidation(pos, tx, r, c) +} + +// EncodeItem ... +func (c CopperGrate) EncodeItem() (name string, meta int16) { + return copperBlockName("copper_grate", c.Oxidation, c.Waxed), 0 +} + +// EncodeBlock ... +func (c CopperGrate) EncodeBlock() (string, map[string]any) { + return copperBlockName("copper_grate", c.Oxidation, c.Waxed), nil +} + +// allCopperGrates returns a list of all copper grate variants. +func allCopperGrates() (c []world.Block) { + f := func(waxed bool) { + for _, o := range OxidationTypes() { + c = append(c, CopperGrate{Oxidation: o, Waxed: waxed}) + } + } + f(true) + f(false) + return +} diff --git a/server/block/copper_lantern.go b/server/block/copper_lantern.go new file mode 100644 index 0000000..3b45587 --- /dev/null +++ b/server/block/copper_lantern.go @@ -0,0 +1,149 @@ +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/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" +) + +// CopperLantern is a light emitting block. +type CopperLantern struct { + transparent + sourceWaterDisplacer + + // Hanging determines if a lantern is hanging off a block. + Hanging bool + // Oxidation is the level of oxidation of the copper lantern. + Oxidation OxidationType + // Waxed bool is whether the copper lantern has been waxed with honeycomb. + Waxed bool +} + +// Model ... +func (c CopperLantern) Model() world.BlockModel { + return model.Lantern{Hanging: c.Hanging} +} + +// NeighbourUpdateTick ... +func (c CopperLantern) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if c.Hanging { + up := pos.Side(cube.FaceUp) + if _, ok := tx.Block(up).(CopperChain); !ok && !tx.Block(up).Model().FaceSolid(up, cube.FaceDown, tx) { + breakBlock(c, pos, tx) + } + } else { + down := pos.Side(cube.FaceDown) + if !tx.Block(down).Model().FaceSolid(down, cube.FaceUp, tx) { + breakBlock(c, pos, tx) + } + } +} + +// LightEmissionLevel ... +func (CopperLantern) LightEmissionLevel() uint8 { + return 15 +} + +// UseOnBlock ... +func (c CopperLantern) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, face, used := firstReplaceable(tx, pos, face, c) + if !used { + return false + } + if face == cube.FaceDown { + upPos := pos.Side(cube.FaceUp) + if _, ok := tx.Block(upPos).(CopperChain); !ok && !tx.Block(upPos).Model().FaceSolid(upPos, cube.FaceDown, tx) { + face = cube.FaceUp + } + } + if face != cube.FaceDown { + downPos := pos.Side(cube.FaceDown) + if !tx.Block(downPos).Model().FaceSolid(downPos, cube.FaceUp, tx) { + return false + } + } + c.Hanging = face == cube.FaceDown + + place(tx, pos, c, user, ctx) + return placed(ctx) +} + +// SideClosed ... +func (CopperLantern) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// BreakInfo ... +func (c CopperLantern) BreakInfo() BreakInfo { + return newBreakInfo(3.5, pickaxeHarvestable, pickaxeEffective, oneOf(c)) +} + +// Wax waxes the copper lantern to stop it from oxidising further. +func (c CopperLantern) Wax(cube.Pos, mgl64.Vec3) (world.Block, bool) { + if c.Waxed { + return c, false + } + c.Waxed = true + return c, true +} + +// Strip ... +func (c CopperLantern) Strip() (world.Block, world.Sound, bool) { + if c.Waxed { + c.Waxed = false + return c, sound.WaxRemoved{}, true + } else if ot, ok := c.Oxidation.Decrease(); ok { + c.Oxidation = ot + return c, sound.CopperScraped{}, true + } + return c, nil, false +} + +// CanOxidate ... +func (c CopperLantern) CanOxidate() bool { + return !c.Waxed +} + +// OxidationLevel ... +func (c CopperLantern) OxidationLevel() OxidationType { + return c.Oxidation +} + +// WithOxidationLevel ... +func (c CopperLantern) WithOxidationLevel(o OxidationType) Oxidisable { + c.Oxidation = o + return c +} + +// RandomTick ... +func (c CopperLantern) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + attemptOxidation(pos, tx, r, c) +} + +// EncodeItem ... +func (c CopperLantern) EncodeItem() (name string, meta int16) { + return copperBlockName("copper_lantern", c.Oxidation, c.Waxed), 0 +} + +// EncodeBlock ... +func (c CopperLantern) EncodeBlock() (name string, properties map[string]any) { + return copperBlockName("copper_lantern", c.Oxidation, c.Waxed), map[string]any{"hanging": c.Hanging} +} + +// allCopperLanterns ... +func allCopperLanterns() (lanterns []world.Block) { + f := func(waxed bool) { + for _, o := range OxidationTypes() { + lanterns = append(lanterns, CopperLantern{Hanging: false, Oxidation: o, Waxed: waxed}) + lanterns = append(lanterns, CopperLantern{Hanging: true, Oxidation: o, Waxed: waxed}) + } + } + f(true) + f(false) + return +} diff --git a/server/block/copper_ore.go b/server/block/copper_ore.go new file mode 100644 index 0000000..e851ad1 --- /dev/null +++ b/server/block/copper_ore.go @@ -0,0 +1,36 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" +) + +// CopperOre is a rare mineral block found underground. +type CopperOre struct { + solid + bassDrum + + // Type is the type of copper ore. + Type OreType +} + +// BreakInfo ... +func (c CopperOre) BreakInfo() BreakInfo { + return newBreakInfo(c.Type.Hardness(), func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierStone.HarvestLevel + }, pickaxeEffective, multiOreDrops(item.RawCopper{}, c, 2, 5)).withBlastResistance(15) +} + +// SmeltInfo ... +func (CopperOre) SmeltInfo() item.SmeltInfo { + return newOreSmeltInfo(item.NewStack(item.CopperIngot{}, 1), 0.7) +} + +// EncodeItem ... +func (c CopperOre) EncodeItem() (name string, meta int16) { + return "minecraft:" + c.Type.Prefix() + "copper_ore", 0 +} + +// EncodeBlock ... +func (c CopperOre) EncodeBlock() (string, map[string]any) { + return "minecraft:" + c.Type.Prefix() + "copper_ore", nil +} diff --git a/server/block/copper_torch.go b/server/block/copper_torch.go new file mode 100644 index 0000000..68a4637 --- /dev/null +++ b/server/block/copper_torch.go @@ -0,0 +1,99 @@ +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" +) + +// CopperTorch are non-solid blocks that emit light. +type CopperTorch struct { + transparent + empty + + // Facing is the direction from the torch to the block. + Facing cube.Face +} + +// BreakInfo ... +func (t CopperTorch) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(t)) +} + +// LightEmissionLevel ... +func (t CopperTorch) LightEmissionLevel() uint8 { + return 14 +} + +// UseOnBlock ... +func (t CopperTorch) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, face, used := firstReplaceable(tx, pos, face, t) + if !used { + return false + } + if face == cube.FaceDown { + return false + } + if !tx.Block(pos.Side(face.Opposite())).Model().FaceSolid(pos.Side(face.Opposite()), face, tx) { + found := false + for _, i := range []cube.Face{cube.FaceSouth, cube.FaceWest, cube.FaceNorth, cube.FaceEast, cube.FaceDown} { + if tx.Block(pos.Side(i)).Model().FaceSolid(pos.Side(i), i.Opposite(), tx) { + found = true + face = i.Opposite() + break + } + } + if !found { + return false + } + } + t.Facing = face.Opposite() + + place(tx, pos, t, user, ctx) + return placed(ctx) +} + +// NeighbourUpdateTick ... +func (t CopperTorch) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if !tx.Block(pos.Side(t.Facing)).Model().FaceSolid(pos.Side(t.Facing), t.Facing.Opposite(), tx) { + breakBlock(t, pos, tx) + } +} + +// HasLiquidDrops ... +func (t CopperTorch) HasLiquidDrops() bool { + return true +} + +// EncodeItem ... +func (t CopperTorch) EncodeItem() (name string, meta int16) { + return "minecraft:copper_torch", 0 +} + +// EncodeBlock ... +func (t CopperTorch) EncodeBlock() (name string, properties map[string]any) { + var face string + switch t.Facing { + case cube.FaceDown: + face = "top" + case unknownFace: + face = "unknown" + default: + face = t.Facing.String() + } + + return "minecraft:copper_torch", map[string]any{"torch_facing_direction": face} +} + +// allTorches ... +func allCopperTorches() (torch []world.Block) { + for _, face := range cube.Faces() { + if face == cube.FaceUp { + face = unknownFace + } + + torch = append(torch, CopperTorch{Facing: face}) + } + return +} diff --git a/server/block/copper_trapdoor.go b/server/block/copper_trapdoor.go new file mode 100644 index 0000000..919e1a0 --- /dev/null +++ b/server/block/copper_trapdoor.go @@ -0,0 +1,137 @@ +package block + +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/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" +) + +// CopperTrapdoor is a block that can be used as an openable 1x1 barrier. +type CopperTrapdoor struct { + transparent + bass + sourceWaterDisplacer + + // Oxidation is the level of oxidation of the copper trapdoor. + Oxidation OxidationType + // Waxed bool is whether the copper trapdoor has been waxed with honeycomb. + Waxed bool + // Facing is the direction the trapdoor is facing. + Facing cube.Direction + // Open is whether the trapdoor is open. + Open bool + // Top is whether the trapdoor occupies the top or bottom part of a block. + Top bool +} + +// Model ... +func (t CopperTrapdoor) Model() world.BlockModel { + return model.Trapdoor{Facing: t.Facing, Top: t.Top, Open: t.Open} +} + +// UseOnBlock handles the directional placing of trapdoors and makes sure they are properly placed upside down +// when needed. +func (t CopperTrapdoor) UseOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, face, used := firstReplaceable(tx, pos, face, t) + if !used { + return false + } + t.Facing = user.Rotation().Direction().Opposite() + t.Top = (clickPos.Y() > 0.5 && face != cube.FaceUp) || face == cube.FaceDown + + place(tx, pos, t, user, ctx) + return placed(ctx) +} + +// Wax waxes the copper trapdoor to stop it from oxidising further. +func (t CopperTrapdoor) Wax(cube.Pos, mgl64.Vec3) (world.Block, bool) { + if t.Waxed { + return t, false + } + t.Waxed = true + return t, true +} + +func (t CopperTrapdoor) Strip() (world.Block, world.Sound, bool) { + if t.Waxed { + t.Waxed = false + return t, sound.WaxRemoved{}, true + } else if ot, ok := t.Oxidation.Decrease(); ok { + t.Oxidation = ot + return t, sound.CopperScraped{}, true + } + return t, nil, false +} + +func (t CopperTrapdoor) CanOxidate() bool { + return !t.Waxed +} + +func (t CopperTrapdoor) OxidationLevel() OxidationType { + return t.Oxidation +} + +func (t CopperTrapdoor) WithOxidationLevel(o OxidationType) Oxidisable { + t.Oxidation = o + return t +} + +func (t CopperTrapdoor) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, _ item.User, _ *item.UseContext) bool { + t.Open = !t.Open + tx.SetBlock(pos, t, nil) + if t.Open { + tx.PlaySound(pos.Vec3Centre(), sound.TrapdoorOpen{Block: t}) + return true + } + tx.PlaySound(pos.Vec3Centre(), sound.TrapdoorClose{Block: t}) + return true +} + +func (t CopperTrapdoor) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + attemptOxidation(pos, tx, r, t) +} + +// BreakInfo ... +func (t CopperTrapdoor) BreakInfo() BreakInfo { + return newBreakInfo(3, func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierStone.HarvestLevel + }, pickaxeEffective, oneOf(t)) +} + +// SideClosed ... +func (t CopperTrapdoor) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// EncodeItem ... +func (t CopperTrapdoor) EncodeItem() (name string, meta int16) { + return copperBlockName("copper_trapdoor", t.Oxidation, t.Waxed), 0 +} + +// EncodeBlock ... +func (t CopperTrapdoor) EncodeBlock() (name string, properties map[string]any) { + return copperBlockName("copper_trapdoor", t.Oxidation, t.Waxed), map[string]any{"direction": int32(math.Abs(float64(t.Facing) - 3)), "open_bit": t.Open, "upside_down_bit": t.Top} +} + +// allCopperTrapdoors returns a list of all copper trapdoor types +func allCopperTrapdoors() (trapdoors []world.Block) { + f := func(waxed bool) { + for _, o := range OxidationTypes() { + for i := cube.Direction(0); i <= 3; i++ { + trapdoors = append(trapdoors, CopperTrapdoor{Oxidation: o, Waxed: waxed, Facing: i, Open: false, Top: false}) + trapdoors = append(trapdoors, CopperTrapdoor{Oxidation: o, Waxed: waxed, Facing: i, Open: false, Top: true}) + trapdoors = append(trapdoors, CopperTrapdoor{Oxidation: o, Waxed: waxed, Facing: i, Open: true, Top: true}) + trapdoors = append(trapdoors, CopperTrapdoor{Oxidation: o, Waxed: waxed, Facing: i, Open: true, Top: false}) + } + } + } + f(false) + f(true) + return +} diff --git a/server/block/copper_type.go b/server/block/copper_type.go new file mode 100644 index 0000000..aeac557 --- /dev/null +++ b/server/block/copper_type.go @@ -0,0 +1,59 @@ +package block + +// CopperType represents a type of copper. +type CopperType struct { + copper +} + +type copper uint8 + +// NormalCopper is the normal variant of copper. +func NormalCopper() CopperType { + return CopperType{0} +} + +// CutCopper is the cut variant of copper. +func CutCopper() CopperType { + return CopperType{1} +} + +// ChiseledCopper is the chiseled variant of copper. +func ChiseledCopper() CopperType { + return CopperType{2} +} + +// Uint8 returns the copper as a uint8. +func (s copper) Uint8() uint8 { + return uint8(s) +} + +// Name ... +func (s copper) Name() string { + switch s { + case 0: + return "Copper" + case 1: + return "Cut Copper" + case 2: + return "Chiseled Copper" + } + panic("unknown copper type") +} + +// String ... +func (s copper) String() string { + switch s { + case 0: + return "default" + case 1: + return "cut" + case 2: + return "chiseled" + } + panic("unknown copper type") +} + +// CopperTypes ... +func CopperTypes() []CopperType { + return []CopperType{NormalCopper(), CutCopper(), ChiseledCopper()} +} diff --git a/server/block/coral.go b/server/block/coral.go new file mode 100644 index 0000000..5b193fd --- /dev/null +++ b/server/block/coral.go @@ -0,0 +1,114 @@ +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" + "math/rand/v2" + "time" +) + +// Coral is a non-solid block that comes in 5 variants. +type Coral struct { + empty + transparent + bassDrum + sourceWaterDisplacer + + // Type is the type of coral of the block. + Type CoralType + // Dead is whether the coral is dead. + Dead bool +} + +// UseOnBlock ... +func (c Coral) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, c) + if !used { + return false + } + if !tx.Block(pos.Side(cube.FaceDown)).Model().FaceSolid(pos.Side(cube.FaceDown), cube.FaceUp, tx) { + return false + } + if liquid, ok := tx.Liquid(pos); ok { + if water, ok := liquid.(Water); ok { + if water.Depth != 8 { + return false + } + } + } + + place(tx, pos, c, user, ctx) + return placed(ctx) +} + +// HasLiquidDrops ... +func (c Coral) HasLiquidDrops() bool { + return false +} + +// SideClosed ... +func (c Coral) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// NeighbourUpdateTick ... +func (c Coral) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if !tx.Block(pos.Side(cube.FaceDown)).Model().FaceSolid(pos.Side(cube.FaceDown), cube.FaceUp, tx) { + breakBlock(c, pos, tx) + return + } else if c.Dead { + return + } + tx.ScheduleBlockUpdate(pos, c, time.Second*5/2) +} + +// ScheduledTick ... +func (c Coral) ScheduledTick(pos cube.Pos, tx *world.Tx, _ *rand.Rand) { + adjacentWater := false + pos.Neighbours(func(neighbour cube.Pos) { + if liquid, ok := tx.Liquid(neighbour); ok { + if _, ok := liquid.(Water); ok { + adjacentWater = true + } + } + }, tx.Range()) + if !adjacentWater { + c.Dead = true + tx.SetBlock(pos, c, nil) + } +} + +// BreakInfo ... +func (c Coral) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, silkTouchOnlyDrop(c)) +} + +// EncodeBlock ... +func (c Coral) EncodeBlock() (name string, properties map[string]any) { + if c.Dead { + return "minecraft:dead_" + c.Type.String() + "_coral", nil + } + return "minecraft:" + c.Type.String() + "_coral", nil +} + +// EncodeItem ... +func (c Coral) EncodeItem() (name string, meta int16) { + if c.Dead { + return "minecraft:dead_" + c.Type.String() + "_coral", 0 + } + return "minecraft:" + c.Type.String() + "_coral", 0 +} + +// allCoral returns a list of all coral block variants +func allCoral() (c []world.Block) { + f := func(dead bool) { + for _, t := range CoralTypes() { + c = append(c, Coral{Type: t, Dead: dead}) + } + } + f(true) + f(false) + return +} diff --git a/server/block/coral_block.go b/server/block/coral_block.go new file mode 100644 index 0000000..5de221a --- /dev/null +++ b/server/block/coral_block.go @@ -0,0 +1,76 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "math/rand/v2" + "time" +) + +// CoralBlock is a solid block that comes in 5 variants. +type CoralBlock struct { + solid + bassDrum + + // Type is the type of coral of the block. + Type CoralType + // Dead is whether the coral block is dead. + Dead bool +} + +// NeighbourUpdateTick ... +func (c CoralBlock) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if c.Dead { + return + } + tx.ScheduleBlockUpdate(pos, c, time.Second*5/2) +} + +// ScheduledTick ... +func (c CoralBlock) ScheduledTick(pos cube.Pos, tx *world.Tx, _ *rand.Rand) { + adjacentWater := false + pos.Neighbours(func(neighbour cube.Pos) { + if liquid, ok := tx.Liquid(neighbour); ok { + if _, ok := liquid.(Water); ok { + adjacentWater = true + } + } + }, tx.Range()) + if !adjacentWater { + c.Dead = true + tx.SetBlock(pos, c, nil) + } +} + +// BreakInfo ... +func (c CoralBlock) BreakInfo() BreakInfo { + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, silkTouchOneOf(CoralBlock{Type: c.Type, Dead: true}, c)).withBlastResistance(30) +} + +// EncodeBlock ... +func (c CoralBlock) EncodeBlock() (name string, properties map[string]any) { + if c.Dead { + return "minecraft:dead_" + c.Type.String() + "_coral_block", nil + } + return "minecraft:" + c.Type.String() + "_coral_block", nil +} + +// EncodeItem ... +func (c CoralBlock) EncodeItem() (name string, meta int16) { + if c.Dead { + return "minecraft:dead_" + c.Type.String() + "_coral_block", 0 + } + return "minecraft:" + c.Type.String() + "_coral_block", 0 +} + +// allCoralBlocks returns a list of all coral block variants +func allCoralBlocks() (c []world.Block) { + f := func(dead bool) { + for _, t := range CoralTypes() { + c = append(c, CoralBlock{Type: t, Dead: dead}) + } + } + f(true) + f(false) + return +} diff --git a/server/block/coral_type.go b/server/block/coral_type.go new file mode 100644 index 0000000..de4744f --- /dev/null +++ b/server/block/coral_type.go @@ -0,0 +1,98 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" +) + +// CoralType represents a type of coral of a block. CoralType, coral fans, and coral blocks carry one of these types. +type CoralType struct { + coral +} + +// TubeCoral returns the tube coral variant +func TubeCoral() CoralType { + return CoralType{0} +} + +// BrainCoral returns the brain coral variant +func BrainCoral() CoralType { + return CoralType{1} +} + +// BubbleCoral returns the bubble coral variant +func BubbleCoral() CoralType { + return CoralType{2} +} + +// FireCoral returns the fire coral variant +func FireCoral() CoralType { + return CoralType{3} +} + +// HornCoral returns the horn coral variant +func HornCoral() CoralType { + return CoralType{4} +} + +// CoralTypes returns all coral types. +func CoralTypes() []CoralType { + return []CoralType{TubeCoral(), BrainCoral(), BubbleCoral(), FireCoral(), HornCoral()} +} + +type coral uint8 + +// Uint8 returns the coral as a uint8. +func (c coral) Uint8() uint8 { + return uint8(c) +} + +// Colour returns the colour of the CoralType. +func (c coral) Colour() item.Colour { + switch c { + case 0: + return item.ColourBlue() + case 1: + return item.ColourPink() + case 2: + return item.ColourPurple() + case 3: + return item.ColourRed() + case 4: + return item.ColourYellow() + } + panic("unknown coral type") +} + +// Name ... +func (c coral) Name() string { + switch c { + case 0: + return "Tube Coral" + case 1: + return "Brain Coral" + case 2: + return "Bubble Coral" + case 3: + return "Fire Coral" + case 4: + return "Horn Coral" + } + panic("unknown coral type") +} + +// String ... +func (c coral) String() string { + switch c { + case 0: + return "tube" + case 1: + return "brain" + case 2: + return "bubble" + case 3: + return "fire" + case 4: + return "horn" + } + panic("unknown coral type") +} diff --git a/server/block/crafting_table.go b/server/block/crafting_table.go new file mode 100644 index 0000000..933ff0c --- /dev/null +++ b/server/block/crafting_table.go @@ -0,0 +1,43 @@ +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" + "time" +) + +// CraftingTable is a utility block that allows the player to craft a variety of blocks and items. +type CraftingTable struct { + bass + solid +} + +// EncodeItem ... +func (c CraftingTable) EncodeItem() (name string, meta int16) { + return "minecraft:crafting_table", 0 +} + +// EncodeBlock ... +func (c CraftingTable) EncodeBlock() (name string, properties map[string]interface{}) { + return "minecraft:crafting_table", nil +} + +// BreakInfo ... +func (c CraftingTable) BreakInfo() BreakInfo { + return newBreakInfo(2.5, alwaysHarvestable, axeEffective, oneOf(c)) +} + +// FuelInfo ... +func (CraftingTable) FuelInfo() item.FuelInfo { + return newFuelInfo(time.Second * 15) +} + +// Activate ... +func (c CraftingTable) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + if opener, ok := u.(ContainerOpener); ok { + opener.OpenBlockContainer(pos, tx) + return true + } + return false +} diff --git a/server/block/crop.go b/server/block/crop.go new file mode 100644 index 0000000..e5ec85d --- /dev/null +++ b/server/block/crop.go @@ -0,0 +1,96 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Crop is an interface for all crops that are grown on farmland. A crop has a random chance to grow during random ticks. +type Crop interface { + // GrowthStage returns the crop's current stage of growth. The max value is 7. + GrowthStage() int + // SameCrop checks if two crops are of the same type. + SameCrop(c Crop) bool +} + +// crop is a base for crop plants. +type crop struct { + transparent + empty + + // Growth is the current stage of growth. The max value is 7. + Growth int +} + +// NeighbourUpdateTick ... +func (c crop) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if _, ok := tx.Block(pos.Side(cube.FaceDown)).(Farmland); !ok { + breakBlock(tx.Block(pos), pos, tx) + } +} + +// HasLiquidDrops ... +func (c crop) HasLiquidDrops() bool { + return true +} + +// GrowthStage returns the current stage of growth. +func (c crop) GrowthStage() int { + return c.Growth +} + +// CalculateGrowthChance calculates the chance the crop will grow during a random tick. +func (c crop) CalculateGrowthChance(pos cube.Pos, tx *world.Tx) float64 { + points := 0.0 + + block := tx.Block(pos) + under := pos.Side(cube.FaceDown) + + for x := -1; x <= 1; x++ { + for z := -1; z <= 1; z++ { + block := tx.Block(under.Add(cube.Pos{x, 0, z})) + if farmland, ok := block.(Farmland); ok { + farmlandPoints := 0.0 + if farmland.Hydration > 0 { + farmlandPoints = 4 + } else { + farmlandPoints = 2 + } + if x != 0 || z != 0 { + farmlandPoints = (farmlandPoints - 1) / 4 + } + points += farmlandPoints + } + } + } + + north := pos.Side(cube.FaceNorth) + south := pos.Side(cube.FaceSouth) + + northSouth := sameCrop(block, tx.Block(north)) || sameCrop(block, tx.Block(south)) + westEast := sameCrop(block, tx.Block(pos.Side(cube.FaceWest))) || sameCrop(block, tx.Block(pos.Side(cube.FaceEast))) + if northSouth && westEast { + points /= 2 + } else { + diagonal := sameCrop(block, tx.Block(north.Side(cube.FaceWest))) || + sameCrop(block, tx.Block(north.Side(cube.FaceEast))) || + sameCrop(block, tx.Block(south.Side(cube.FaceWest))) || + sameCrop(block, tx.Block(south.Side(cube.FaceEast))) + if diagonal { + points /= 2 + } + } + + chance := 1 / (25/points + 1) + return chance +} + +// sameCrop checks if both blocks are crops and that they are the same type. +func sameCrop(blockA, blockB world.Block) bool { + if a, ok := blockA.(Crop); ok { + if b, ok := blockB.(Crop); ok { + return a.SameCrop(b) + } + } + return false +} diff --git a/server/block/cube/axis.go b/server/block/cube/axis.go new file mode 100644 index 0000000..dd94d46 --- /dev/null +++ b/server/block/cube/axis.go @@ -0,0 +1,63 @@ +package cube + +import "github.com/go-gl/mathgl/mgl64" + +// Axis represents the axis that a block, such as a log, may be directed in. +type Axis int + +const ( + // Y represents the vertical Y axis. + Y Axis = iota + // Z represents the horizontal Z axis. + Z + // X represents the horizontal X axis. + X +) + +// String converts an Axis into either x, y or z, depending on which axis it is. +func (a Axis) String() string { + switch a { + case X: + return "x" + case Y: + return "y" + default: + return "z" + } +} + +// RotateLeft rotates an Axis from X to Z or from Z to X. +func (a Axis) RotateLeft() Axis { + switch a { + case X: + return Z + case Z: + return X + default: + return 0 + } +} + +// RotateRight rotates an Axis from X to Z or from Z to X. +func (a Axis) RotateRight() Axis { + // No difference in rotating left or right for an Axis. + return a.RotateLeft() +} + +// 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 { + switch a { + case X: + return mgl64.Vec3{1, 0, 0} + case Y: + return mgl64.Vec3{0, 1, 0} + default: + return mgl64.Vec3{0, 0, 1} + } +} + +// Axes return all possible axes. (x, y, z) +func Axes() []Axis { + return []Axis{X, Y, Z} +} diff --git a/server/block/cube/bbox.go b/server/block/cube/bbox.go new file mode 100644 index 0000000..b0a8131 --- /dev/null +++ b/server/block/cube/bbox.go @@ -0,0 +1,284 @@ +package cube + +import ( + "github.com/go-gl/mathgl/mgl64" +) + +// BBox represents an Axis Aligned Bounding Box in a 3D space. It is defined as +// two Vec3s, of which one is the minimum and one is the maximum. +type BBox struct { + min, max mgl64.Vec3 +} + +// Box creates a new axis aligned bounding box with the minimum and maximum +// coordinates provided. The returned box has minimum and maximum coordinates +// swapped if necessary so that it is well-formed. +func Box(x0, y0, z0, x1, y1, z1 float64) BBox { + if x0 > x1 { + x0, x1 = x1, x0 + } + if y0 > y1 { + y0, y1 = y1, y0 + } + if z0 > z1 { + z0, z1 = z1, z0 + } + return BBox{min: mgl64.Vec3{x0, y0, z0}, max: mgl64.Vec3{x1, y1, z1}} +} + +// Grow grows the bounding box in all directions by x and returns the new +// bounding box. +func (box BBox) Grow(x float64) BBox { + add := mgl64.Vec3{x, x, x} + return BBox{min: box.min.Sub(add), max: box.max.Add(add)} +} + +// GrowVec3 grows the BBox on all axes as represented by the Vec3 passed. The +// vec values are subtracted from the minimum values of the BBox and added to +// the maximum values of the BBox. +func (box BBox) GrowVec3(vec mgl64.Vec3) BBox { + return BBox{min: box.min.Sub(vec), max: box.max.Add(vec)} +} + +// Min returns the minimum coordinate of the bounding box. +func (box BBox) Min() mgl64.Vec3 { + return box.min +} + +// Max returns the maximum coordinate of the bounding box. +func (box BBox) Max() mgl64.Vec3 { + return box.max +} + +// Width returns the width of the BBox. +func (box BBox) Width() float64 { + return box.max[0] - box.min[0] +} + +// Length returns the length of the BBox. +func (box BBox) Length() float64 { + return box.max[2] - box.min[2] +} + +// Height returns the height of the BBox. +func (box BBox) Height() float64 { + return box.max[1] - box.min[1] +} + +// Extend expands the BBox on all axes as represented by the Vec3 passed. +// Negative coordinates result in an expansion towards the negative axis, and +// vice versa for positive coordinates. +func (box BBox) Extend(vec mgl64.Vec3) BBox { + if vec[0] < 0 { + box.min[0] += vec[0] + } else if vec[0] > 0 { + box.max[0] += vec[0] + } + if vec[1] < 0 { + box.min[1] += vec[1] + } else if vec[1] > 0 { + box.max[1] += vec[1] + } + if vec[2] < 0 { + box.min[2] += vec[2] + } else if vec[2] > 0 { + box.max[2] += vec[2] + } + return box +} + +// ExtendTowards extends the bounding box by x in a given direction. +func (box BBox) ExtendTowards(f Face, x float64) BBox { + switch f { + case FaceDown: + box.min[1] -= x + case FaceUp: + box.max[1] += x + case FaceNorth: + box.min[2] -= x + case FaceSouth: + box.max[2] += x + case FaceWest: + box.min[0] -= x + case FaceEast: + box.max[0] += x + } + return box +} + +// Stretch stretches the bounding box by x in a given axis. +func (box BBox) Stretch(a Axis, x float64) BBox { + switch a { + case Y: + box.min[1] -= x + box.max[1] += x + case Z: + box.min[2] -= x + box.max[2] += x + case X: + box.min[0] -= x + box.max[0] += x + } + return box +} + +// Translate moves the entire BBox with the Vec3 given. The (minimum and +// maximum) x, y and z coordinates are moved by those in the Vec3 passed. +func (box BBox) Translate(vec mgl64.Vec3) BBox { + return BBox{min: box.min.Add(vec), max: box.max.Add(vec)} +} + +// TranslateTowards moves the entire BBox by x in the direction of a Face f. +func (box BBox) TranslateTowards(f Face, x float64) BBox { + switch f { + case FaceDown: + return box.Translate(mgl64.Vec3{0, -x, 0}) + case FaceUp: + return box.Translate(mgl64.Vec3{0, x, 0}) + case FaceNorth: + return box.Translate(mgl64.Vec3{0, 0, -x}) + case FaceSouth: + return box.Translate(mgl64.Vec3{0, 0, x}) + case FaceWest: + return box.Translate(mgl64.Vec3{-x, 0, 0}) + case FaceEast: + return box.Translate(mgl64.Vec3{x, 0, 0}) + } + return box +} + +// IntersectsWith checks if the BBox intersects with another BBox. +func (box BBox) IntersectsWith(other BBox) bool { + return box.intersectsWith(other, 1e-5) +} + +// intersectsWith checks if the BBox intersects with another BBox using a +// specific epsilon. +func (box BBox) intersectsWith(other BBox, epsilon float64) bool { + if other.max[0]-box.min[0] > epsilon && box.max[0]-other.min[0] > epsilon { + if other.max[1]-box.min[1] > epsilon && box.max[1]-other.min[1] > epsilon { + return other.max[2]-box.min[2] > epsilon && box.max[2]-other.min[2] > epsilon + } + } + return false +} + +// AnyIntersections checks if any of boxes intersect with search. +func AnyIntersections(boxes []BBox, search BBox) bool { + for _, box := range boxes { + if box.intersectsWith(search, 0) { + return true + } + } + return false +} + +// Vec3Within checks if a BBox has vec within it. +func (box BBox) Vec3Within(vec mgl64.Vec3) bool { + if vec[0] <= box.min[0] || vec[0] >= box.max[0] { + return false + } + if vec[2] <= box.min[2] || vec[2] >= box.max[2] { + return false + } + return vec[1] > box.min[1] && vec[1] < box.max[1] +} + +// Vec3WithinYZ checks if a BBox has vec within its Y and Z bounds. +func (box BBox) Vec3WithinYZ(vec mgl64.Vec3) bool { + if vec[2] < box.min[2] || vec[2] > box.max[2] { + return false + } + return vec[1] >= box.min[1] && vec[1] <= box.max[1] +} + +// Vec3WithinXZ checks if a BBox has vec within its X and Z bounds. +func (box BBox) Vec3WithinXZ(vec mgl64.Vec3) bool { + if vec[0] < box.min[0] || vec[0] > box.max[0] { + return false + } + return vec[2] >= box.min[2] && vec[2] <= box.max[2] +} + +// Vec3WithinXY checks if a BBox has vec within its X and Y bounds. +func (box BBox) Vec3WithinXY(vec mgl64.Vec3) bool { + if vec[0] < box.min[0] || vec[0] > box.max[0] { + return false + } + return vec[1] >= box.min[1] && vec[1] <= box.max[1] +} + +// XOffset calculates the offset on the X axis between two bounding boxes, +// returning a delta always smaller than or equal to deltaX if deltaX is bigger +// than 0, or always bigger than or equal to deltaX if it is smaller than 0. +func (box BBox) XOffset(nearby BBox, deltaX float64) float64 { + if box.max[1] <= nearby.min[1] || box.min[1] >= nearby.max[1] || box.max[2] <= nearby.min[2] || box.min[2] >= nearby.max[2] { + // Not in the same Y/Z plane. + return deltaX + } + if deltaX > 0 && box.max[0] <= nearby.min[0] { + deltaX = min(deltaX, nearby.min[0]-box.max[0]) + } else if deltaX < 0 && box.min[0] >= nearby.max[0] { + deltaX = max(deltaX, nearby.max[0]-box.min[0]) + } + return deltaX +} + +// YOffset calculates the offset on the Y axis between two bounding boxes, +// returning a delta always smaller than or equal to deltaY if deltaY is bigger +// than 0, or always bigger than or equal to deltaY if it is smaller than 0. +func (box BBox) YOffset(nearby BBox, deltaY float64) float64 { + if box.max[0] <= nearby.min[0] || box.min[0] >= nearby.max[0] || box.max[2] <= nearby.min[2] || box.min[2] >= nearby.max[2] { + // Not the same X/Z plane. + return deltaY + } + if deltaY > 0 && box.max[1] <= nearby.min[1] { + deltaY = min(deltaY, nearby.min[1]-box.max[1]) + } + if deltaY < 0 && box.min[1] >= nearby.max[1] { + deltaY = max(deltaY, nearby.max[1]-box.min[1]) + } + return deltaY +} + +// ZOffset calculates the offset on the Z axis between two bounding boxes, +// returning a delta always smaller than or equal to deltaZ if deltaZ is bigger +// than 0, or always bigger than or equal to deltaZ if it is smaller than 0. +func (box BBox) ZOffset(nearby BBox, deltaZ float64) float64 { + if box.max[0] <= nearby.min[0] || box.min[0] >= nearby.max[0] || box.max[1] <= nearby.min[1] || box.min[1] >= nearby.max[1] { + // Not the same X/Y plane. + return deltaZ + } + if deltaZ > 0 && box.max[2] <= nearby.min[2] { + deltaZ = min(deltaZ, nearby.min[2]-box.max[2]) + } + if deltaZ < 0 && box.min[2] >= nearby.max[2] { + deltaZ = max(deltaZ, nearby.max[2]-box.min[2]) + } + return deltaZ +} + +// Corners returns the positions of all corners of a BBox. +func (box BBox) Corners() []mgl64.Vec3 { + bbmin, bbmax := box.min, box.max + return []mgl64.Vec3{ + box.min, + box.max, + {bbmin[0], bbmin[1], bbmax[2]}, + {bbmin[0], bbmax[1], bbmin[2]}, + {bbmin[0], bbmax[1], bbmax[2]}, + {bbmax[0], bbmax[1], bbmin[2]}, + {bbmax[0], bbmin[1], bbmax[2]}, + {bbmax[0], bbmin[1], bbmin[2]}, + } +} + +// Mul performs a scalar multiplication of the min and max points of a BBox. +func (box BBox) Mul(val float64) BBox { + return BBox{min: box.min.Mul(val), max: box.max.Mul(val)} +} + +// Volume calculates the volume of a BBox. +func (box BBox) Volume() float64 { + return box.Height() * box.Length() * box.Width() +} diff --git a/server/block/cube/direction.go b/server/block/cube/direction.go new file mode 100644 index 0000000..9074f61 --- /dev/null +++ b/server/block/cube/direction.go @@ -0,0 +1,89 @@ +package cube + +// Direction represents a direction towards one of the horizontal axes +type Direction int + +const ( + // North represents the north direction, towards the negative Z. + North Direction = iota + // South represents the south direction, towards the positive Z. + South + // West represents the west direction, towards the negative X. + West + // East represents the east direction, towards the positive X. + East +) + +// Face converts the direction to a Face and returns it. +func (d Direction) Face() Face { + return Face(d + 2) +} + +// Opposite returns Direction opposite to the current one. +func (d Direction) Opposite() Direction { + switch d { + case North: + return South + case South: + return North + case West: + return East + case East: + return West + } + panic("invalid direction") +} + +// RotateRight rotates the direction 90 degrees to the right horizontally +// (clockwise) and returns the new direction. +func (d Direction) RotateRight() Direction { + switch d { + case North: + return East + case East: + return South + case South: + return West + case West: + return North + } + panic("invalid direction") +} + +// RotateLeft rotates the direction 90 degrees to the left horizontally +// (counter-clockwise) and returns the new direction. +func (d Direction) RotateLeft() Direction { + switch d { + case North: + return West + case East: + return North + case South: + return East + case West: + return South + } + panic("invalid direction") +} + +// String returns the Direction as a string. +func (d Direction) String() string { + switch d { + case North: + return "north" + case East: + return "east" + case South: + return "south" + case West: + return "west" + } + panic("invalid direction") +} + +var directions = [...]Direction{North, East, South, West} + +// Directions returns a list of all directions, going from North to West. +func Directions() []Direction { + return directions[:] +} diff --git a/server/block/cube/doc.go b/server/block/cube/doc.go new file mode 100644 index 0000000..d3f2514 --- /dev/null +++ b/server/block/cube/doc.go @@ -0,0 +1,3 @@ +// Package cube provides types and functions to handle positions and rotations +// of voxel-based objects in a 3D world. +package cube diff --git a/server/block/cube/face.go b/server/block/cube/face.go new file mode 100644 index 0000000..d08286b --- /dev/null +++ b/server/block/cube/face.go @@ -0,0 +1,124 @@ +package cube + +const ( + // FaceDown represents the bottom face of a block. + FaceDown Face = iota + // FaceUp represents the top face of a block. + FaceUp + // FaceNorth represents the north face of a block. + FaceNorth + // FaceSouth represents the south face of a block. + FaceSouth + // FaceWest represents the west face of the block. + FaceWest + // FaceEast represents the east face of the block. + FaceEast +) + +// Face represents the face of a block or entity. +type Face int + +// Direction converts the Face to a Direction and returns it, assuming the Face +// is horizontal and not FaceUp or FaceDown. +func (f Face) Direction() Direction { + return Direction(f - 2) +} + +// Opposite returns the opposite Face. FaceDown will return FaceUp, FaceNorth +// will return FaceSouth, FaceWest will return FaceEast, and vice versa. +func (f Face) Opposite() Face { + switch f { + default: + return FaceUp + case FaceUp: + return FaceDown + case FaceNorth: + return FaceSouth + case FaceSouth: + return FaceNorth + case FaceWest: + return FaceEast + case FaceEast: + return FaceWest + } +} + +// Axis returns the Axis the face is facing. FaceEast and FaceWest correspond +// to the X Axis, FaceNorth and FaceSouth to the Z Axis and FaceUp and FaceDown +// to the Y Axis. +func (f Face) Axis() Axis { + switch f { + default: + return Y + case FaceEast, FaceWest: + return X + case FaceNorth, FaceSouth: + return Z + } +} + +// RotateRight rotates the face 90 degrees to the right horizontally +// (clockwise) and returns the new Face. +func (f Face) RotateRight() Face { + switch f { + case FaceNorth: + return FaceEast + case FaceEast: + return FaceSouth + case FaceSouth: + return FaceWest + case FaceWest: + return FaceNorth + } + return f +} + +// RotateLeft rotates the face 90 degrees to the left horizontally +// (counter-clockwise) and returns the new Face. +func (f Face) RotateLeft() Face { + switch f { + case FaceNorth: + return FaceWest + case FaceEast: + return FaceNorth + case FaceSouth: + return FaceEast + case FaceWest: + return FaceSouth + } + return f +} + +// String returns the Face as a string. +func (f Face) String() string { + switch f { + case FaceUp: + return "up" + case FaceDown: + return "down" + case FaceNorth: + return "north" + case FaceSouth: + return "south" + case FaceWest: + return "west" + case FaceEast: + return "east" + } + panic("invalid face") +} + +// Faces returns a list of all faces, starting with down, then up, then north +// to west. +func Faces() []Face { + return faces[:] +} + +// HorizontalFaces returns a list of all horizontal faces, from north to west. +func HorizontalFaces() []Face { + return hFaces[:] +} + +var hFaces = [...]Face{FaceNorth, FaceEast, FaceSouth, FaceWest} + +var faces = [...]Face{FaceDown, FaceUp, FaceNorth, FaceEast, FaceSouth, FaceWest} diff --git a/server/block/cube/orientation.go b/server/block/cube/orientation.go new file mode 100644 index 0000000..9b08cdd --- /dev/null +++ b/server/block/cube/orientation.go @@ -0,0 +1,33 @@ +package cube + +import "math" + +// Orientation represents the rotation of a block divided into 16 options. +type Orientation int + +// OrientationFromYaw returns an Orientation value that (roughly) matches the +// yaw passed. +func OrientationFromYaw(yaw float64) Orientation { + yaw = math.Mod(yaw, 360) + return Orientation(math.Round(yaw / 360 * 16)) +} + +// Yaw returns the yaw value that matches the orientation. +func (o Orientation) Yaw() float64 { + return float64(o) / 16 * 360 +} + +// Opposite returns the opposite orientation value of the Orientation. +func (o Orientation) Opposite() Orientation { + return OrientationFromYaw(o.Yaw() + 180) +} + +// RotateLeft rotates the orientation left by 90 degrees and returns it. +func (o Orientation) RotateLeft() Orientation { + return OrientationFromYaw(o.Yaw() - 90) +} + +// RotateRight rotates the orientation right by 90 degrees and returns it. +func (o Orientation) RotateRight() Orientation { + return OrientationFromYaw(o.Yaw() + 90) +} diff --git a/server/block/cube/pos.go b/server/block/cube/pos.go new file mode 100644 index 0000000..2c87d86 --- /dev/null +++ b/server/block/cube/pos.go @@ -0,0 +1,176 @@ +package cube + +import ( + "fmt" + "iter" + "math" + + "github.com/go-gl/mathgl/mgl64" +) + +// Pos holds the position of a block. The position is represented as an array +// with an x, y and z value, where the y value is positive. +type Pos [3]int + +// String converts the Pos to a string in the format (1,2,3) and returns it. +func (p Pos) String() string { + return fmt.Sprintf("(%v,%v,%v)", p[0], p[1], p[2]) +} + +// X returns the X coordinate of the block position. +func (p Pos) X() int { + return p[0] +} + +// Y returns the Y coordinate of the block position. +func (p Pos) Y() int { + return p[1] +} + +// Z returns the Z coordinate of the block position. +func (p Pos) Z() int { + return p[2] +} + +// OutOfBounds checks if the Y value is either bigger than r[1] or smaller than +// r[0]. +func (p Pos) OutOfBounds(r Range) bool { + y := p[1] + return y > r[1] || y < r[0] +} + +// Within reports if the position lies within the inclusive minimum and maximum bounds passed. +func (p Pos) Within(min, max Pos) bool { + return p[0] >= min[0] && p[0] <= max[0] && + p[1] >= min[1] && p[1] <= max[1] && + p[2] >= min[2] && p[2] <= max[2] +} + +// Add adds two positions together and returns a new combined one. +func (p Pos) Add(pos Pos) Pos { + return Pos{p[0] + pos[0], p[1] + pos[1], p[2] + pos[2]} +} + +// Sub subtracts pos from p and returns a new one with the subtracted values. +func (p Pos) Sub(pos Pos) Pos { + return Pos{p[0] - pos[0], p[1] - pos[1], p[2] - pos[2]} +} + +// Vec3 returns a vec3 holding the same coordinates as the block position. +func (p Pos) Vec3() mgl64.Vec3 { + return mgl64.Vec3{float64(p[0]), float64(p[1]), float64(p[2])} +} + +// Vec3Middle returns a Vec3 holding the coordinates of the block position with +// 0.5 added on both horizontal axes. +func (p Pos) Vec3Middle() mgl64.Vec3 { + return mgl64.Vec3{float64(p[0]) + 0.5, float64(p[1]), float64(p[2]) + 0.5} +} + +// Vec3Centre returns a Vec3 holding the coordinates of the block position with +// 0.5 added on all axes. +func (p Pos) Vec3Centre() mgl64.Vec3 { + return mgl64.Vec3{float64(p[0]) + 0.5, float64(p[1]) + 0.5, float64(p[2]) + 0.5} +} + +// Side returns the position on the side of this block position, at a specific +// face. +func (p Pos) Side(face Face) Pos { + switch face { + case FaceUp: + p[1]++ + case FaceDown: + p[1]-- + case FaceNorth: + p[2]-- + case FaceSouth: + p[2]++ + case FaceWest: + p[0]-- + case FaceEast: + p[0]++ + } + return p +} + +// 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 + } + return FaceUp +} + +// Neighbours calls the function passed for each of the block position's +// neighbours. If the Y value is out of bounds, the function will not be called +// for that position. +func (p Pos) Neighbours(f func(neighbour Pos), r Range) { + if p.OutOfBounds(r) { + return + } + p[0]++ + f(p) + p[0] -= 2 + f(p) + p[0]++ + p[1]++ + if p[1] <= r[1] { + f(p) + } + p[1] -= 2 + if p[1] >= r[0] { + f(p) + } + p[1]++ + p[2]++ + f(p) + p[2] -= 2 + f(p) +} + +// PosFromVec3 returns a block position by a Vec3, rounding the values down +// adequately. +func PosFromVec3(vec3 mgl64.Vec3) Pos { + return Pos{int(math.Floor(vec3[0])), int(math.Floor(vec3[1])), int(math.Floor(vec3[2]))} +} + +// Min returns a new position where each coordinate is the minimum +// of input positions p1 and p2. +func Min(p1, p2 Pos) Pos { + return Pos{min(p1[0], p2[0]), min(p1[1], p2[1]), min(p1[2], p2[2])} +} + +// Max returns a new position where each coordinate is the maximum +// of input positions p1 and p2. +func Max(p1, p2 Pos) Pos { + return Pos{max(p1[0], p2[0]), max(p1[1], p2[1]), max(p1[2], p2[2])} +} + +// Range3D returns iterator that iterates all points between minimum and maximum of p1 & p2. +func Range3D(p1, p2 Pos) iter.Seq[Pos] { + max := Max(p1, p2) + min := Min(p1, p2) + return func(yield func(Pos) bool) { + for x := min[0]; x <= max[0]; x++ { + for y := min[1]; y <= max[1]; y++ { + for z := min[2]; z <= max[2]; z++ { + if !yield(Pos{x, y, z}) { + return + } + } + } + } + } +} diff --git a/server/block/cube/range.go b/server/block/cube/range.go new file mode 100644 index 0000000..686cb3d --- /dev/null +++ b/server/block/cube/range.go @@ -0,0 +1,22 @@ +package cube + +// Range represents the height range of a Dimension in blocks. The first value +// of the Range holds the minimum Y value, the second value holds the maximum Y +// value. +type Range [2]int + +// Min returns the minimum Y value of a Range. It is equivalent to Range[0]. +func (r Range) Min() int { + return r[0] +} + +// Max returns the maximum Y value of a Range. It is equivalent to Range[1]. +func (r Range) Max() int { + return r[1] +} + +// Height returns the total height of the Range, the difference between Max and +// Min. +func (r Range) Height() int { + return r[1] - r[0] +} diff --git a/server/block/cube/rotation.go b/server/block/cube/rotation.go new file mode 100644 index 0000000..962cb22 --- /dev/null +++ b/server/block/cube/rotation.go @@ -0,0 +1,101 @@ +package cube + +import ( + "github.com/go-gl/mathgl/mgl64" + "math" +) + +// Rotation describes the rotation of an object in the world in degrees. It +// holds a yaw (r[0]) and pitch value (r[1]). Yaw is in the range (-180, 180) +// while pitch is in the range (-90, 90). A positive pitch implies an entity is +// looking downwards, while a negative pitch implies it is looking upwards. +type Rotation [2]float64 + +// Yaw returns the yaw of r (r[0]). +func (r Rotation) Yaw() float64 { + return r[0] +} + +// Pitch returns the pitch of r (r[1]). +func (r Rotation) Pitch() float64 { + return r[1] +} + +// Elem extracts the elements of the Rotation for direct value assignment. +func (r Rotation) Elem() (yaw, pitch float64) { + return r[0], r[1] +} + +// Add adds the values of two Rotations element-wise and returns a new Rotation. +// If the yaw or pitch would otherwise exceed their respective range as +// described, they are 'overflown' to the other end of the allowed range. +func (r Rotation) Add(r2 Rotation) Rotation { + return Rotation{r[0] + r2[0], r[1] + r2[1]}.fix() +} + +// Opposite returns the Rotation opposite r, so that +// r.Vec3().Add(r.Opposite().Vec3()).Len() is equal to 0. +func (r Rotation) Opposite() Rotation { + fixed := r.fix() + return Rotation{fixed[0] + 180, -fixed[1]}.fix() +} + +// Neg returns the negation of the Rotation. It is equivalent to creating a new +// Rotation{-r[0], -r[1]}. +func (r Rotation) Neg() Rotation { + fixed := r.fix() + return Rotation{-fixed[0], -fixed[1]} +} + +// Direction returns the horizontal Direction that r points towards based on the +// yaw of r. +func (r Rotation) Direction() Direction { + yaw := r.fix().Yaw() + switch { + case yaw > 45 && yaw <= 135: + return West + case yaw > -45 && yaw <= 45: + return South + case yaw > -135 && yaw <= -45: + return East + case yaw <= -135 || yaw > 135: + return North + } + return 0 +} + +// Orientation returns an Orientation value that most closely matches the yaw +// of r. +func (r Rotation) Orientation() Orientation { + const step = 360 / 16.0 + + yaw := r.fix().Yaw() + if yaw < -step/2 { + yaw += 360 + } + return Orientation(math.Round(yaw / step)) +} + +// Vec3 returns the direction vector of r. The length of the mgl64.Vec3 returned +// is always 1. +func (r Rotation) Vec3() mgl64.Vec3 { + yaw, pitch := r.fix().Elem() + yawRad, pitchRad := mgl64.DegToRad(yaw), mgl64.DegToRad(pitch) + + m := math.Cos(pitchRad) + return mgl64.Vec3{ + -m * math.Sin(yawRad), + -math.Sin(pitchRad), + m * math.Cos(yawRad), + } +} + +// fix 'overflows' the Rotation's values to make sure they are within the range +// as described above. +func (r Rotation) fix() Rotation { + signYaw, signPitch := math.Copysign(180, r[0]), math.Copysign(90, r[1]) + return Rotation{ + math.Mod(r[0]+signYaw, 360) - signYaw, + math.Mod(r[1]+signPitch, 180) - signPitch, + } +} diff --git a/server/block/cube/trace/bbox.go b/server/block/cube/trace/bbox.go new file mode 100644 index 0000000..aef3bbf --- /dev/null +++ b/server/block/cube/trace/bbox.go @@ -0,0 +1,180 @@ +package trace + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/go-gl/mathgl/mgl64" + "math" +) + +// BBoxResult is the result of a basic ray trace collision with a bounding box. +type BBoxResult struct { + bb cube.BBox + pos mgl64.Vec3 + face cube.Face +} + +// BBox ... +func (r BBoxResult) BBox() cube.BBox { + return r.bb +} + +// Position ... +func (r BBoxResult) Position() mgl64.Vec3 { + return r.pos +} + +// Face ... +func (r BBoxResult) Face() cube.Face { + return r.face +} + +// BBoxIntercept performs a ray trace and calculates the point on the BBox's edge nearest to the start position that the ray trace +// collided with. +// BBoxIntercept returns a BBoxResult with the colliding vector closest to the start position, if no colliding point was found, +// a zero BBoxResult is returned and ok is false. +func BBoxIntercept(bb cube.BBox, start, end mgl64.Vec3) (result BBoxResult, ok bool) { + min, max := bb.Min(), bb.Max() + v1 := vec3OnLineWithX(start, end, min[0]) + v2 := vec3OnLineWithX(start, end, max[0]) + v3 := vec3OnLineWithY(start, end, min[1]) + v4 := vec3OnLineWithY(start, end, max[1]) + v5 := vec3OnLineWithZ(start, end, min[2]) + v6 := vec3OnLineWithZ(start, end, max[2]) + + if v1 != nil && !bb.Vec3WithinYZ(*v1) { + v1 = nil + } + if v2 != nil && !bb.Vec3WithinYZ(*v2) { + v2 = nil + } + if v3 != nil && !bb.Vec3WithinXZ(*v3) { + v3 = nil + } + if v4 != nil && !bb.Vec3WithinXZ(*v4) { + v4 = nil + } + if v5 != nil && !bb.Vec3WithinXY(*v5) { + v5 = nil + } + if v6 != nil && !bb.Vec3WithinXY(*v6) { + v6 = nil + } + + var ( + vec *mgl64.Vec3 + dist = math.MaxFloat64 + ) + + for _, v := range [...]*mgl64.Vec3{v1, v2, v3, v4, v5, v6} { + if v == nil { + continue + } + + if d := start.Sub(*v).LenSqr(); d < dist { + vec = v + dist = d + } + } + + if vec == nil { + return + } + + var f cube.Face + switch vec { + case v1: + f = cube.FaceWest + case v2: + f = cube.FaceEast + case v3: + f = cube.FaceDown + case v4: + f = cube.FaceUp + case v5: + f = cube.FaceNorth + case v6: + f = cube.FaceSouth + } + + 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 { + if mgl64.FloatEqual(b[0], a[0]) { + return nil + } + + f := (x - a[0]) / (b[0] - a[0]) + if f < 0 || f > 1 { + return nil + } + + return &mgl64.Vec3{x, a[1] + (b[1]-a[1])*f, a[2] + (b[2]-a[2])*f} +} + +// vec3OnLineWithY returns an mgl64.Vec3 on the line between mgl64.Vec3 a and b with a Y value passed. If no such vec3 +// could be found, the bool returned is false. +func vec3OnLineWithY(a, b mgl64.Vec3, y float64) *mgl64.Vec3 { + if mgl64.FloatEqual(a[1], b[1]) { + return nil + } + + f := (y - a[1]) / (b[1] - a[1]) + if f < 0 || f > 1 { + return nil + } + + return &mgl64.Vec3{a[0] + (b[0]-a[0])*f, y, a[2] + (b[2]-a[2])*f} +} + +// vec3OnLineWithZ returns an mgl64.Vec3 on the line between mgl64.Vec3 a and b with a Z value passed. If no such vec3 +// could be found, the bool returned is false. +func vec3OnLineWithZ(a, b mgl64.Vec3, z float64) *mgl64.Vec3 { + if mgl64.FloatEqual(a[2], b[2]) { + return nil + } + + f := (z - a[2]) / (b[2] - a[2]) + if f < 0 || f > 1 { + return nil + } + + return &mgl64.Vec3{a[0] + (b[0]-a[0])*f, a[1] + (b[1]-a[1])*f, z} +} diff --git a/server/block/cube/trace/block.go b/server/block/cube/trace/block.go new file mode 100644 index 0000000..3139151 --- /dev/null +++ b/server/block/cube/trace/block.go @@ -0,0 +1,93 @@ +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" +) + +// BlockResult is the result of a ray trace collision with a block's model. +type BlockResult struct { + bb cube.BBox + pos mgl64.Vec3 + face cube.Face + + blockPos cube.Pos +} + +// BBox returns the BBox that was collided within the block's model. +func (r BlockResult) BBox() cube.BBox { + return r.bb +} + +// Position ... +func (r BlockResult) Position() mgl64.Vec3 { + return r.pos +} + +// Face returns the hit block face. +func (r BlockResult) Face() cube.Face { + return r.face +} + +// BlockPosition returns the block that was collided with. +func (r BlockResult) BlockPosition() cube.Pos { + return r.blockPos +} + +// BlockIntercept performs a ray trace and calculates the point on the block model's edge nearest to the start position +// that the ray collided with. +// BlockIntercept returns a BlockResult with the block collided with and with the colliding vector closest to the start position, +// if no colliding point was found, a zero BlockResult is returned and ok is false. +func BlockIntercept(pos cube.Pos, src world.BlockSource, b world.Block, start, end mgl64.Vec3) (result BlockResult, ok bool) { + bbs := b.Model().BBox(pos, src) + if len(bbs) == 0 { + return + } + + var ( + hit Result + dist = math.MaxFloat64 + ) + + for _, bb := range bbs { + next, ok := BBoxIntercept(bb.Translate(pos.Vec3()), start, end) + if !ok { + continue + } + + nextDist := next.Position().Sub(start).LenSqr() + if nextDist < dist { + hit = next + dist = nextDist + } + } + + if hit == nil { + return result, false + } + + 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/cube/trace/entity.go b/server/block/cube/trace/entity.go new file mode 100644 index 0000000..17a829c --- /dev/null +++ b/server/block/cube/trace/entity.go @@ -0,0 +1,51 @@ +package trace + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// EntityResult is the result of a ray trace collision with an entities bounding box. +type EntityResult struct { + bb cube.BBox + pos mgl64.Vec3 + face cube.Face + + entity world.Entity +} + +// BBox returns the entities bounding box that was collided with. +func (r EntityResult) BBox() cube.BBox { + return r.bb +} + +// Position ... +func (r EntityResult) Position() mgl64.Vec3 { + return r.pos +} + +// Face ... +func (r EntityResult) Face() cube.Face { + return r.face +} + +// Entity returns the entity that was collided with. +func (r EntityResult) Entity() world.Entity { + return r.entity +} + +// EntityIntercept performs a ray trace and calculates the point on the entities bounding box's edge nearest to the start position +// that the ray collided with. +// EntityIntercept returns an EntityResult with the entity collided with and with the colliding vector closest to the start position, +// if no colliding point was found, a zero BlockResult is returned ok is false. +func EntityIntercept(e world.Entity, start, end mgl64.Vec3) (result EntityResult, ok bool) { + bb := e.H().Type().BBox(e).Translate(e.Position()).Grow(0.3) + + r, ok := BBoxIntercept(bb, start, end) + if !ok { + return + } + + return EntityResult{bb: bb, pos: r.Position(), face: r.Face(), entity: e}, true +} diff --git a/server/block/cube/trace/result.go b/server/block/cube/trace/result.go new file mode 100644 index 0000000..776a3ef --- /dev/null +++ b/server/block/cube/trace/result.go @@ -0,0 +1,64 @@ +package trace + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" + "iter" + "math" +) + +// Result represents the result of a ray trace collision with a bounding box. +type Result interface { + // BBox returns the bounding box collided with. + BBox() cube.BBox + // Position returns where the ray first collided with the bounding box. + Position() mgl64.Vec3 + // Face returns the face of the bounding box that was collided on. + Face() cube.Face +} + +type EntityFilter func(iter.Seq[world.Entity]) iter.Seq[world.Entity] + +// Perform performs a ray trace between start and end, checking if any blocks or entities collided with the +// ray. The physics.BBox that's passed is used for checking if any entity within the bounding box collided +// with the ray. +func Perform(start, end mgl64.Vec3, tx *world.Tx, box cube.BBox, filter EntityFilter) (hit Result, ok bool) { + // Check if there's any blocks that we may collide with. + TraverseBlocks(start, end, func(pos cube.Pos) (cont bool) { + b := tx.Block(pos) + + // Check if we collide with the block's model. + if result, ok := BlockIntercept(pos, tx, b, start, end); ok { + hit = result + end = hit.Position() + return false + } + return true + }) + + // Now check for any entities that we may collide with. + dist := math.MaxFloat64 + bb := box.Translate(start).Extend(end.Sub(start)) + entities := tx.EntitiesWithin(bb.Grow(8.0)) + if filter != nil { + entities = filter(entities) + } + for entity := range entities { + if !entity.H().Type().BBox(entity).Translate(entity.Position()).IntersectsWith(bb) { + continue + } + // Check if we collide with the entities bounding box. + result, ok := EntityIntercept(entity, start, end) + if !ok { + continue + } + + if distance := start.Sub(result.Position()).LenSqr(); distance < dist { + dist = distance + hit = result + } + } + + return hit, hit != nil +} diff --git a/server/block/cube/trace/trace.go b/server/block/cube/trace/trace.go new file mode 100644 index 0000000..9959e47 --- /dev/null +++ b/server/block/cube/trace/trace.go @@ -0,0 +1,109 @@ +package trace + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/go-gl/mathgl/mgl64" + "math" +) + +// TraverseBlocks performs a ray trace between the start and end coordinates. +// A function 'f' is passed which is called for each voxel, if f returns false, the function will return. +// TraverseBlocks panics if the start and end positions are the same. +func TraverseBlocks(start, end mgl64.Vec3, f func(pos cube.Pos) (con bool)) { + dir := end.Sub(start) + if mgl64.FloatEqual(dir.LenSqr(), 0) { + panic("start and end points are the same, giving a zero direction vector") + } + dir = dir.Normalize() + + b := cube.PosFromVec3(start) + + step := signVec3(dir) + stepX, stepY, stepZ := int(step[0]), int(step[1]), int(step[2]) + max := boundaryVec3(start, dir) + + delta := safeDivideVec3(step, dir) + + r := start.Sub(end).Len() + for { + if !f(b) { + return + } + + switch { + case max[0] < max[1] && max[0] < max[2]: + if max[0] > r { + return + } + b[0] += stepX + max[0] += delta[0] + case max[1] < max[2]: + if max[1] > r { + return + } + b[1] += stepY + max[1] += delta[1] + default: + if max[2] > r { + return + } + b[2] += stepZ + max[2] += delta[2] + } + } +} + +// safeDivideVec3 ... +func safeDivideVec3(dividend, divisor mgl64.Vec3) mgl64.Vec3 { + return mgl64.Vec3{ + safeDivide(dividend[0], divisor[0]), + safeDivide(dividend[1], divisor[1]), + safeDivide(dividend[2], divisor[2]), + } +} + +// safeDivide divides the dividend by the divisor, but if the divisor is 0, it returns 0. +func safeDivide(dividend, divisor float64) float64 { + if divisor == 0.0 { + return 0.0 + } + return dividend / divisor +} + +// boundaryVec3 ... +func boundaryVec3(v1, v2 mgl64.Vec3) mgl64.Vec3 { + return mgl64.Vec3{boundary(v1[0], v2[0]), boundary(v1[1], v2[1]), boundary(v1[2], v2[2])} +} + +// boundary returns the distance that must be travelled on an axis from the start point with the direction vector +// component to cross a block boundary. +func boundary(start, dir float64) float64 { + if dir == 0.0 { + return math.Inf(1) + } + + if dir < 0.0 { + start, dir = -start, -dir + if math.Floor(start) == start { + return 0.0 + } + } + + return (1 - (start - math.Floor(start))) / dir +} + +// signVec3 ... +func signVec3(v1 mgl64.Vec3) mgl64.Vec3 { + return mgl64.Vec3{sign(v1[0]), sign(v1[1]), sign(v1[2])} +} + +// sign ... +func sign(f float64) float64 { + switch { + case f > 0.0: + return 1.0 + case f < 0.0: + return -1.0 + } + return 0.0 +} diff --git a/server/block/customblock/material.go b/server/block/customblock/material.go new file mode 100644 index 0000000..8b01894 --- /dev/null +++ b/server/block/customblock/material.go @@ -0,0 +1,58 @@ +package customblock + +// Material represents a single material used for rendering part of a custom block. +type Material struct { + // texture is the name of the texture for the material. + texture string + // renderMethod is the method to use when rendering the material. + renderMethod Method + // faceDimming is if the material should be dimmed by the direction it's facing. + faceDimming bool + // ambientOcclusion is if the material should have ambient occlusion applied when lighting. + ambientOcclusion bool +} + +// NewMaterial returns a new Material with the provided information. It enables face dimming by default and ambient +// occlusion based on the render method given. +func NewMaterial(texture string, method Method) Material { + return Material{ + texture: texture, + renderMethod: method, + faceDimming: true, + ambientOcclusion: method.AmbientOcclusion(), + } +} + +// WithFaceDimming returns a copy of the Material with face dimming enabled. +func (m Material) WithFaceDimming() Material { + m.faceDimming = true + return m +} + +// WithoutFaceDimming returns a copy of the Material with face dimming disabled. +func (m Material) WithoutFaceDimming() Material { + m.faceDimming = false + return m +} + +// WithAmbientOcclusion returns a copy of the Material with ambient occlusion enabled. +func (m Material) WithAmbientOcclusion() Material { + m.ambientOcclusion = true + return m +} + +// WithoutAmbientOcclusion returns a copy of the Material with ambient occlusion disabled. +func (m Material) WithoutAmbientOcclusion() Material { + m.ambientOcclusion = false + return m +} + +// Encode returns the material encoded as a map that can be sent over the network to the client. +func (m Material) Encode() map[string]any { + return map[string]any{ + "texture": m.texture, + "render_method": m.renderMethod.String(), + "face_dimming": m.faceDimming, + "ambient_occlusion": m.ambientOcclusion, + } +} diff --git a/server/block/customblock/permutations.go b/server/block/customblock/permutations.go new file mode 100644 index 0000000..ae55bf6 --- /dev/null +++ b/server/block/customblock/permutations.go @@ -0,0 +1,44 @@ +package customblock + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/go-gl/mathgl/mgl64" +) + +// Properties represents the different properties that can be applied to a block or a permutation. +type Properties struct { + // CollisionBox represents the bounding box of the block that the player can collide with. This cannot exceed the + // position of the current block in the world, otherwise it will be cut off at the edge. + CollisionBox cube.BBox + // Cube determines whether the block should inherit the default cube geometry. This will only be considered if the + // Geometry field is empty. + Cube bool + // Geometry represents the geometry identifier that should be used for the block. If you want to use the default + // cube geometry, leave this field empty and set Cube to true. + Geometry string + // MapColour represents the hex colour that should be used for the block on a map. + MapColour string + // Rotation represents the rotation of the block. Rotations are only applied in 90 degree increments, meaning + // 1 = 90 degrees, 2 = 180 degrees, 3 = 270 degrees and 4 = 360 degrees. + Rotation cube.Pos + // Scale is the scale of the block, with 1 being the default scale in all axes. When scaled, the block cannot + // exceed a 30x30x30 pixel area otherwise the client will not render the block. + Scale mgl64.Vec3 + // SelectionBox represents the bounding box of the block that the player can interact with. This cannot exceed the + // position of the current block in the world, otherwise it will be cut off at the edge. + SelectionBox cube.BBox + // Textures define the textures that should be used for the block. The key is the target of the texture, such as + // "*" for all sides, or one of "up", "down", "north", "south", "east", "west" for a specific side. + Textures map[string]Material + // Translation is the translation of the block within itself. When translated, the block cannot exceed a 30x30x30 + // pixel area otherwise the client will not render the block. + Translation mgl64.Vec3 +} + +// Permutation represents a specific permutation for a block that is only applied when the condition is met. +type Permutation struct { + Properties + // Condition is a molang query that is used to determine whether the permutation should be applied. + // Only the latest version of molang is supported. + Condition string +} diff --git a/server/block/customblock/render_method.go b/server/block/customblock/render_method.go new file mode 100644 index 0000000..e003f66 --- /dev/null +++ b/server/block/customblock/render_method.go @@ -0,0 +1,58 @@ +package customblock + +// Method is the method to use when rendering a material for a custom block. +type Method struct { + renderMethod +} + +// OpaqueRenderMethod returns the opaque rendering method for a material. It does not render an alpha layer, meaning it +// does not support transparent or translucent textures, only textures that are fully opaque. +func OpaqueRenderMethod() Method { + return Method{0} +} + +// AlphaTestRenderMethod returns the alpha_test rendering method for a material. It does not allow for translucent +// textures, only textures that are fully opaque or fully transparent, used for blocks such as regular glass. It also +// disables ambient occlusion by default. +func AlphaTestRenderMethod() Method { + return Method{1} +} + +// BlendRenderMethod returns the blend rendering method for a material. It allows for transparent and translucent +// textures, used for blocks such as stained-glass. It also disables ambient occlusion by default. +func BlendRenderMethod() Method { + return Method{2} +} + +// DoubleSidedRenderMethod returns the double_sided rendering method for a material. It is used to completely disable +// backface culling, which would be used for flat faces visible from both sides. +func DoubleSidedRenderMethod() Method { + return Method{3} +} + +type renderMethod uint8 + +// Uint8 returns the render method as a uint8. +func (m renderMethod) Uint8() uint8 { + return uint8(m) +} + +// String ... +func (m renderMethod) String() string { + switch m { + case 0: + return "opaque" + case 1: + return "alpha_test" + case 2: + return "blend" + case 3: + return "double_sided" + } + panic("should never happen") +} + +// AmbientOcclusion returns if ambient occlusion should be enabled by default for a material using this rendering method. +func (m renderMethod) AmbientOcclusion() bool { + return m != 1 && m != 2 +} diff --git a/server/block/deadbush.go b/server/block/deadbush.go new file mode 100644 index 0000000..463566e --- /dev/null +++ b/server/block/deadbush.go @@ -0,0 +1,73 @@ +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" + "math/rand/v2" +) + +// DeadBush is a transparent block in the form of an aesthetic plant. +type DeadBush struct { + empty + replaceable + transparent + sourceWaterDisplacer +} + +// NeighbourUpdateTick ... +func (d DeadBush) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if !supportsVegetation(d, tx.Block(pos.Side(cube.FaceDown))) { + breakBlock(d, pos, tx) + } +} + +// UseOnBlock ... +func (d DeadBush) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, d) + if !used || !supportsVegetation(d, tx.Block(pos.Side(cube.FaceDown))) { + return false + } + + place(tx, pos, d, user, ctx) + return placed(ctx) +} + +// SideClosed ... +func (d DeadBush) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// HasLiquidDrops ... +func (d DeadBush) HasLiquidDrops() bool { + return true +} + +// FlammabilityInfo ... +func (d DeadBush) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(60, 100, true) +} + +// BreakInfo ... +func (d DeadBush) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, func(t item.Tool, enchantments []item.Enchantment) []item.Stack { + if t.ToolType() == item.TypeShears { + return []item.Stack{item.NewStack(d, 1)} + } + if amount := rand.IntN(3); amount != 0 { + return []item.Stack{item.NewStack(item.Stick{}, amount)} + } + return nil + }) +} + +// EncodeItem ... +func (d DeadBush) EncodeItem() (name string, meta int16) { + return "minecraft:deadbush", 0 +} + +// EncodeBlock ... +func (d DeadBush) EncodeBlock() (string, map[string]any) { + return "minecraft:deadbush", nil +} diff --git a/server/block/decorated_pot.go b/server/block/decorated_pot.go new file mode 100644 index 0000000..9ffcc0a --- /dev/null +++ b/server/block/decorated_pot.go @@ -0,0 +1,205 @@ +package block + +import ( + "fmt" + "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/particle" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" +) + +// PotDecoration represents an item that can be used as a decoration on a pot. +type PotDecoration interface { + world.Item + PotDecoration() bool +} + +// DecoratedPot is a decoration block that can be crafted from up to four pottery sherds, and bricks on the sides where +// no pattern should be displayed. +type DecoratedPot struct { + transparent + sourceWaterDisplacer + + // Item is the item being stored in the decorated pot. + Item item.Stack + // Facing is the direction the pot is facing. The first decoration will be facing opposite of this direction. + Facing cube.Direction + // Decorations are the four decorations displayed on the sides of the pot. If a decoration is a brick or nil, + // the side will appear to be empty. + Decorations [4]PotDecoration +} + +// SideClosed ... +func (p DecoratedPot) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// ProjectileHit ... +func (p DecoratedPot) ProjectileHit(pos cube.Pos, tx *world.Tx, _ world.Entity, _ cube.Face) { + for _, d := range p.Decorations { + if d == nil { + dropItem(tx, item.NewStack(item.Brick{}, 1), pos.Vec3Centre()) + continue + } + dropItem(tx, item.NewStack(d, 1), pos.Vec3Centre()) + } + breakBlockNoDrops(p, pos, tx) +} + +// Pick ... +func (p DecoratedPot) Pick() item.Stack { + return item.NewStack(DecoratedPot{Decorations: p.Decorations}, 1) +} + +// ExtractItem ... +func (p DecoratedPot) ExtractItem(h Hopper, pos cube.Pos, tx *world.Tx) bool { + if p.Item.Empty() { + return false + } + if _, err := h.inventory.AddItem(p.Item.Grow(-p.Item.Count() + 1)); err != nil { + return false + } + p.Item = p.Item.Grow(-1) + tx.SetBlock(pos, p, nil) + return true +} + +// InsertItem ... +func (p DecoratedPot) InsertItem(h Hopper, pos cube.Pos, tx *world.Tx) bool { + for sourceSlot, sourceStack := range h.inventory.Slots() { + if !sourceStack.Empty() && sourceStack.Comparable(p.Item) { + if p.Item.Empty() { + p.Item = sourceStack.Grow(-sourceStack.Count() + 1) + } else { + p.Item = p.Item.Grow(1) + } + _ = h.inventory.SetItem(sourceSlot, sourceStack.Grow(-1)) + tx.SetBlock(pos, p, nil) + return true + } + } + return false +} + +// wobble ... +func (p DecoratedPot) wobble(pos cube.Pos, tx *world.Tx, success bool) { + for _, v := range tx.Viewers(pos.Vec3Centre()) { + v.ViewBlockAction(pos, DecoratedPotWobbleAction{DecoratedPot: p, Success: success}) + } + + if success { + tx.AddParticle(pos.Vec3Middle().Add(mgl64.Vec3{0, 1.2}), particle.DustPlume{}) + tx.PlaySound(pos.Vec3Centre(), sound.DecoratedPotInserted{Progress: float64(p.Item.Count()) / float64(p.Item.MaxCount())}) + } else { + tx.PlaySound(pos.Vec3Centre(), sound.DecoratedPotInsertFailed{}) + } +} + +// Activate ... +func (p DecoratedPot) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, ctx *item.UseContext) bool { + held, _ := u.HeldItems() + if held.Empty() || !p.Item.Comparable(held) || p.Item.Count() == p.Item.MaxCount() { + p.wobble(pos, tx, false) + return false + } + + if p.Item.Empty() { + p.Item = held.Grow(-held.Count() + 1) + } else { + p.Item = p.Item.Grow(1) + } + tx.SetBlock(pos, p, nil) + p.wobble(pos, tx, true) + ctx.SubtractFromCount(1) + return true +} + +// BreakInfo ... +func (p DecoratedPot) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(DecoratedPot{Decorations: p.Decorations})).withBreakHandler(func(pos cube.Pos, tx *world.Tx, u item.User) { + if !p.Item.Empty() { + dropItem(tx, p.Item, pos.Vec3Centre()) + } + }) +} + +// EncodeItem ... +func (p DecoratedPot) EncodeItem() (name string, meta int16) { + return "minecraft:decorated_pot", 0 +} + +// EncodeBlock ... +func (p DecoratedPot) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:decorated_pot", map[string]any{"direction": int32(horizontalDirection(p.Facing))} +} + +// Model ... +func (p DecoratedPot) Model() world.BlockModel { + return model.DecoratedPot{} +} + +// UseOnBlock ... +func (p DecoratedPot) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, p) + if !used { + return + } + p.Facing = user.Rotation().Direction().Opposite() + + place(tx, pos, p, user, ctx) + return placed(ctx) +} + +// EncodeNBT ... +func (p DecoratedPot) EncodeNBT() map[string]any { + var sherds []any + for _, decoration := range p.Decorations { + if decoration == nil { + sherds = append(sherds, "minecraft:brick") + } else { + name, _ := decoration.EncodeItem() + sherds = append(sherds, name) + } + } + + m := map[string]any{ + "id": "DecoratedPot", + "sherds": sherds, + } + if !p.Item.Empty() { + m["item"] = nbtconv.WriteItem(p.Item, true) + } + return m +} + +// DecodeNBT ... +func (p DecoratedPot) DecodeNBT(data map[string]any) any { + p.Item = nbtconv.MapItem(data, "item") + p.Decorations = [4]PotDecoration{} + if sherds := nbtconv.Slice(data, "sherds"); sherds != nil { + for i, name := range sherds { + it, ok := world.ItemByName(name.(string), 0) + if !ok { + panic(fmt.Errorf("unknown item %s", name)) + } + decoration, ok := it.(PotDecoration) + if !ok { + panic(fmt.Errorf("item %s is not a pot decoration", name)) + } + p.Decorations[i] = decoration + } + } + return p +} + +// allDecoratedPots ... +func allDecoratedPots() (pots []world.Block) { + for _, f := range cube.Directions() { + pots = append(pots, DecoratedPot{Facing: f}) + } + return +} diff --git a/server/block/deepslate.go b/server/block/deepslate.go new file mode 100644 index 0000000..70ef4ac --- /dev/null +++ b/server/block/deepslate.go @@ -0,0 +1,76 @@ +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" +) + +// Deepslate is similar to stone but is naturally found deep underground around Y0 and below, and is harder to break. +type Deepslate struct { + solid + bassDrum + + // Type is the type of deepslate of the block. + Type DeepslateType + // Axis is the axis which the deepslate faces. + Axis cube.Axis +} + +// BreakInfo ... +func (d Deepslate) BreakInfo() BreakInfo { + if d.Type == NormalDeepslate() { + return newBreakInfo(3, pickaxeHarvestable, pickaxeEffective, silkTouchOneOf(Deepslate{Type: CobbledDeepslate()}, d)).withBlastResistance(30) + } + return newBreakInfo(3.5, pickaxeHarvestable, pickaxeEffective, oneOf(d)).withBlastResistance(30) +} + +// SmeltInfo ... +func (d Deepslate) SmeltInfo() item.SmeltInfo { + if d.Type == CobbledDeepslate() { + return newSmeltInfo(item.NewStack(Deepslate{}, 1), 0.1) + } + return item.SmeltInfo{} +} + +// UseOnBlock ... +func (d Deepslate) 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, d) + if !used { + return + } + if d.Type == NormalDeepslate() { + d.Axis = face.Axis() + } + + place(tx, pos, d, user, ctx) + return placed(ctx) +} + +// EncodeItem ... +func (d Deepslate) EncodeItem() (name string, meta int16) { + return "minecraft:" + d.Type.String(), 0 +} + +// EncodeBlock ... +func (d Deepslate) EncodeBlock() (string, map[string]any) { + if d.Type == NormalDeepslate() { + return "minecraft:deepslate", map[string]any{"pillar_axis": d.Axis.String()} + } + return "minecraft:" + d.Type.String(), nil +} + +// allDeepslate returns a list of all deepslate block variants. +func allDeepslate() (s []world.Block) { + for _, t := range DeepslateTypes() { + axes := []cube.Axis{0} + if t == NormalDeepslate() { + axes = cube.Axes() + } + for _, axis := range axes { + s = append(s, Deepslate{Type: t, Axis: axis}) + } + } + return +} diff --git a/server/block/deepslate_bricks.go b/server/block/deepslate_bricks.go new file mode 100644 index 0000000..08342a2 --- /dev/null +++ b/server/block/deepslate_bricks.go @@ -0,0 +1,41 @@ +package block + +import "github.com/df-mc/dragonfly/server/item" + +// DeepslateBricks are a brick variant of deepslate and can spawn in ancient cities. +type DeepslateBricks struct { + solid + bassDrum + + // Cracked specifies if the deepslate bricks is its cracked variant. + Cracked bool +} + +// BreakInfo ... +func (d DeepslateBricks) BreakInfo() BreakInfo { + return newBreakInfo(3.5, pickaxeHarvestable, pickaxeEffective, oneOf(d)).withBlastResistance(30) +} + +// SmeltInfo ... +func (d DeepslateBricks) SmeltInfo() item.SmeltInfo { + if d.Cracked { + return item.SmeltInfo{} + } + return newSmeltInfo(item.NewStack(DeepslateBricks{Cracked: true}, 1), 0.1) +} + +// EncodeItem ... +func (d DeepslateBricks) EncodeItem() (name string, meta int16) { + if d.Cracked { + return "minecraft:cracked_deepslate_bricks", 0 + } + return "minecraft:deepslate_bricks", 0 +} + +// EncodeBlock ... +func (d DeepslateBricks) EncodeBlock() (string, map[string]any) { + if d.Cracked { + return "minecraft:cracked_deepslate_bricks", nil + } + return "minecraft:deepslate_bricks", nil +} diff --git a/server/block/deepslate_tiles.go b/server/block/deepslate_tiles.go new file mode 100644 index 0000000..ed271d9 --- /dev/null +++ b/server/block/deepslate_tiles.go @@ -0,0 +1,41 @@ +package block + +import "github.com/df-mc/dragonfly/server/item" + +// DeepslateTiles are a tiled variant of deepslate and can spawn in ancient cities. +type DeepslateTiles struct { + solid + bassDrum + + // Cracked specifies if the deepslate tiles is its cracked variant. + Cracked bool +} + +// BreakInfo ... +func (d DeepslateTiles) BreakInfo() BreakInfo { + return newBreakInfo(3.5, pickaxeHarvestable, pickaxeEffective, oneOf(d)).withBlastResistance(30) +} + +// SmeltInfo ... +func (d DeepslateTiles) SmeltInfo() item.SmeltInfo { + if d.Cracked { + return item.SmeltInfo{} + } + return newSmeltInfo(item.NewStack(DeepslateTiles{Cracked: true}, 1), 0.1) +} + +// EncodeItem ... +func (d DeepslateTiles) EncodeItem() (name string, meta int16) { + if d.Cracked { + return "minecraft:cracked_deepslate_tiles", 0 + } + return "minecraft:deepslate_tiles", 0 +} + +// EncodeBlock ... +func (d DeepslateTiles) EncodeBlock() (string, map[string]any) { + if d.Cracked { + return "minecraft:cracked_deepslate_tiles", nil + } + return "minecraft:deepslate_tiles", nil +} diff --git a/server/block/deepslate_type.go b/server/block/deepslate_type.go new file mode 100644 index 0000000..9a57ec2 --- /dev/null +++ b/server/block/deepslate_type.go @@ -0,0 +1,68 @@ +package block + +// DeepslateType represents a type of deepslate. +type DeepslateType struct { + deepslate +} + +type deepslate uint8 + +// NormalDeepslate is the normal variant of deepslate. +func NormalDeepslate() DeepslateType { + return DeepslateType{0} +} + +// CobbledDeepslate is the cobbled variant of deepslate. +func CobbledDeepslate() DeepslateType { + return DeepslateType{1} +} + +// PolishedDeepslate is the polished variant of deepslate. +func PolishedDeepslate() DeepslateType { + return DeepslateType{2} +} + +// ChiseledDeepslate is the chiseled variant of deepslate. +func ChiseledDeepslate() DeepslateType { + return DeepslateType{3} +} + +// Uint8 returns the deepslate type as a uint8. +func (s deepslate) Uint8() uint8 { + return uint8(s) +} + +// Name ... +func (s deepslate) Name() string { + switch s { + case 0: + return "Deepslate" + case 1: + return "Cobbled Deepslate" + case 2: + return "Polished Deepslate" + case 3: + return "Chiseled Deepslate" + } + panic("unknown deepslate type") +} + +// String ... +func (s deepslate) String() string { + switch s { + case 0: + return "deepslate" + case 1: + return "cobbled_deepslate" + case 2: + return "polished_deepslate" + case 3: + return "chiseled_deepslate" + } + panic("unknown deepslate type") +} + +// DeepslateTypes ... +func DeepslateTypes() []DeepslateType { + return []DeepslateType{NormalDeepslate(), CobbledDeepslate(), PolishedDeepslate(), ChiseledDeepslate()} +} diff --git a/server/block/diamond.go b/server/block/diamond.go new file mode 100644 index 0000000..cd89c09 --- /dev/null +++ b/server/block/diamond.go @@ -0,0 +1,32 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" +) + +// Diamond is a block which can only be gained by crafting it. +type Diamond struct { + solid +} + +// BreakInfo ... +func (d Diamond) BreakInfo() BreakInfo { + return newBreakInfo(5, func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierIron.HarvestLevel + }, pickaxeEffective, oneOf(d)).withBlastResistance(30) +} + +// PowersBeacon ... +func (Diamond) PowersBeacon() bool { + return true +} + +// EncodeItem ... +func (Diamond) EncodeItem() (name string, meta int16) { + return "minecraft:diamond_block", 0 +} + +// EncodeBlock ... +func (Diamond) EncodeBlock() (string, map[string]any) { + return "minecraft:diamond_block", nil +} diff --git a/server/block/diamond_ore.go b/server/block/diamond_ore.go new file mode 100644 index 0000000..f48a46e --- /dev/null +++ b/server/block/diamond_ore.go @@ -0,0 +1,36 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" +) + +// DiamondOre is a rare ore that generates underground. +type DiamondOre struct { + solid + bassDrum + + // Type is the type of diamond ore. + Type OreType +} + +// BreakInfo ... +func (d DiamondOre) BreakInfo() BreakInfo { + return newBreakInfo(d.Type.Hardness(), func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierIron.HarvestLevel + }, pickaxeEffective, oreDrops(item.Diamond{}, d)).withXPDropRange(3, 7).withBlastResistance(15) +} + +// SmeltInfo ... +func (DiamondOre) SmeltInfo() item.SmeltInfo { + return newOreSmeltInfo(item.NewStack(item.Diamond{}, 1), 1) +} + +// EncodeItem ... +func (d DiamondOre) EncodeItem() (name string, meta int16) { + return "minecraft:" + d.Type.Prefix() + "diamond_ore", 0 +} + +// EncodeBlock ... +func (d DiamondOre) EncodeBlock() (string, map[string]any) { + return "minecraft:" + d.Type.Prefix() + "diamond_ore", nil +} diff --git a/server/block/dirt.go b/server/block/dirt.go new file mode 100644 index 0000000..4ba7a31 --- /dev/null +++ b/server/block/dirt.go @@ -0,0 +1,72 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/world" +) + +// Dirt is a block found abundantly in most biomes under a layer of grass blocks at the top of the normal +// world. +type Dirt struct { + solid + + // Coarse specifies if the dirt should be off the coarse dirt variant. Grass blocks won't spread on + // the block if set to true. + Coarse bool +} + +// SoilFor ... +func (d Dirt) SoilFor(block world.Block) bool { + switch block.(type) { + case ShortGrass, Fern, DoubleTallGrass, DeadBush: + return !d.Coarse + case Flower, DoubleFlower, NetherSprouts, PinkPetals, SugarCane: + return true + } + return false +} + +// BreakInfo ... +func (d Dirt) BreakInfo() BreakInfo { + return newBreakInfo(0.5, alwaysHarvestable, shovelEffective, oneOf(d)) +} + +// Till ... +func (d Dirt) Till() (world.Block, bool) { + if d.Coarse { + return Dirt{Coarse: false}, true + } + return Farmland{}, true +} + +// Shovel ... +func (d Dirt) Shovel() (world.Block, bool) { + return DirtPath{}, true +} + +// EncodeItem ... +func (d Dirt) EncodeItem() (name string, meta int16) { + if d.Coarse { + return "minecraft:coarse_dirt", 0 + } + return "minecraft:dirt", 0 +} + +// EncodeBlock ... +func (d Dirt) EncodeBlock() (string, map[string]any) { + if d.Coarse { + return "minecraft:coarse_dirt", nil + } + return "minecraft:dirt", nil +} + +// supportsVegetation checks if the vegetation can exist on the block. +func supportsVegetation(vegetation, block world.Block) bool { + soil, ok := block.(Soil) + return ok && soil.SoilFor(vegetation) +} + +// Soil represents a block that can support vegetation. +type Soil interface { + // SoilFor returns whether the vegetation can exist on the block. + SoilFor(world.Block) bool +} diff --git a/server/block/dirt_path.go b/server/block/dirt_path.go new file mode 100644 index 0000000..96b4f9a --- /dev/null +++ b/server/block/dirt_path.go @@ -0,0 +1,41 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// DirtPath is a decorative block that can be created by using a shovel on a dirt or grass block. +type DirtPath struct { + tilledGrass + transparent +} + +// Till ... +func (p DirtPath) Till() (world.Block, bool) { + return Farmland{}, true +} + +// NeighbourUpdateTick handles the turning from dirt path into dirt if a block is placed on top of it. +func (p DirtPath) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + up := pos.Side(cube.FaceUp) + if tx.Block(up).Model().FaceSolid(up, cube.FaceDown, tx) { + // A block with a solid side at the bottom was placed onto this one. + tx.SetBlock(pos, Dirt{}, nil) + } +} + +// BreakInfo ... +func (p DirtPath) BreakInfo() BreakInfo { + return newBreakInfo(0.65, alwaysHarvestable, shovelEffective, silkTouchOneOf(Dirt{}, p)) +} + +// EncodeItem ... +func (DirtPath) EncodeItem() (name string, meta int16) { + return "minecraft:grass_path", 0 +} + +// EncodeBlock ... +func (DirtPath) EncodeBlock() (string, map[string]any) { + return "minecraft:grass_path", nil +} diff --git a/server/block/doc.go b/server/block/doc.go new file mode 100644 index 0000000..555dda8 --- /dev/null +++ b/server/block/doc.go @@ -0,0 +1,5 @@ +// Package block exports implementations of the Block interface found in the server/world package. The blocks +// implemented in this package are automatically registered in the server/world package using the world.RegisterBlock +// and world.RegisterItem functions through an init function, so that methods in server/world that return blocks and +// items are able to return them by the respective types implemented here. +package block diff --git a/server/block/double_flower.go b/server/block/double_flower.go new file mode 100644 index 0000000..00dbc7d --- /dev/null +++ b/server/block/double_flower.go @@ -0,0 +1,89 @@ +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" +) + +// DoubleFlower is a two block high flower consisting of an upper and lower part. +type DoubleFlower struct { + transparent + empty + + // UpperPart is set if the plant is the upper part. + UpperPart bool + // Type is the type of the double plant. + Type DoubleFlowerType +} + +// FlammabilityInfo ... +func (d DoubleFlower) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(60, 100, true) +} + +// BoneMeal ... +func (d DoubleFlower) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { + dropItem(tx, item.NewStack(d, 1), pos.Vec3Centre()) + return item.BoneMealResultSmall +} + +// NeighbourUpdateTick ... +func (d DoubleFlower) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if d.UpperPart { + if bottom, ok := tx.Block(pos.Side(cube.FaceDown)).(DoubleFlower); !ok || bottom.Type != d.Type || bottom.UpperPart { + breakBlockNoDrops(d, pos, tx) + } + } else if upper, ok := tx.Block(pos.Side(cube.FaceUp)).(DoubleFlower); !ok || upper.Type != d.Type || !upper.UpperPart { + breakBlockNoDrops(d, pos, tx) + } else if !supportsVegetation(d, tx.Block(pos.Side(cube.FaceDown))) { + breakBlock(d, pos, tx) + } +} + +// UseOnBlock ... +func (d DoubleFlower) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, d) + if !used || !replaceableWith(tx, pos.Side(cube.FaceUp), d) || !supportsVegetation(d, tx.Block(pos.Side(cube.FaceDown))) { + return false + } + + place(tx, pos, d, user, ctx) + place(tx, pos.Side(cube.FaceUp), DoubleFlower{Type: d.Type, UpperPart: true}, user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (d DoubleFlower) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(d)) +} + +// CompostChance ... +func (DoubleFlower) CompostChance() float64 { + return 0.65 +} + +// HasLiquidDrops ... +func (d DoubleFlower) HasLiquidDrops() bool { + return true +} + +// EncodeItem ... +func (d DoubleFlower) EncodeItem() (name string, meta int16) { + return "minecraft:" + d.Type.String(), 0 +} + +// EncodeBlock ... +func (d DoubleFlower) EncodeBlock() (string, map[string]any) { + return "minecraft:" + d.Type.String(), map[string]any{"upper_block_bit": d.UpperPart} +} + +// allDoubleFlowers ... +func allDoubleFlowers() (b []world.Block) { + for _, d := range DoubleFlowerTypes() { + b = append(b, DoubleFlower{Type: d, UpperPart: true}) + b = append(b, DoubleFlower{Type: d, UpperPart: false}) + } + return +} diff --git a/server/block/double_flower_type.go b/server/block/double_flower_type.go new file mode 100644 index 0000000..6638a55 --- /dev/null +++ b/server/block/double_flower_type.go @@ -0,0 +1,68 @@ +package block + +// DoubleFlowerType represents a type of double flower. +type DoubleFlowerType struct { + doubleFlower +} + +type doubleFlower uint8 + +// Sunflower is a sunflower plant. +func Sunflower() DoubleFlowerType { + return DoubleFlowerType{0} +} + +// Lilac is a lilac plant. +func Lilac() DoubleFlowerType { + return DoubleFlowerType{1} +} + +// RoseBush is a rose bush plant. +func RoseBush() DoubleFlowerType { + return DoubleFlowerType{4} +} + +// Peony is a peony plant. +func Peony() DoubleFlowerType { + return DoubleFlowerType{5} +} + +// Uint8 returns the double plant as a uint8. +func (d doubleFlower) Uint8() uint8 { + return uint8(d) +} + +// Name ... +func (d doubleFlower) Name() string { + switch d { + case 0: + return "Sunflower" + case 1: + return "Lilac" + case 4: + return "Rose Bush" + case 5: + return "Peony" + } + panic("unknown double plant type") +} + +// String ... +func (d doubleFlower) String() string { + switch d { + case 0: + return "sunflower" + case 1: + return "lilac" + case 4: + return "rose_bush" + case 5: + return "peony" + } + panic("unknown double plant type") +} + +// DoubleFlowerTypes ... +func DoubleFlowerTypes() []DoubleFlowerType { + return []DoubleFlowerType{Sunflower(), Lilac(), RoseBush(), Peony()} +} diff --git a/server/block/double_tall_grass.go b/server/block/double_tall_grass.go new file mode 100644 index 0000000..7db5df7 --- /dev/null +++ b/server/block/double_tall_grass.go @@ -0,0 +1,87 @@ +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" +) + +// DoubleTallGrass is a two-block high variety of grass. +type DoubleTallGrass struct { + transparent + replaceable + empty + + // UpperPart is set if the plant is the upper part. + UpperPart bool + // Type is the type of double tall grass. + Type DoubleTallGrassType +} + +// HasLiquidDrops ... +func (d DoubleTallGrass) HasLiquidDrops() bool { + return true +} + +// NeighbourUpdateTick ... +func (d DoubleTallGrass) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if d.UpperPart { + if bottom, ok := tx.Block(pos.Side(cube.FaceDown)).(DoubleTallGrass); !ok || bottom.Type != d.Type || bottom.UpperPart { + breakBlockNoDrops(d, pos, tx) + } + } else if upper, ok := tx.Block(pos.Side(cube.FaceUp)).(DoubleTallGrass); !ok || upper.Type != d.Type || !upper.UpperPart { + breakBlockNoDrops(d, pos, tx) + } else if !supportsVegetation(d, tx.Block(pos.Side(cube.FaceDown))) { + breakBlock(d, pos, tx) + } +} + +// UseOnBlock ... +func (d DoubleTallGrass) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, d) + if !used || !replaceableWith(tx, pos.Side(cube.FaceUp), d) || !supportsVegetation(d, tx.Block(pos.Side(cube.FaceDown))) { + return false + } + + place(tx, pos, d, user, ctx) + place(tx, pos.Side(cube.FaceUp), DoubleTallGrass{Type: d.Type, UpperPart: true}, user, ctx) + return placed(ctx) +} + +// FlammabilityInfo ... +func (d DoubleTallGrass) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(60, 100, true) +} + +// BreakInfo ... +func (d DoubleTallGrass) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, grassDrops(d)) +} + +// CompostChance ... +func (d DoubleTallGrass) CompostChance() float64 { + if d.Type == FernDoubleTallGrass() { + return 0.65 + } + return 0.5 +} + +// EncodeItem ... +func (d DoubleTallGrass) EncodeItem() (name string, meta int16) { + return "minecraft:" + d.Type.String(), 0 +} + +// EncodeBlock ... +func (d DoubleTallGrass) EncodeBlock() (string, map[string]any) { + return "minecraft:" + d.Type.String(), map[string]any{"upper_block_bit": d.UpperPart} +} + +// allDoubleTallGrass ... +func allDoubleTallGrass() (b []world.Block) { + for _, g := range DoubleTallGrassTypes() { + b = append(b, DoubleTallGrass{Type: g}) + b = append(b, DoubleTallGrass{Type: g, UpperPart: true}) + } + return +} diff --git a/server/block/double_tall_grass_type.go b/server/block/double_tall_grass_type.go new file mode 100644 index 0000000..008b9fc --- /dev/null +++ b/server/block/double_tall_grass_type.go @@ -0,0 +1,50 @@ +package block + +// DoubleTallGrassType represents a type of double tall grass, which can be placed on top of grass blocks. +type DoubleTallGrassType struct { + doubleTallGrass +} + +// NormalDoubleTallGrass returns the normal variant of double tall grass. +func NormalDoubleTallGrass() DoubleTallGrassType { + return DoubleTallGrassType{0} +} + +// FernDoubleTallGrass returns the fern variant of double tall grass. +func FernDoubleTallGrass() DoubleTallGrassType { + return DoubleTallGrassType{1} +} + +// DoubleTallGrassTypes returns all variants of double tall grass. +func DoubleTallGrassTypes() []DoubleTallGrassType { + return []DoubleTallGrassType{NormalDoubleTallGrass(), FernDoubleTallGrass()} +} + +type doubleTallGrass uint8 + +// Uint8 ... +func (t doubleTallGrass) Uint8() uint8 { + return uint8(t) +} + +// Name ... +func (t doubleTallGrass) Name() string { + switch t { + case 0: + return "Tall Grass" + case 1: + return "Large Fern" + } + panic("unknown double tall grass type") +} + +// String ... +func (t doubleTallGrass) String() string { + switch t { + case 0: + return "tall_grass" + case 1: + return "large_fern" + } + panic("unknown double tall grass type") +} diff --git a/server/block/dragon_egg.go b/server/block/dragon_egg.go new file mode 100644 index 0000000..71af284 --- /dev/null +++ b/server/block/dragon_egg.go @@ -0,0 +1,76 @@ +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/df-mc/dragonfly/server/world/particle" +) + +// DragonEgg is a decorative block or a "trophy item", and the rarest item in the game. +type DragonEgg struct { + solid + transparent + gravityAffected + sourceWaterDisplacer +} + +// NeighbourUpdateTick ... +func (d DragonEgg) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + d.fall(d, pos, tx) +} + +// SideClosed ... +func (d DragonEgg) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// teleport ... +func (d DragonEgg) teleport(pos cube.Pos, tx *world.Tx) { + for i := 0; i < 1000; i++ { + newPos := pos.Add(cube.Pos{rand.IntN(31) - 15, max(tx.Range()[0]-pos.Y(), min(tx.Range()[1]-pos.Y(), rand.IntN(15)-7)), rand.IntN(31) - 15}) + + if _, ok := tx.Block(newPos).(Air); ok { + tx.SetBlock(newPos, d, nil) + tx.SetBlock(pos, nil, nil) + tx.AddParticle(pos.Vec3(), particle.DragonEggTeleport{Diff: pos.Sub(newPos)}) + return + } + } +} + +// LightEmissionLevel ... +func (d DragonEgg) LightEmissionLevel() uint8 { + return 1 +} + +// Punch ... +func (d DragonEgg) Punch(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User) { + if gm, ok := u.(interface{ GameMode() world.GameMode }); ok && gm.GameMode().CreativeInventory() { + return + } + d.teleport(pos, tx) +} + +// Activate ... +func (d DragonEgg) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, _ item.User, _ *item.UseContext) bool { + d.teleport(pos, tx) + return true +} + +// BreakInfo ... +func (d DragonEgg) BreakInfo() BreakInfo { + return newBreakInfo(3, pickaxeHarvestable, pickaxeEffective, oneOf(d)).withBlastResistance(45) +} + +// EncodeItem ... +func (DragonEgg) EncodeItem() (name string, meta int16) { + return "minecraft:dragon_egg", 0 +} + +// EncodeBlock ... +func (DragonEgg) EncodeBlock() (string, map[string]any) { + return "minecraft:dragon_egg", nil +} diff --git a/server/block/dried_kelp.go b/server/block/dried_kelp.go new file mode 100644 index 0000000..b47c2b1 --- /dev/null +++ b/server/block/dried_kelp.go @@ -0,0 +1,41 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" + "time" +) + +// DriedKelp is a block primarily used as fuel in furnaces. +type DriedKelp struct { + solid +} + +// BreakInfo ... +func (d DriedKelp) BreakInfo() BreakInfo { + return newBreakInfo(0.5, alwaysHarvestable, hoeEffective, oneOf(d)).withBlastResistance(12.5) +} + +// FlammabilityInfo ... +func (DriedKelp) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(30, 5, false) +} + +// FuelInfo ... +func (DriedKelp) FuelInfo() item.FuelInfo { + return newFuelInfo(time.Second * 200) +} + +// CompostChance ... +func (DriedKelp) CompostChance() float64 { + return 0.5 +} + +// EncodeItem ... +func (DriedKelp) EncodeItem() (name string, meta int16) { + return "minecraft:dried_kelp_block", 0 +} + +// EncodeBlock ... +func (DriedKelp) EncodeBlock() (string, map[string]any) { + return "minecraft:dried_kelp_block", nil +} diff --git a/server/block/dripstone.go b/server/block/dripstone.go new file mode 100644 index 0000000..b3bbcb2 --- /dev/null +++ b/server/block/dripstone.go @@ -0,0 +1,22 @@ +package block + +// Dripstone is a rock block that allows pointed dripstone to grow beneath it. +type Dripstone struct { + solid + bassDrum +} + +// BreakInfo ... +func (d Dripstone) BreakInfo() BreakInfo { + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(d)).withBlastResistance(5) +} + +// EncodeItem ... +func (d Dripstone) EncodeItem() (name string, meta int16) { + return "minecraft:dripstone_block", 0 +} + +// EncodeBlock ... +func (d Dripstone) EncodeBlock() (string, map[string]any) { + return "minecraft:dripstone_block", nil +} diff --git a/server/block/emerald.go b/server/block/emerald.go new file mode 100644 index 0000000..bebdcb9 --- /dev/null +++ b/server/block/emerald.go @@ -0,0 +1,38 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world/sound" +) + +// Emerald is a precious mineral block crafted using 9 emeralds. +type Emerald struct { + solid +} + +// Instrument ... +func (e Emerald) Instrument() sound.Instrument { + return sound.Bit() +} + +// BreakInfo ... +func (e Emerald) BreakInfo() BreakInfo { + return newBreakInfo(5, func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierIron.HarvestLevel + }, pickaxeEffective, oneOf(e)).withBlastResistance(30) +} + +// PowersBeacon ... +func (Emerald) PowersBeacon() bool { + return true +} + +// EncodeItem ... +func (Emerald) EncodeItem() (name string, meta int16) { + return "minecraft:emerald_block", 0 +} + +// EncodeBlock ... +func (Emerald) EncodeBlock() (string, map[string]any) { + return "minecraft:emerald_block", nil +} diff --git a/server/block/emerald_ore.go b/server/block/emerald_ore.go new file mode 100644 index 0000000..dea7f38 --- /dev/null +++ b/server/block/emerald_ore.go @@ -0,0 +1,40 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" +) + +// EmeraldOre is an ore generating exclusively under mountain biomes. +type EmeraldOre struct { + solid + bassDrum + + // Type is the type of emerald ore. + Type OreType +} + +// BreakInfo ... +func (e EmeraldOre) BreakInfo() BreakInfo { + i := newBreakInfo(e.Type.Hardness(), func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierIron.HarvestLevel + }, pickaxeEffective, oreDrops(item.Emerald{}, e)).withXPDropRange(3, 7) + if e.Type == DeepslateOre() { + i = i.withBlastResistance(15) + } + return i +} + +// SmeltInfo ... +func (EmeraldOre) SmeltInfo() item.SmeltInfo { + return newOreSmeltInfo(item.NewStack(item.Emerald{}, 1), 1) +} + +// EncodeItem ... +func (e EmeraldOre) EncodeItem() (name string, meta int16) { + return "minecraft:" + e.Type.Prefix() + "emerald_ore", 0 +} + +// EncodeBlock ... +func (e EmeraldOre) EncodeBlock() (string, map[string]any) { + return "minecraft:" + e.Type.Prefix() + "emerald_ore", nil +} diff --git a/server/block/enchanting_table.go b/server/block/enchanting_table.go new file mode 100644 index 0000000..dbba12f --- /dev/null +++ b/server/block/enchanting_table.go @@ -0,0 +1,66 @@ +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/item" + "github.com/df-mc/dragonfly/server/world" +) + +// EnchantingTable is a block that allows players to spend their experience point levels to enchant tools, weapons, +// books, armour, and certain other items. +type EnchantingTable struct { + transparent + bassDrum + sourceWaterDisplacer +} + +// Model ... +func (e EnchantingTable) Model() world.BlockModel { + return model.EnchantingTable{} +} + +// BreakInfo ... +func (e EnchantingTable) BreakInfo() BreakInfo { + return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(e)).withBlastResistance(6000) +} + +// SideClosed ... +func (EnchantingTable) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// LightEmissionLevel ... +func (EnchantingTable) LightEmissionLevel() uint8 { + return 7 +} + +// Activate ... +func (EnchantingTable) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + if opener, ok := u.(ContainerOpener); ok { + opener.OpenBlockContainer(pos, tx) + return true + } + return false +} + +// EncodeItem ... +func (EnchantingTable) EncodeItem() (name string, meta int16) { + return "minecraft:enchanting_table", 0 +} + +// EncodeBlock ... +func (EnchantingTable) EncodeBlock() (string, map[string]any) { + return "minecraft:enchanting_table", nil +} + +// EncodeNBT is used to encode the block to NBT, so that the enchanting table book will be rendered properly client-side. +// The actual rotation value doesn't need to be set in the NBT, we just need to write the default NBT for the block. +func (e EnchantingTable) EncodeNBT() map[string]any { + return map[string]any{"id": "EnchantTable"} +} + +// DecodeNBT is used to implement world.NBTer. +func (e EnchantingTable) DecodeNBT(map[string]any) any { + return e +} diff --git a/server/block/end_bricks.go b/server/block/end_bricks.go new file mode 100644 index 0000000..c5b8474 --- /dev/null +++ b/server/block/end_bricks.go @@ -0,0 +1,22 @@ +package block + +// EndBricks is a block made from combining four endstone blocks together. +type EndBricks struct { + solid + bassDrum +} + +// BreakInfo ... +func (e EndBricks) BreakInfo() BreakInfo { + return newBreakInfo(3, pickaxeHarvestable, pickaxeEffective, oneOf(e)).withBlastResistance(45) +} + +// EncodeItem ... +func (EndBricks) EncodeItem() (name string, meta int16) { + return "minecraft:end_bricks", 0 +} + +// EncodeBlock ... +func (EndBricks) EncodeBlock() (string, map[string]any) { + return "minecraft:end_bricks", nil +} diff --git a/server/block/end_rod.go b/server/block/end_rod.go new file mode 100644 index 0000000..cbb3673 --- /dev/null +++ b/server/block/end_rod.go @@ -0,0 +1,76 @@ +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/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// EndRod is a decorative light source that emits white particles. +type EndRod struct { + transparent + flowingWaterDisplacer + + // Facing is the direction the white end point of the end rod is facing. + Facing cube.Face +} + +// UseOnBlock ... +func (e EndRod) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, face, used := firstReplaceable(tx, pos, face, e) + if !used { + return false + } + + e.Facing = face + if other, ok := tx.Block(pos.Side(face.Opposite())).(EndRod); ok { + if face == other.Facing { + e.Facing = face.Opposite() + } + } + place(tx, pos, e, user, ctx) + return placed(ctx) +} + +// SideClosed ... +func (EndRod) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// Model ... +func (e EndRod) Model() world.BlockModel { + return model.EndRod{Axis: e.Facing.Axis()} +} + +// LightEmissionLevel ... +func (EndRod) LightEmissionLevel() uint8 { + return 14 +} + +// BreakInfo ... +func (e EndRod) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(e)) +} + +// EncodeItem ... +func (EndRod) EncodeItem() (name string, meta int16) { + return "minecraft:end_rod", 0 +} + +// EncodeBlock ... +func (e EndRod) EncodeBlock() (string, map[string]any) { + if e.Facing.Axis() == cube.Y { + return "minecraft:end_rod", map[string]any{"facing_direction": int32(e.Facing)} + } + return "minecraft:end_rod", map[string]any{"facing_direction": int32(e.Facing.Opposite())} +} + +// allEndRods ... +func allEndRods() (b []world.Block) { + for _, f := range cube.Faces() { + b = append(b, EndRod{Facing: f}) + } + return +} diff --git a/server/block/end_stone.go b/server/block/end_stone.go new file mode 100644 index 0000000..98224ed --- /dev/null +++ b/server/block/end_stone.go @@ -0,0 +1,22 @@ +package block + +// EndStone is a block found in The End. +type EndStone struct { + solid + bassDrum +} + +// BreakInfo ... +func (e EndStone) BreakInfo() BreakInfo { + return newBreakInfo(3, pickaxeHarvestable, pickaxeEffective, oneOf(e)).withBlastResistance(45) +} + +// EncodeItem ... +func (EndStone) EncodeItem() (name string, meta int16) { + return "minecraft:end_stone", 0 +} + +// EncodeBlock ... +func (EndStone) EncodeBlock() (string, map[string]any) { + return "minecraft:end_stone", nil +} diff --git a/server/block/ender_chest.go b/server/block/ender_chest.go new file mode 100644 index 0000000..054b075 --- /dev/null +++ b/server/block/ender_chest.go @@ -0,0 +1,137 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/inventory" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" + "sync/atomic" +) + +// enderChestOwner represents an entity that has an ender chest inventory. +type enderChestOwner interface { + ContainerOpener + EnderChestInventory() *inventory.Inventory +} + +// EnderChest is a type of chest whose contents are exclusive to each player, and can be accessed from anywhere. +// The empty value of EnderChest is not valid. It must be created using block.NewEnderChest(). +type EnderChest struct { + chest + transparent + bass + sourceWaterDisplacer + + // Facing is the direction that the ender chest is facing. + Facing cube.Direction + + viewers *atomic.Int64 +} + +// NewEnderChest creates a new initialised ender chest. +func NewEnderChest() EnderChest { + return EnderChest{viewers: &atomic.Int64{}} +} + +// BreakInfo ... +func (c EnderChest) BreakInfo() BreakInfo { + return newBreakInfo(22.5, pickaxeHarvestable, pickaxeEffective, silkTouchDrop(item.NewStack(Obsidian{}, 8), item.NewStack(NewEnderChest(), 1))).withBlastResistance(3000) +} + +// LightEmissionLevel ... +func (c EnderChest) LightEmissionLevel() uint8 { + return 7 +} + +// SideClosed ... +func (EnderChest) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// UseOnBlock ... +func (c EnderChest) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, c) + if !used { + return + } + //noinspection GoAssignmentToReceiver + c = NewEnderChest() + c.Facing = user.Rotation().Direction().Opposite() + + place(tx, pos, c, user, ctx) + return placed(ctx) +} + +// Activate ... +func (c EnderChest) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + if opener, ok := u.(enderChestOwner); ok { + opener.OpenBlockContainer(pos, tx) + return true + } + return false +} + +// AddViewer ... +func (c EnderChest) AddViewer(tx *world.Tx, pos cube.Pos) { + if c.viewers.Add(1) == 1 { + c.open(tx, pos) + } +} + +// RemoveViewer ... +func (c EnderChest) RemoveViewer(tx *world.Tx, pos cube.Pos) { + if c.viewers.Load() == 0 { + return + } + if c.viewers.Add(-1) == 0 { + c.close(tx, pos) + } +} + +// open opens the ender chest, displaying the animation and playing a sound. +func (c EnderChest) open(tx *world.Tx, pos cube.Pos) { + for _, v := range tx.Viewers(pos.Vec3()) { + v.ViewBlockAction(pos, OpenAction{}) + } + tx.PlaySound(pos.Vec3Centre(), sound.EnderChestOpen{}) +} + +// close closes the ender chest, displaying the animation and playing a sound. +func (c EnderChest) close(tx *world.Tx, pos cube.Pos) { + for _, v := range tx.Viewers(pos.Vec3()) { + v.ViewBlockAction(pos, CloseAction{}) + } + tx.PlaySound(pos.Vec3Centre(), sound.EnderChestClose{}) +} + +// EncodeNBT ... +func (c EnderChest) EncodeNBT() map[string]interface{} { + return map[string]interface{}{"id": "EnderChest"} +} + +// DecodeNBT ... +func (c EnderChest) DecodeNBT(map[string]interface{}) interface{} { + ec := NewEnderChest() + ec.Facing = c.Facing + return ec +} + +// EncodeItem ... +func (EnderChest) EncodeItem() (name string, meta int16) { + return "minecraft:ender_chest", 0 +} + +// EncodeBlock ... +func (c EnderChest) EncodeBlock() (name string, properties map[string]interface{}) { + return "minecraft:ender_chest", map[string]any{"minecraft:cardinal_direction": c.Facing.String()} +} + +// allEnderChests ... +func allEnderChests() (chests []world.Block) { + for _, direction := range cube.Directions() { + chests = append(chests, EnderChest{Facing: direction}) + } + return +} diff --git a/server/block/explosion.go b/server/block/explosion.go new file mode 100644 index 0000000..660549f --- /dev/null +++ b/server/block/explosion.go @@ -0,0 +1,228 @@ +package block + +import ( + "math" + "math/rand/v2" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/block/cube/trace" + "github.com/df-mc/dragonfly/server/event" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/particle" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" +) + +// ExplosionConfig is the configuration for an explosion. The world, position, size, sound, particle, and more can all +// be configured through this configuration. +type ExplosionConfig struct { + // Size is the size of the explosion, it is effectively the radius which entities/blocks will be affected within. + Size float64 + // RandSource is the source to use for the explosion "randomness". If set + // to nil, RandSource defaults to a `rand.PCG`source seeded with + // `time.Now().UnixNano()`. + RandSource rand.Source + // SpawnFire will cause the explosion to randomly start fires in 1/3 of all destroyed air blocks that are + // above opaque blocks. + SpawnFire bool + // ItemDropChance specifies how item drops should be handled. By default, + // the item drop chance is 1/Size. If negative, no items will be dropped by + // the explosion. If set to 1 or higher, all items are dropped. + ItemDropChance float64 + + // Sound is the sound to play when the explosion is created. If set to nil, this will default to the sound of a + // regular explosion. + Sound world.Sound + // Particle is the particle to spawn when the explosion is created. If set to nil, this will default to the particle + // of a regular huge explosion. + Particle world.Particle +} + +// ExplodableEntity represents an entity that can be exploded. +type ExplodableEntity interface { + // Explode is called when an explosion occurs. The entity can then react to the explosion using the configuration + // and impact provided. + Explode(explosionPos mgl64.Vec3, impact float64, c ExplosionConfig) +} + +// Explodable represents a block that can be exploded. +type Explodable interface { + // Explode is called when an explosion occurs. The block can react to the explosion using the configuration passed. + Explode(explosionPos mgl64.Vec3, pos cube.Pos, tx *world.Tx, c ExplosionConfig) +} + +// rays ... +var rays = make([]mgl64.Vec3, 0, 1352) + +// init ... +func init() { + for x := 0.0; x < 16; x++ { + for y := 0.0; y < 16; y++ { + for z := 0.0; z < 16; z++ { + if x != 0 && x != 15 && y != 0 && y != 15 && z != 0 && z != 15 { + continue + } + rays = append(rays, mgl64.Vec3{x/15*2 - 1, y/15*2 - 1, z/15*2 - 1}.Normalize().Mul(0.3)) + } + } + } +} + +// Explode performs the explosion as specified by the configuration. +func (c ExplosionConfig) Explode(tx *world.Tx, explosionPos mgl64.Vec3) { + if c.Sound == nil { + c.Sound = sound.Explosion{} + } + if c.Particle == nil { + c.Particle = particle.HugeExplosion{} + } + if c.RandSource == nil { + t := uint64(time.Now().UnixNano()) + c.RandSource = rand.NewPCG(t, t) + } + if c.Size == 0 { + c.Size = 4 + } + if c.ItemDropChance == 0 { + c.ItemDropChance = 1.0 / c.Size + } + + r, d := rand.New(c.RandSource), c.Size*2 + box := cube.Box( + math.Floor(explosionPos[0]-d-1), + math.Floor(explosionPos[1]-d-1), + math.Floor(explosionPos[2]-d-1), + math.Ceil(explosionPos[0]+d+1), + math.Ceil(explosionPos[1]+d+1), + math.Ceil(explosionPos[2]+d+1), + ) + + affectedEntities := make([]world.Entity, 0, 32) + for e := range tx.EntitiesWithin(box.Grow(2)) { + pos := e.Position() + dist := pos.Sub(explosionPos).Len() + if dist > d || dist == 0 { + continue + } + + affectedEntities = append(affectedEntities, e) + } + + affectedBlocks := make([]cube.Pos, 0, 32) + for _, ray := range rays { + pos := explosionPos + for blastForce := c.Size * (0.7 + r.Float64()*0.6); blastForce > 0.0; blastForce -= 0.225 { + current := cube.PosFromVec3(pos) + currentBlock := tx.Block(current) + + resistance := 0.0 + if l, ok := tx.Liquid(current); ok { + resistance = l.BlastResistance() + } else if i, ok := currentBlock.(Breakable); ok { + resistance = i.BreakInfo().BlastResistance + } else if _, ok = currentBlock.(Air); !ok { + // Completely stop the ray if the current block is not air and unbreakable. + break + } + + pos = pos.Add(ray) + if blastForce -= (resistance/5 + 0.3) * 0.3; blastForce > 0 { + affectedBlocks = append(affectedBlocks, current) + } + } + } + + ctx := event.C(tx) + spawnFire := c.SpawnFire + itemDropChance := c.ItemDropChance + if tx.World().Handler().HandleExplosion(ctx, explosionPos, &affectedEntities, &affectedBlocks, &itemDropChance, &spawnFire); ctx.Cancelled() { + return + } + + for _, e := range affectedEntities { + if explodable, ok := e.(ExplodableEntity); ok { + impact := (1 - e.Position().Sub(explosionPos).Len()/d) * exposure(tx, explosionPos, e) + explodable.Explode(explosionPos, impact, c) + } + } + + for _, pos := range affectedBlocks { + bl := tx.Block(pos) + if explodable, ok := bl.(Explodable); ok { + explodable.Explode(explosionPos, pos, tx, c) + } else if breakable, ok := bl.(Breakable); ok { + // Clear the block first so break handlers see the post-break world, this is required by things such as redstone updates. + tx.SetBlock(pos, nil, nil) + breakHandler := breakable.BreakInfo().BreakHandler + if breakHandler != nil { + breakHandler(pos, tx, nil) + } + if itemDropChance > r.Float64() { + for _, drop := range breakable.BreakInfo().Drops(item.ToolNone{}, nil) { + dropItem(tx, drop, pos.Vec3Centre()) + } + } + } + } + + if spawnFire { + for _, pos := range affectedBlocks { + if r.IntN(3) == 0 { + if _, ok := tx.Block(pos).(Air); ok && tx.Block(pos.Side(cube.FaceDown)).Model().FaceSolid(pos, cube.FaceUp, tx) { + Fire{}.Start(tx, pos) + } + } + } + } + + tx.AddParticle(explosionPos, c.Particle) + tx.PlaySound(explosionPos, c.Sound) +} + +// exposure returns the exposure of an explosion to an entity, used to calculate the impact of an explosion. +func exposure(tx *world.Tx, origin mgl64.Vec3, e world.Entity) float64 { + pos := e.Position() + box := e.H().Type().BBox(e).Translate(pos) + + boxMin, boxMax := box.Min(), box.Max() + diff := boxMax.Sub(boxMin).Mul(2.0).Add(mgl64.Vec3{1, 1, 1}) + + step := mgl64.Vec3{1.0 / diff[0], 1.0 / diff[1], 1.0 / diff[2]} + if step[0] < 0.0 || step[1] < 0.0 || step[2] < 0.0 { + return 0.0 + } + + xOffset := (1.0 - math.Floor(diff[0])/diff[0]) / 2.0 + zOffset := (1.0 - math.Floor(diff[2])/diff[2]) / 2.0 + + var checks, misses float64 + for x := 0.0; x <= 1.0; x += step[0] { + for y := 0.0; y <= 1.0; y += step[1] { + for z := 0.0; z <= 1.0; z += step[2] { + point := mgl64.Vec3{ + lerp(x, boxMin[0], boxMax[0]) + xOffset, + lerp(y, boxMin[1], boxMax[1]), + lerp(z, boxMin[2], boxMax[2]) + zOffset, + } + var collided bool + trace.TraverseBlocks(origin, point, func(pos cube.Pos) (cont bool) { + _, collided = trace.BlockIntercept(pos, tx, tx.Block(pos), origin, point) + return !collided + }) + + if !collided { + misses++ + } + checks++ + } + } + } + return misses / checks +} + +// lerp returns the linear interpolation between a and b at t. +func lerp(a, b, t float64) float64 { + return b + a*(t-b) +} diff --git a/server/block/farmland.go b/server/block/farmland.go new file mode 100644 index 0000000..d308785 --- /dev/null +++ b/server/block/farmland.go @@ -0,0 +1,114 @@ +package block + +import ( + "math/rand/v2" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/event" + "github.com/df-mc/dragonfly/server/world" +) + +// Farmland is a block that crops are grown on. Farmland is created by interacting with a grass or dirt block using a +// hoe. Farmland can be hydrated by nearby water, with no hydration resulting in it turning into a dirt block. +type Farmland struct { + tilledGrass + + // Hydration is how much moisture the farmland block has. Hydration starts at 0 & caps at 7. During a random tick + // update, if there is water within 4 blocks from the farmland block, hydration is set to 7. Otherwise, it + // decrements until it turns into dirt. + Hydration int +} + +// SoilFor ... +func (f Farmland) SoilFor(block world.Block) bool { + switch block.(type) { + case ShortGrass, Fern, DoubleTallGrass, Flower, DoubleFlower, NetherSprouts, PinkPetals, DeadBush: + return true + } + return false +} + +// NeighbourUpdateTick ... +func (f Farmland) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if solid := tx.Block(pos.Side(cube.FaceUp)).Model().FaceSolid(pos.Side(cube.FaceUp), cube.FaceDown, tx); solid { + tx.SetBlock(pos, Dirt{}, nil) + } +} + +// RandomTick ... +func (f Farmland) RandomTick(pos cube.Pos, tx *world.Tx, _ *rand.Rand) { + if !f.hydrated(pos, tx) { + if f.Hydration > 0 { + f.Hydration-- + tx.SetBlock(pos, f, nil) + } else { + blockAbove := tx.Block(pos.Side(cube.FaceUp)) + if _, cropAbove := blockAbove.(Crop); !cropAbove { + tx.SetBlock(pos, Dirt{}, nil) + } + } + } else { + f.Hydration = 7 + tx.SetBlock(pos, f, nil) + } +} + +// hydrated checks for water within 4 blocks in each direction from the farmland. +func (f Farmland) hydrated(pos cube.Pos, tx *world.Tx) bool { + posX, posY, posZ := pos.X(), pos.Y(), pos.Z() + for y := 0; y <= 1; y++ { + for x := -4; x <= 4; x++ { + for z := -4; z <= 4; z++ { + if liquid, ok := tx.Liquid(cube.Pos{posX + x, posY + y, posZ + z}); ok { + if _, ok := liquid.(Water); ok { + return true + } + } + } + } + } + return false +} + +// EntityLand ... +func (f Farmland) EntityLand(pos cube.Pos, tx *world.Tx, e world.Entity, _ *float64) { + if living, ok := e.(livingEntity); ok { + if fall, ok := living.(fallDistanceEntity); ok && rand.Float64() < fall.FallDistance()-0.5 { + ctx := event.C(tx) + if tx.World().Handler().HandleCropTrample(ctx, pos); !ctx.Cancelled() { + tx.SetBlock(pos, Dirt{}, nil) + } + } + } +} + +// fallDistanceEntity is an entity that has a fall distance. +type fallDistanceEntity interface { + // ResetFallDistance resets the entities fall distance. + ResetFallDistance() + // FallDistance returns the entities fall distance. + FallDistance() float64 +} + +// BreakInfo ... +func (f Farmland) BreakInfo() BreakInfo { + return newBreakInfo(0.6, alwaysHarvestable, shovelEffective, oneOf(Dirt{})) +} + +// EncodeBlock ... +func (f Farmland) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:farmland", map[string]any{"moisturized_amount": int32(f.Hydration)} +} + +// EncodeItem ... +func (f Farmland) EncodeItem() (name string, meta int16) { + return "minecraft:farmland", 0 +} + +// allFarmland returns all possible states that a block of farmland can be in. +func allFarmland() (b []world.Block) { + for i := 0; i <= 7; i++ { + b = append(b, Farmland{Hydration: i}) + } + return +} diff --git a/server/block/fern.go b/server/block/fern.go new file mode 100644 index 0000000..88479a5 --- /dev/null +++ b/server/block/fern.go @@ -0,0 +1,74 @@ +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" +) + +// Fern is a transparent plant block which can be used to obtain seeds and as decoration. +type Fern struct { + replaceable + transparent + empty +} + +// FlammabilityInfo ... +func (g Fern) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(60, 100, false) +} + +// BreakInfo ... +func (g Fern) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, grassDrops(g)) +} + +// BoneMeal attempts to affect the block using a bone meal item. +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 item.BoneMealResultSmall + } + return item.BoneMealResultNone +} + +// CompostChance ... +func (g Fern) CompostChance() float64 { + return 0.3 +} + +// NeighbourUpdateTick ... +func (g Fern) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if !supportsVegetation(g, tx.Block(pos.Side(cube.FaceDown))) { + breakBlock(g, pos, tx) + } +} + +// HasLiquidDrops ... +func (g Fern) HasLiquidDrops() bool { + return true +} + +// UseOnBlock ... +func (g Fern) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, g) + if !used || !supportsVegetation(g, tx.Block(pos.Side(cube.FaceDown))) { + return false + } + + place(tx, pos, g, user, ctx) + return placed(ctx) +} + +// EncodeItem ... +func (g Fern) EncodeItem() (name string, meta int16) { + return "minecraft:fern", 0 +} + +// EncodeBlock ... +func (g Fern) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:fern", nil +} diff --git a/server/block/fire.go b/server/block/fire.go new file mode 100644 index 0000000..59a6052 --- /dev/null +++ b/server/block/fire.go @@ -0,0 +1,293 @@ +package block + +//lint:file-ignore ST1022 Exported variables in this package have compiler directives. These variables are not otherwise exposed to users. + +import ( + "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" +) + +// Fire is a non-solid block that can spread to nearby flammable blocks. +type Fire struct { + replaceable + transparent + empty + + // Type is the type of fire. + Type FireType + // Age affects how fire extinguishes. Newly placed fire starts at 0 and the value has a 1/3 chance of incrementing + // each block tick. + Age int +} + +// flammableBlock returns true if a block is flammable. +func flammableBlock(block world.Block) bool { + flammable, ok := block.(Flammable) + return ok && flammable.FlammabilityInfo().Encouragement > 0 +} + +// neighboursFlammable returns true if one a block adjacent to the passed position is flammable. +func neighboursFlammable(pos cube.Pos, tx *world.Tx) bool { + for _, i := range cube.Faces() { + if flammableBlock(tx.Block(pos.Side(i))) { + return true + } + } + return false +} + +// infinitelyBurning returns true if fire can infinitely burn at the specified position. +func infinitelyBurning(pos cube.Pos, tx *world.Tx) bool { + switch block := tx.Block(pos.Side(cube.FaceDown)).(type) { + // TODO: Magma Block + case Netherrack: + return true + case Bedrock: + return block.InfiniteBurning + } + return false +} + +// burn attempts to burn a block. +func (f Fire) burn(from, to cube.Pos, tx *world.Tx, r *rand.Rand, chanceBound int) { + if flammable, ok := tx.Block(to).(Flammable); ok && r.IntN(chanceBound) < flammable.FlammabilityInfo().Flammability { + if t, ok := flammable.(TNT); ok { + t.Ignite(to, tx, nil) + return + } + if r.IntN(f.Age+10) < 5 && !rainingAround(to, tx) { + f.spread(from, to, tx, r) + return + } + tx.SetBlock(to, nil, nil) + } +} + +// rainingAround checks if it is raining either at the cube.Pos passed or at any of its horizontal neighbours. +func rainingAround(pos cube.Pos, tx *world.Tx) bool { + raining := tx.RainingAt(pos) + for _, face := range cube.HorizontalFaces() { + if raining { + break + } + raining = tx.RainingAt(pos.Side(face)) + } + return raining +} + +// tick ... +func (f Fire) tick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + if f.Type == SoulFire() { + return + } + infinitelyBurns := infinitelyBurning(pos, tx) + if !infinitelyBurns && (20+f.Age*3) > r.IntN(100) && rainingAround(pos, tx) { + // Fire is extinguished by the rain. + tx.SetBlock(pos, nil, nil) + return + } + + if f.Age < 15 && r.IntN(3) == 0 { + f.Age++ + tx.SetBlock(pos, f, nil) + } + + tx.ScheduleBlockUpdate(pos, f, time.Duration(30+r.IntN(10))*time.Second/20) + + if !infinitelyBurns { + _, waterBelow := tx.Block(pos.Side(cube.FaceDown)).(Water) + if waterBelow { + tx.SetBlock(pos, nil, nil) + return + } + if !neighboursFlammable(pos, tx) { + if !tx.Block(pos.Side(cube.FaceDown)).Model().FaceSolid(pos, cube.FaceUp, tx) || f.Age > 3 { + tx.SetBlock(pos, nil, nil) + } + return + } + if !flammableBlock(tx.Block(pos.Side(cube.FaceDown))) && f.Age == 15 && r.IntN(4) == 0 { + tx.SetBlock(pos, nil, nil) + return + } + } + + humid := tx.Biome(pos).Rainfall() > 0.85 + + s := 0 + if humid { + s = 50 + } + for _, face := range cube.Faces() { + if face == cube.FaceUp || face == cube.FaceDown { + f.burn(pos, pos.Side(face), tx, r, 300-s) + } else { + f.burn(pos, pos.Side(face), tx, r, 250-s) + } + } + + for y := -1; y <= 4; y++ { + randomBound := 100 + if y > 1 { + randomBound += (y - 1) * 100 + } + + for x := -1; x <= 1; x++ { + for z := -1; z <= 1; z++ { + if x == 0 && y == 0 && z == 0 { + continue + } + blockPos := pos.Add(cube.Pos{x, y, z}) + block := tx.Block(blockPos) + if _, ok := block.(Air); !ok { + continue + } + + encouragement := 0 + blockPos.Neighbours(func(neighbour cube.Pos) { + if flammable, ok := tx.Block(neighbour).(Flammable); ok { + encouragement = max(encouragement, flammable.FlammabilityInfo().Encouragement) + } + }, tx.Range()) + if encouragement <= 0 { + continue + } + + maxChance := (encouragement + 40 + tx.World().Difficulty().FireSpreadIncrease()) / (f.Age + 30) + if humid { + maxChance /= 2 + } + + if maxChance > 0 && r.IntN(randomBound) <= maxChance && !rainingAround(blockPos, tx) { + f.spread(pos, blockPos, tx, r) + } + } + } + } +} + +// spread attempts to spread fire from a cube.Pos to another. If the block burn or fire spreading events are cancelled, +// this might end up not happening. +func (f Fire) spread(from, to cube.Pos, tx *world.Tx, r *rand.Rand) { + if _, air := tx.Block(to).(Air); !air { + ctx := event.C(tx) + if tx.World().Handler().HandleBlockBurn(ctx, to); ctx.Cancelled() { + return + } + } + ctx := event.C(tx) + if tx.World().Handler().HandleFireSpread(ctx, from, to); ctx.Cancelled() { + return + } + spread := Fire{Type: f.Type, Age: min(15, f.Age+r.IntN(5)/4)} + tx.SetBlock(to, spread, nil) + tx.ScheduleBlockUpdate(to, spread, time.Duration(30+r.IntN(10))*time.Second/20) +} + +// EntityInside ... +func (f Fire) EntityInside(_ cube.Pos, _ *world.Tx, e world.Entity) { + if flammable, ok := e.(flammableEntity); ok { + if l, ok := e.(livingEntity); ok { + l.Hurt(f.Type.Damage(), FireDamageSource{}) + } + if flammable.OnFireDuration() < time.Second*8 { + flammable.SetOnFire(8 * time.Second) + } + } +} + +// ScheduledTick ... +func (f Fire) ScheduledTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + f.tick(pos, tx, r) +} + +// RandomTick ... +func (f Fire) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + f.tick(pos, tx, r) +} + +// NeighbourUpdateTick ... +func (f Fire) NeighbourUpdateTick(pos, changedNeighbour cube.Pos, tx *world.Tx) { + below := tx.Block(pos.Side(cube.FaceDown)) + if diffuser, ok := below.(LightDiffuser); (ok && diffuser.LightDiffusionLevel() != 15) && (!neighboursFlammable(pos, tx) || f.Type == SoulFire()) { + tx.SetBlock(pos, nil, nil) + return + } + switch below.(type) { + case SoulSand, SoulSoil: + f.Type = SoulFire() + tx.SetBlock(pos, f, nil) + case Water: + if changedNeighbour == pos { + tx.SetBlock(pos, nil, nil) + } + default: + if f.Type == SoulFire() { + tx.SetBlock(pos, nil, nil) + return + } + } +} + +// HasLiquidDrops ... +func (f Fire) HasLiquidDrops() bool { + return false +} + +// LightEmissionLevel ... +func (f Fire) LightEmissionLevel() uint8 { + return f.Type.LightLevel() +} + +// EncodeBlock ... +func (f Fire) EncodeBlock() (name string, properties map[string]any) { + switch f.Type { + case NormalFire(): + return "minecraft:fire", map[string]any{"age": int32(f.Age)} + case SoulFire(): + return "minecraft:soul_fire", map[string]any{"age": int32(f.Age)} + } + panic("unknown fire type") +} + +// Start starts a fire at a position in the world. The position passed must be either air or tall grass and conditions +// for a fire to be present must be present. +func (f Fire) Start(tx *world.Tx, pos cube.Pos) { + b := tx.Block(pos) + _, air := b.(Air) + _, shortGrass := b.(ShortGrass) + _, fern := b.(Fern) + if air || shortGrass || fern { + below := tx.Block(pos.Side(cube.FaceDown)) + if below.Model().FaceSolid(pos, cube.FaceUp, tx) || neighboursFlammable(pos, tx) { + f := Fire{} + tx.SetBlock(pos, f, nil) + tx.ScheduleBlockUpdate(pos, f, time.Duration(30+rand.IntN(10))*time.Second/20) + } + } +} + +// allFire ... +func allFire() (b []world.Block) { + for i := 0; i < 16; i++ { + b = append(b, Fire{Age: i, Type: NormalFire()}) + b = append(b, Fire{Age: i, Type: SoulFire()}) + } + return +} + +// FireDamageSource is used for damage caused by being in fire. +type FireDamageSource struct{} + +func (FireDamageSource) ReducedByResistance() bool { return true } +func (FireDamageSource) ReducedByArmour() bool { return true } +func (FireDamageSource) Fire() bool { return true } +func (FireDamageSource) AffectedByEnchantment(e item.EnchantmentType) bool { + return e == enchantment.FireProtection +} +func (FireDamageSource) IgnoreTotem() bool { return false } diff --git a/server/block/fire_type.go b/server/block/fire_type.go new file mode 100644 index 0000000..46f50b4 --- /dev/null +++ b/server/block/fire_type.go @@ -0,0 +1,72 @@ +package block + +// FireType represents a type of fire. Used by flaming blocks such as torches, lanterns, fire, and campfires. +type FireType struct { + fire +} + +type fire uint8 + +// NormalFire is the default variant of fires +func NormalFire() FireType { + return FireType{0} +} + +// SoulFire is a turquoise variant of normal fire +func SoulFire() FireType { + return FireType{1} +} + +// Uint8 returns the fire as a uint8. +func (f fire) Uint8() uint8 { + return uint8(f) +} + +// LightLevel returns the light level of the fire. +func (f fire) LightLevel() uint8 { + switch f { + case 0: + return 15 + case 1: + return 10 + } + panic("unknown fire type") +} + +// Damage returns the amount of damage taken by entities inside the fire. +func (f fire) Damage() float64 { + switch f { + case 0: + return 1 + case 1: + return 2 + } + panic("unknown fire type") +} + +// Name ... +func (f fire) Name() string { + switch f { + case 0: + return "Fire" + case 1: + return "Soul Fire" + } + panic("unknown fire type") +} + +// String ... +func (f fire) String() string { + switch f { + case 0: + return "normal" + case 1: + return "soul" + } + panic("unknown fire type") +} + +// FireTypes ... +func FireTypes() []FireType { + return []FireType{NormalFire(), SoulFire()} +} diff --git a/server/block/fletching_table.go b/server/block/fletching_table.go new file mode 100644 index 0000000..8dc3170 --- /dev/null +++ b/server/block/fletching_table.go @@ -0,0 +1,32 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" + "time" +) + +// FletchingTable is a block in villages that turn an unemployed villager into a Fletcher. +type FletchingTable struct { + solid + bass +} + +// BreakInfo ... +func (f FletchingTable) BreakInfo() BreakInfo { + return newBreakInfo(2.5, alwaysHarvestable, axeEffective, oneOf(f)) +} + +// FuelInfo ... +func (FletchingTable) FuelInfo() item.FuelInfo { + return newFuelInfo(time.Second * 15) +} + +// EncodeItem ... +func (FletchingTable) EncodeItem() (string, int16) { + return "minecraft:fletching_table", 0 +} + +// EncodeBlock ... +func (FletchingTable) EncodeBlock() (string, map[string]any) { + return "minecraft:fletching_table", nil +} diff --git a/server/block/flower.go b/server/block/flower.go new file mode 100644 index 0000000..3cccb32 --- /dev/null +++ b/server/block/flower.go @@ -0,0 +1,118 @@ +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" +) + +// Flower is a non-solid plant that occur in a variety of shapes and colours. They are primarily used for decoration +// and crafted into dyes. +type Flower struct { + empty + transparent + + // Type is the type of flower. + Type FlowerType +} + +// EntityInside ... +func (f Flower) EntityInside(_ cube.Pos, _ *world.Tx, e world.Entity) { + if f.Type == WitherRose() { + if living, ok := e.(interface { + AddEffect(effect.Effect) + }); ok { + living.AddEffect(effect.New(effect.Wither, 1, 2*time.Second)) + } + } +} + +// BoneMeal ... +func (f Flower) BoneMeal(pos cube.Pos, tx *world.Tx) (result item.BoneMealResult) { + result = item.BoneMealResultNone + if f.Type == WitherRose() { + return + } + + for i := 0; i < 8; i++ { + p := pos.Add(cube.Pos{rand.IntN(7) - 3, rand.IntN(3) - 1, rand.IntN(7) - 3}) + if _, ok := tx.Block(p).(Air); !ok { + continue + } + if _, ok := tx.Block(p.Side(cube.FaceDown)).(Grass); !ok { + continue + } + flowerType := f.Type + if rand.Float64() < 0.1 { + if f.Type == Dandelion() { + flowerType = Poppy() + } else if f.Type == Poppy() { + flowerType = Dandelion() + } + } + tx.SetBlock(p, Flower{Type: flowerType}, nil) + result = item.BoneMealResultArea + } + return +} + +// NeighbourUpdateTick ... +func (f Flower) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if !supportsVegetation(f, tx.Block(pos.Side(cube.FaceDown))) { + breakBlock(f, pos, tx) + } +} + +// UseOnBlock ... +func (f Flower) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, f) + if !used || !supportsVegetation(f, tx.Block(pos.Side(cube.FaceDown))) { + return false + } + + place(tx, pos, f, user, ctx) + return placed(ctx) +} + +// HasLiquidDrops ... +func (Flower) HasLiquidDrops() bool { + return true +} + +// FlammabilityInfo ... +func (f Flower) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(60, 100, false) +} + +// BreakInfo ... +func (f Flower) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(f)) +} + +// CompostChance ... +func (Flower) CompostChance() float64 { + return 0.65 +} + +// EncodeItem ... +func (f Flower) EncodeItem() (name string, meta int16) { + return "minecraft:" + f.Type.String(), 0 +} + +// EncodeBlock ... +func (f Flower) EncodeBlock() (string, map[string]any) { + return "minecraft:" + f.Type.String(), nil +} + +// allFlowers ... +func allFlowers() (b []world.Block) { + for _, f := range FlowerTypes() { + b = append(b, Flower{Type: f}) + } + return +} diff --git a/server/block/flower_type.go b/server/block/flower_type.go new file mode 100644 index 0000000..d674471 --- /dev/null +++ b/server/block/flower_type.go @@ -0,0 +1,149 @@ +package block + +// FlowerType represents a type of flower. +type FlowerType struct { + flower +} + +type flower uint8 + +// Dandelion is a dandelion flower. +func Dandelion() FlowerType { + return FlowerType{0} +} + +// Poppy is a poppy flower. +func Poppy() FlowerType { + return FlowerType{1} +} + +// BlueOrchid is a blue orchid flower. +func BlueOrchid() FlowerType { + return FlowerType{2} +} + +// Allium is an allium flower. +func Allium() FlowerType { + return FlowerType{3} +} + +// AzureBluet is an azure bluet flower. +func AzureBluet() FlowerType { + return FlowerType{4} +} + +// RedTulip is a red tulip flower. +func RedTulip() FlowerType { + return FlowerType{5} +} + +// OrangeTulip is an orange tulip flower. +func OrangeTulip() FlowerType { + return FlowerType{6} +} + +// WhiteTulip is a white tulip flower. +func WhiteTulip() FlowerType { + return FlowerType{7} +} + +// PinkTulip is a pink tulip flower. +func PinkTulip() FlowerType { + return FlowerType{8} +} + +// OxeyeDaisy is an oxeye daisy flower. +func OxeyeDaisy() FlowerType { + return FlowerType{9} +} + +// Cornflower is a cornflower flower. +func Cornflower() FlowerType { + return FlowerType{10} +} + +// LilyOfTheValley is a lily of the valley flower. +func LilyOfTheValley() FlowerType { + return FlowerType{11} +} + +// WitherRose is a wither rose flower. +func WitherRose() FlowerType { + return FlowerType{12} +} + +// Uint8 returns the flower as a uint8. +func (f flower) Uint8() uint8 { + return uint8(f) +} + +// Name ... +func (f flower) Name() string { + switch f { + case 0: + return "Dandelion" + case 1: + return "Poppy" + case 2: + return "Blue Orchid" + case 3: + return "Allium" + case 4: + return "Azure Bluet" + case 5: + return "Red Tulip" + case 6: + return "Orange Tulip" + case 7: + return "White Tulip" + case 8: + return "Pink Tulip" + case 9: + return "Oxeye Daisy" + case 10: + return "Cornflower" + case 11: + return "Lily of the Valley" + case 12: + return "Wither Rose" + } + panic("unknown flower type") +} + +// String ... +func (f flower) String() string { + switch f { + case 0: + return "dandelion" + case 1: + return "poppy" + case 2: + return "blue_orchid" + case 3: + return "allium" + case 4: + return "azure_bluet" + case 5: + return "red_tulip" + case 6: + return "orange_tulip" + case 7: + return "white_tulip" + case 8: + return "pink_tulip" + case 9: + return "oxeye_daisy" + case 10: + return "cornflower" + case 11: + return "lily_of_the_valley" + case 12: + return "wither_rose" + } + panic("unknown flower type") +} + +// FlowerTypes ... +func FlowerTypes() []FlowerType { + return []FlowerType{Dandelion(), Poppy(), BlueOrchid(), Allium(), AzureBluet(), RedTulip(), OrangeTulip(), WhiteTulip(), PinkTulip(), OxeyeDaisy(), Cornflower(), LilyOfTheValley(), WitherRose()} +} diff --git a/server/block/froglight.go b/server/block/froglight.go new file mode 100644 index 0000000..5d8736c --- /dev/null +++ b/server/block/froglight.go @@ -0,0 +1,60 @@ +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" +) + +// Froglight is a luminous natural block that can be obtained if a frog eats a tiny magma cube. +type Froglight struct { + solid + + // Type is the type of froglight. + Type FroglightType + // Axis is the axis which the froglight block faces. + Axis cube.Axis +} + +// LightEmissionLevel ... +func (f Froglight) LightEmissionLevel() uint8 { + return 15 +} + +// UseOnBlock ... +func (f Froglight) 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, f) + if !used { + return + } + f.Axis = face.Axis() + + place(tx, pos, f, user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (f Froglight) BreakInfo() BreakInfo { + return newBreakInfo(0.3, alwaysHarvestable, nothingEffective, oneOf(f)) +} + +// EncodeItem ... +func (f Froglight) EncodeItem() (name string, meta int16) { + return "minecraft:" + f.Type.String() + "_froglight", 0 +} + +// EncodeBlock ... +func (f Froglight) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:" + f.Type.String() + "_froglight", map[string]any{"pillar_axis": f.Axis.String()} +} + +// allFrogLight ... +func allFroglight() (froglight []world.Block) { + for _, axis := range cube.Axes() { + for _, t := range FroglightTypes() { + froglight = append(froglight, Froglight{Type: t, Axis: axis}) + } + } + return +} diff --git a/server/block/froglight_type.go b/server/block/froglight_type.go new file mode 100644 index 0000000..cb074e2 --- /dev/null +++ b/server/block/froglight_type.go @@ -0,0 +1,59 @@ +package block + +// FroglightType represents a type of froglight. +type FroglightType struct { + froglight +} + +type froglight uint8 + +// Pearlescent is the purple variant of froglight. +func Pearlescent() FroglightType { + return FroglightType{0} +} + +// Verdant is the green variant of froglight. +func Verdant() FroglightType { + return FroglightType{1} +} + +// Ochre is the yellow variant of froglight. +func Ochre() FroglightType { + return FroglightType{2} +} + +// Uint8 ... +func (f froglight) Uint8() uint8 { + return uint8(f) +} + +// Name ... +func (f froglight) Name() string { + switch f { + case 0: + return "Pearlescent Froglight" + case 1: + return "Verdant Froglight" + case 2: + return "Ochre Froglight" + } + panic("unknown froglight type") +} + +// String ... +func (f froglight) String() string { + switch f { + case 0: + return "pearlescent" + case 1: + return "verdant" + case 2: + return "ochre" + } + panic("unknown froglight type") +} + +// FroglightTypes ... +func FroglightTypes() []FroglightType { + return []FroglightType{Pearlescent(), Verdant(), Ochre()} +} diff --git a/server/block/furnace.go b/server/block/furnace.go new file mode 100644 index 0000000..0632a2a --- /dev/null +++ b/server/block/furnace.go @@ -0,0 +1,141 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "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/v2" + "time" +) + +// Furnace is a utility block used for the smelting of blocks and items. +// The empty value of Furnace is not valid. It must be created using block.NewFurnace(cube.Face). +type Furnace struct { + solid + bassDrum + *smelter + + // Facing is the direction the furnace is facing. + Facing cube.Direction + // Lit is true if the furnace is lit. + Lit bool +} + +// NewFurnace creates a new initialised furnace. The smelter is properly initialised. +func NewFurnace(face cube.Direction) Furnace { + return Furnace{ + Facing: face, + smelter: newSmelter(), + } +} + +// Tick is called to check if the furnace should update and start or stop smelting. +func (f Furnace) Tick(_ int64, pos cube.Pos, tx *world.Tx) { + if f.Lit && rand.Float64() <= 0.016 { // Every three or so seconds. + tx.PlaySound(pos.Vec3Centre(), sound.FurnaceCrackle{}) + } + if lit := f.tickSmelting(time.Second*10, time.Millisecond*100, f.Lit, func(item.SmeltInfo) bool { + return true + }); f.Lit != lit { + f.Lit = lit + tx.SetBlock(pos, f, nil) + } +} + +// LightEmissionLevel ... +func (f Furnace) LightEmissionLevel() uint8 { + if f.Lit { + return 13 + } + return 0 +} + +// EncodeItem ... +func (f Furnace) EncodeItem() (name string, meta int16) { + return "minecraft:furnace", 0 +} + +// EncodeBlock ... +func (f Furnace) EncodeBlock() (name string, properties map[string]interface{}) { + if f.Lit { + return "minecraft:lit_furnace", map[string]interface{}{"minecraft:cardinal_direction": f.Facing.String()} + } + return "minecraft:furnace", map[string]interface{}{"minecraft:cardinal_direction": f.Facing.String()} +} + +// UseOnBlock ... +func (f Furnace) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, f) + if !used { + return false + } + + place(tx, pos, NewFurnace(user.Rotation().Direction().Opposite()), user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (f Furnace) BreakInfo() BreakInfo { + xp := f.Experience() + return newBreakInfo(3.5, alwaysHarvestable, pickaxeEffective, oneOf(Furnace{})).withXPDropRange(xp, xp).withBreakHandler(func(pos cube.Pos, tx *world.Tx, u item.User) { + for _, i := range f.Inventory(tx, pos).Clear() { + dropItem(tx, i, pos.Vec3()) + } + }) +} + +// Activate ... +func (f Furnace) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + if opener, ok := u.(ContainerOpener); ok { + opener.OpenBlockContainer(pos, tx) + return true + } + return false +} + +// EncodeNBT ... +func (f Furnace) EncodeNBT() map[string]interface{} { + if f.smelter == nil { + //noinspection GoAssignmentToReceiver + f = NewFurnace(f.Facing) + } + remaining, maximum, cook := f.Durations() + return map[string]interface{}{ + "BurnTime": int16(remaining.Milliseconds() / 50), + "CookTime": int16(cook.Milliseconds() / 50), + "BurnDuration": int16(maximum.Milliseconds() / 50), + "StoredXPInt": int16(f.Experience()), + "Items": nbtconv.InvToNBT(f.inventory), + "id": "Furnace", + } +} + +// DecodeNBT ... +func (f Furnace) DecodeNBT(data map[string]interface{}) interface{} { + remaining := nbtconv.TickDuration[int16](data, "BurnTime") + maximum := nbtconv.TickDuration[int16](data, "BurnDuration") + cook := nbtconv.TickDuration[int16](data, "CookTime") + + xp := int(nbtconv.Int16(data, "StoredXPInt")) + lit := f.Lit + + //noinspection GoAssignmentToReceiver + f = NewFurnace(f.Facing) + f.Lit = lit + f.setExperience(xp) + f.setDurations(remaining, maximum, cook) + nbtconv.InvFromNBT(f.inventory, nbtconv.Slice(data, "Items")) + return f +} + +// allFurnaces ... +func allFurnaces() (furnaces []world.Block) { + for _, face := range cube.Directions() { + furnaces = append(furnaces, Furnace{Facing: face}) + furnaces = append(furnaces, Furnace{Facing: face, Lit: true}) + } + return +} diff --git a/server/block/glass.go b/server/block/glass.go new file mode 100644 index 0000000..1429f3a --- /dev/null +++ b/server/block/glass.go @@ -0,0 +1,23 @@ +package block + +// Glass is a decorative, fully transparent solid block that can be dyed into stained-glass. +type Glass struct { + solid + transparent + clicksAndSticks +} + +// BreakInfo ... +func (g Glass) BreakInfo() BreakInfo { + return newBreakInfo(0.3, alwaysHarvestable, nothingEffective, silkTouchOnlyDrop(g)) +} + +// EncodeItem ... +func (Glass) EncodeItem() (name string, meta int16) { + return "minecraft:glass", 0 +} + +// EncodeBlock ... +func (Glass) EncodeBlock() (string, map[string]any) { + return "minecraft:glass", nil +} diff --git a/server/block/glass_pane.go b/server/block/glass_pane.go new file mode 100644 index 0000000..fe54640 --- /dev/null +++ b/server/block/glass_pane.go @@ -0,0 +1,34 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// GlassPane is a transparent block that can be used as a more efficient alternative to glass blocks. +type GlassPane struct { + transparent + thin + clicksAndSticks + sourceWaterDisplacer +} + +// SideClosed ... +func (p GlassPane) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// BreakInfo ... +func (p GlassPane) BreakInfo() BreakInfo { + return newBreakInfo(0.3, alwaysHarvestable, nothingEffective, silkTouchOnlyDrop(p)) +} + +// EncodeItem ... +func (GlassPane) EncodeItem() (name string, meta int16) { + return "minecraft:glass_pane", meta +} + +// EncodeBlock ... +func (GlassPane) EncodeBlock() (string, map[string]any) { + return "minecraft:glass_pane", nil +} diff --git a/server/block/glazed_terracotta.go b/server/block/glazed_terracotta.go new file mode 100644 index 0000000..fe10f65 --- /dev/null +++ b/server/block/glazed_terracotta.go @@ -0,0 +1,59 @@ +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" +) + +// GlazedTerracotta is a vibrant solid block that comes in the 16 regular dye colours. +type GlazedTerracotta struct { + solid + bassDrum + + // Colour specifies the colour of the block. + Colour item.Colour + // Facing specifies the face of the block. + Facing cube.Direction +} + +// BreakInfo ... +func (t GlazedTerracotta) BreakInfo() BreakInfo { + return newBreakInfo(1.4, pickaxeHarvestable, pickaxeEffective, oneOf(t)) +} + +// EncodeItem ... +func (t GlazedTerracotta) EncodeItem() (name string, meta int16) { + return "minecraft:" + t.Colour.SilverString() + "_glazed_terracotta", 0 +} + +// EncodeBlock ... +func (t GlazedTerracotta) EncodeBlock() (name string, properties map[string]any) { + if t.Facing == unknownDirection { + return "minecraft:" + t.Colour.SilverString() + "_glazed_terracotta", map[string]any{"facing_direction": int32(0)} + } + return "minecraft:" + t.Colour.SilverString() + "_glazed_terracotta", map[string]any{"facing_direction": int32(2 + t.Facing)} +} + +// UseOnBlock ensures the proper facing is used when placing a glazed terracotta block, by using the opposite of the player. +func (t GlazedTerracotta) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, t) + if !used { + return + } + t.Facing = user.Rotation().Direction().Opposite() + + place(tx, pos, t, user, ctx) + return placed(ctx) +} + +// allGlazedTerracotta returns glazed terracotta blocks with all possible colours. +func allGlazedTerracotta() (b []world.Block) { + for _, dir := range append(cube.Directions(), unknownDirection) { + for _, c := range item.Colours() { + b = append(b, GlazedTerracotta{Colour: c, Facing: dir}) + } + } + return b +} diff --git a/server/block/glowstone.go b/server/block/glowstone.go new file mode 100644 index 0000000..97539d7 --- /dev/null +++ b/server/block/glowstone.go @@ -0,0 +1,48 @@ +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/df-mc/dragonfly/server/world/sound" +) + +// Glowstone is commonly found on the ceiling of the nether dimension. +type Glowstone struct { + solid +} + +// Instrument ... +func (g Glowstone) Instrument() sound.Instrument { + return sound.Pling() +} + +// CanRedstoneWireStepDown ... +func (Glowstone) CanRedstoneWireStepDown(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// RelaysRedstonePowerThrough returns false. +func (Glowstone) RelaysRedstonePowerThrough() bool { + return false +} + +// BreakInfo ... +func (g Glowstone) BreakInfo() BreakInfo { + return newBreakInfo(0.3, alwaysHarvestable, nothingEffective, discreteDrops(item.GlowstoneDust{}, g, 2, 4, 4)) +} + +// EncodeItem ... +func (Glowstone) EncodeItem() (name string, meta int16) { + return "minecraft:glowstone", 0 +} + +// EncodeBlock ... +func (Glowstone) EncodeBlock() (string, map[string]any) { + return "minecraft:glowstone", nil +} + +// LightEmissionLevel returns 15. +func (Glowstone) LightEmissionLevel() uint8 { + return 15 +} diff --git a/server/block/gold.go b/server/block/gold.go new file mode 100644 index 0000000..2a08ad2 --- /dev/null +++ b/server/block/gold.go @@ -0,0 +1,38 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world/sound" +) + +// Gold is a precious metal block crafted from 9 gold ingots. +type Gold struct { + solid +} + +// Instrument ... +func (g Gold) Instrument() sound.Instrument { + return sound.Bell() +} + +// BreakInfo ... +func (g Gold) BreakInfo() BreakInfo { + return newBreakInfo(3, func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierIron.HarvestLevel + }, pickaxeEffective, oneOf(g)).withBlastResistance(30) +} + +// PowersBeacon ... +func (Gold) PowersBeacon() bool { + return true +} + +// EncodeItem ... +func (Gold) EncodeItem() (name string, meta int16) { + return "minecraft:gold_block", 0 +} + +// EncodeBlock ... +func (Gold) EncodeBlock() (string, map[string]any) { + return "minecraft:gold_block", nil +} diff --git a/server/block/gold_ore.go b/server/block/gold_ore.go new file mode 100644 index 0000000..698ac4f --- /dev/null +++ b/server/block/gold_ore.go @@ -0,0 +1,36 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" +) + +// GoldOre is a rare mineral block found underground. +type GoldOre struct { + solid + bassDrum + + // Type is the type of gold ore. + Type OreType +} + +// BreakInfo ... +func (g GoldOre) BreakInfo() BreakInfo { + return newBreakInfo(g.Type.Hardness(), func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierIron.HarvestLevel + }, pickaxeEffective, oreDrops(item.RawGold{}, g)).withBlastResistance(15) +} + +// SmeltInfo ... +func (GoldOre) SmeltInfo() item.SmeltInfo { + return newOreSmeltInfo(item.NewStack(item.GoldIngot{}, 1), 1) +} + +// EncodeItem ... +func (g GoldOre) EncodeItem() (name string, meta int16) { + return "minecraft:" + g.Type.Prefix() + "gold_ore", 0 +} + +// EncodeBlock ... +func (g GoldOre) EncodeBlock() (string, map[string]any) { + return "minecraft:" + g.Type.Prefix() + "gold_ore", nil +} diff --git a/server/block/grass.go b/server/block/grass.go new file mode 100644 index 0000000..dade87c --- /dev/null +++ b/server/block/grass.go @@ -0,0 +1,126 @@ +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" +) + +// Grass blocks generate abundantly across the surface of the world. +type Grass struct { + solid +} + +// plantSelection are the plants that are picked from when a bone meal is attempted. +// TODO: Base plant selection on current biome. +var plantSelection = []world.Block{ + Flower{Type: OxeyeDaisy()}, + Flower{Type: PinkTulip()}, + Flower{Type: Cornflower()}, + Flower{Type: WhiteTulip()}, + Flower{Type: RedTulip()}, + Flower{Type: OrangeTulip()}, + Flower{Type: Dandelion()}, + Flower{Type: Poppy()}, +} + +// init adds extra variants of TallGrass to the plant selection. +func init() { + for i := 0; i < 8; i++ { + plantSelection = append(plantSelection, Fern{}) + } + for i := 0; i < 12; i++ { + plantSelection = append(plantSelection, ShortGrass{}) + } +} + +// SoilFor ... +func (g Grass) SoilFor(block world.Block) bool { + switch block.(type) { + case ShortGrass, Fern, DoubleTallGrass, Flower, DoubleFlower, NetherSprouts, PinkPetals, SugarCane, DeadBush: + return true + } + return false +} + +// RandomTick handles the ticking of grass, which may or may not result in the spreading of grass onto dirt. +func (g Grass) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + aboveLight := tx.Light(pos.Side(cube.FaceUp)) + if aboveLight < 4 { + // The light above the block is too low: The grass turns to dirt. + tx.SetBlock(pos, Dirt{}, nil) + return + } + if aboveLight < 9 { + // Don't attempt to spread if the light level is lower than 9. + return + } + + // Generate a single uint32 as we only need 28 bits (7 bits each iteration). + n := r.Uint32() + + // Four attempts to spread to another block. + for i := 0; i < 4; i++ { + x, y, z := int(n)%3, int(n>>2)%5, int(n>>5)%3 + n >>= 7 + + spreadPos := pos.Add(cube.Pos{x - 1, y - 3, z - 1}) + // Don't spread grass to locations where dirt is exposed to hardly any light. + if tx.Light(spreadPos.Side(cube.FaceUp)) < 4 { + continue + } + b := tx.Block(spreadPos) + if dirt, ok := b.(Dirt); !ok || dirt.Coarse { + continue + } + tx.SetBlock(spreadPos, g, nil) + } +} + +// BoneMeal ... +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 +} + +// BreakInfo ... +func (g Grass) BreakInfo() BreakInfo { + return newBreakInfo(0.6, alwaysHarvestable, shovelEffective, silkTouchOneOf(Dirt{}, g)) +} + +// CompostChance ... +func (Grass) CompostChance() float64 { + return 0.3 +} + +// EncodeItem ... +func (Grass) EncodeItem() (name string, meta int16) { + return "minecraft:grass_block", 0 +} + +// EncodeBlock ... +func (Grass) EncodeBlock() (string, map[string]any) { + return "minecraft:grass_block", nil +} + +// Till ... +func (g Grass) Till() (world.Block, bool) { + return Farmland{}, true +} + +// Shovel ... +func (g Grass) Shovel() (world.Block, bool) { + return DirtPath{}, true +} diff --git a/server/block/gravel.go b/server/block/gravel.go new file mode 100644 index 0000000..4326d59 --- /dev/null +++ b/server/block/gravel.go @@ -0,0 +1,46 @@ +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" +) + +// Gravel is a block affected by gravity. It has a 10% chance of dropping flint instead of itself on break. +type Gravel struct { + gravityAffected + solid + snare +} + +// NeighbourUpdateTick ... +func (g Gravel) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + g.fall(g, pos, tx) +} + +// BreakInfo ... +func (g Gravel) BreakInfo() BreakInfo { + return newBreakInfo(0.6, alwaysHarvestable, shovelEffective, func(t item.Tool, enchantments []item.Enchantment) []item.Stack { + if hasSilkTouch(enchantments) { + return []item.Stack{item.NewStack(g, 1)} + } + flintChances := []float64{0.1, 1.0 / 7.0, 0.25, 1.0} + flintChance := flintChances[min(fortuneLevel(enchantments), 3)] + if rand.Float64() < flintChance { + return []item.Stack{item.NewStack(item.Flint{}, 1)} + } + return []item.Stack{item.NewStack(g, 1)} + }) +} + +// EncodeItem ... +func (Gravel) EncodeItem() (name string, meta int16) { + return "minecraft:gravel", 0 +} + +// EncodeBlock ... +func (Gravel) EncodeBlock() (string, map[string]any) { + return "minecraft:gravel", nil +} diff --git a/server/block/grindstone.go b/server/block/grindstone.go new file mode 100644 index 0000000..e321f2e --- /dev/null +++ b/server/block/grindstone.go @@ -0,0 +1,98 @@ +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/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// Grindstone is a block that repairs items and tools as well as removing enchantments from them. It also serves as a +// weaponsmith's job site block. +type Grindstone struct { + transparent + + // Attach represents the attachment type of the Grindstone. + Attach GrindstoneAttachment + // Facing represents the direction the Grindstone is facing. + Facing cube.Direction +} + +// BreakInfo ... +func (g Grindstone) BreakInfo() BreakInfo { + return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(g)).withBlastResistance(30) +} + +// Activate ... +func (g Grindstone) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + if opener, ok := u.(ContainerOpener); ok { + opener.OpenBlockContainer(pos, tx) + return true + } + return false +} + +// UseOnBlock ... +func (g Grindstone) 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, g) + if !used { + return false + } + g.Facing = user.Rotation().Direction().Opposite() + if face == cube.FaceDown { + g.Attach = HangingGrindstoneAttachment() + } else if face != cube.FaceUp { + g.Attach = WallGrindstoneAttachment() + g.Facing = face.Direction() + } + place(tx, pos, g, user, ctx) + return placed(ctx) +} + +// NeighbourUpdateTick ... +func (g Grindstone) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + supportFace := g.Facing.Face().Opposite() + if g.Attach == HangingGrindstoneAttachment() { + supportFace = cube.FaceUp + } else if g.Attach == StandingGrindstoneAttachment() { + supportFace = cube.FaceDown + } + if _, ok := tx.Block(pos.Side(supportFace)).Model().(model.Empty); ok { + // Grindstone is pickaxeHarvestable, so don't use breakBlock() here. + breakBlockNoDrops(g, pos, tx) + dropItem(tx, item.NewStack(g, 1), pos.Vec3Centre()) + } +} + +// Model ... +func (g Grindstone) Model() world.BlockModel { + axis := cube.Y + if g.Attach == WallGrindstoneAttachment() { + axis = g.Facing.Face().Axis() + } + return model.Grindstone{Axis: axis} +} + +// EncodeBlock ... +func (g Grindstone) EncodeBlock() (string, map[string]any) { + return "minecraft:grindstone", map[string]any{ + "attachment": g.Attach.String(), + "direction": int32(horizontalDirection(g.Facing)), + } +} + +// EncodeItem ... +func (g Grindstone) EncodeItem() (name string, meta int16) { + return "minecraft:grindstone", 0 +} + +// allGrindstones ... +func allGrindstones() (grindstones []world.Block) { + for _, a := range GrindstoneAttachments() { + for _, d := range cube.Directions() { + grindstones = append(grindstones, Grindstone{Attach: a, Facing: d}) + } + } + return +} diff --git a/server/block/grindstone_attachment.go b/server/block/grindstone_attachment.go new file mode 100644 index 0000000..4902dff --- /dev/null +++ b/server/block/grindstone_attachment.go @@ -0,0 +1,46 @@ +package block + +// GrindstoneAttachment represents a type of attachment for a Grindstone. +type GrindstoneAttachment struct { + grindstoneAttachment +} + +// StandingGrindstoneAttachment is a type of attachment for a standing Grindstone. +func StandingGrindstoneAttachment() GrindstoneAttachment { + return GrindstoneAttachment{0} +} + +// HangingGrindstoneAttachment is a type of attachment for a hanging Grindstone. +func HangingGrindstoneAttachment() GrindstoneAttachment { + return GrindstoneAttachment{1} +} + +// WallGrindstoneAttachment is a type of attachment for a wall Grindstone. +func WallGrindstoneAttachment() GrindstoneAttachment { + return GrindstoneAttachment{2} +} + +// GrindstoneAttachments returns all possible GrindstoneAttachments. +func GrindstoneAttachments() []GrindstoneAttachment { + return []GrindstoneAttachment{StandingGrindstoneAttachment(), HangingGrindstoneAttachment(), WallGrindstoneAttachment()} +} + +type grindstoneAttachment uint8 + +// Uint8 returns the GrindstoneAttachment as a uint8. +func (g grindstoneAttachment) Uint8() uint8 { + return uint8(g) +} + +// String returns the GrindstoneAttachment as a string. +func (g grindstoneAttachment) String() string { + switch g { + case 0: + return "standing" + case 1: + return "hanging" + case 2: + return "side" + } + panic("should never happen") +} diff --git a/server/block/hash.go b/server/block/hash.go new file mode 100644 index 0000000..55b51ec --- /dev/null +++ b/server/block/hash.go @@ -0,0 +1,1043 @@ +// Code generated by cmd/blockhash; DO NOT EDIT. + +package block + +import "github.com/df-mc/dragonfly/server/world" + +const ( + hashAir = iota + hashAmethyst + hashAncientDebris + hashAndesite + hashAnvil + hashBambooBlock + hashBambooMosaic + hashBanner + hashBarrel + hashBarrier + hashBasalt + hashBeacon + hashBed + hashBedrock + hashBeetrootSeeds + hashBlackstone + hashBlastFurnace + hashBlueIce + hashBone + hashBookshelf + hashBrewingStand + hashBricks + hashCactus + hashCake + hashCalcite + hashCampfire + hashCarpet + hashCarrot + hashChest + hashChiseledQuartz + hashClay + hashCoal + hashCoalOre + hashCobblestone + hashCobweb + hashCocoaBean + hashComposter + hashConcrete + hashConcretePowder + hashCopper + hashCopperBars + hashCopperChain + hashCopperDoor + hashCopperGolemStatue + hashCopperGrate + hashCopperLantern + hashCopperOre + hashCopperTorch + hashCopperTrapdoor + hashCoral + hashCoralBlock + hashCraftingTable + hashDeadBush + hashDecoratedPot + hashDeepslate + hashDeepslateBricks + hashDeepslateTiles + hashDiamond + hashDiamondOre + hashDiorite + hashDirt + hashDirtPath + hashDoubleFlower + hashDoubleTallGrass + hashDragonEgg + hashDriedKelp + hashDripstone + hashEmerald + hashEmeraldOre + hashEnchantingTable + hashEndBricks + hashEndRod + hashEndStone + hashEnderChest + hashFarmland + hashFern + hashFire + hashFletchingTable + hashFlower + hashFroglight + hashFurnace + hashGlass + hashGlassPane + hashGlazedTerracotta + hashGlowstone + hashGold + hashGoldOre + hashGranite + hashGrass + hashGravel + hashGrindstone + hashHayBale + hashHoneycomb + hashHopper + hashInfestedCobblestone + hashInfestedDeepslate + hashInfestedStone + hashInfestedStoneBricks + hashInvisibleBedrock + hashIron + hashIronBars + hashIronChain + hashIronOre + hashItemFrame + hashJukebox + hashKelp + hashLadder + hashLantern + hashLapis + hashLapisOre + hashLava + hashLeaves + hashLectern + hashLever + hashLight + hashLilyPad + hashLitPumpkin + hashLog + hashLoom + hashMagma + hashMelon + hashMelonSeeds + hashMossCarpet + hashMud + hashMudBricks + hashMuddyMangroveRoots + hashNetherBrickFence + hashNetherBricks + hashNetherGoldOre + hashNetherQuartzOre + hashNetherSprouts + hashNetherWart + hashNetherWartBlock + hashNetherite + hashNetherrack + hashNote + hashObsidian + hashPackedIce + hashPackedMud + hashPinkPetals + hashPlanks + hashPodzol + hashPolishedBlackstoneBrick + hashPolishedTuff + hashPotato + hashPrismarine + hashPumpkin + hashPumpkinSeeds + hashPurpur + hashPurpurPillar + hashQuartz + hashQuartzBricks + hashQuartzPillar + hashRawCopper + hashRawGold + hashRawIron + hashRedstoneBlock + hashRedstoneOre + hashRedstoneTorch + hashRedstoneWire + hashReinforcedDeepslate + hashResin + hashResinBricks + hashSand + hashSandstone + hashSeaLantern + hashSeaPickle + hashShortGrass + hashShroomlight + hashSign + hashSkull + hashSlab + hashSlime + hashSmithingTable + hashSmoker + hashSmoothBasalt + hashSnow + hashSoulSand + hashSoulSoil + hashSponge + hashSporeBlossom + hashStainedGlass + hashStainedGlassPane + hashStainedTerracotta + hashStairs + hashStone + hashStoneBricks + hashStonecutter + hashString + hashSugarCane + hashTNT + hashTerracotta + hashTorch + hashTuff + hashTuffBricks + hashVines + hashWall + hashWater + hashWheatSeeds + hashWood + hashWoodDoor + hashWoodFence + hashWoodFenceGate + hashWoodTrapdoor + hashWool + hashCustomBlockBase +) + +// customBlockBase represents the base hash for all custom blocks. +var customBlockBase = uint64(hashCustomBlockBase - 1) + +// NextHash returns the next free hash for custom blocks. +func NextHash() uint64 { + customBlockBase++ + return customBlockBase +} + +func (Air) Hash() (uint64, uint64) { + return hashAir, 0 +} + +func (Amethyst) Hash() (uint64, uint64) { + return hashAmethyst, 0 +} + +func (AncientDebris) Hash() (uint64, uint64) { + return hashAncientDebris, 0 +} + +func (a Andesite) Hash() (uint64, uint64) { + return hashAndesite, uint64(boolByte(a.Polished)) +} + +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()) +} + +func (b Barrel) Hash() (uint64, uint64) { + return hashBarrel, uint64(b.Facing) | uint64(boolByte(b.Open))<<3 +} + +func (Barrier) Hash() (uint64, uint64) { + return hashBarrier, 0 +} + +func (b Basalt) Hash() (uint64, uint64) { + return hashBasalt, uint64(boolByte(b.Polished)) | uint64(b.Axis)<<1 +} + +func (Beacon) Hash() (uint64, uint64) { + return hashBeacon, 0 +} + +func (b Bed) Hash() (uint64, uint64) { + return hashBed, uint64(b.Facing) | uint64(boolByte(b.Head))<<2 +} + +func (b Bedrock) Hash() (uint64, uint64) { + return hashBedrock, uint64(boolByte(b.InfiniteBurning)) +} + +func (b BeetrootSeeds) Hash() (uint64, uint64) { + return hashBeetrootSeeds, uint64(b.Growth) +} + +func (b Blackstone) Hash() (uint64, uint64) { + return hashBlackstone, uint64(b.Type.Uint8()) +} + +func (b BlastFurnace) Hash() (uint64, uint64) { + return hashBlastFurnace, uint64(b.Facing) | uint64(boolByte(b.Lit))<<2 +} + +func (BlueIce) Hash() (uint64, uint64) { + return hashBlueIce, 0 +} + +func (b Bone) Hash() (uint64, uint64) { + return hashBone, uint64(b.Axis) +} + +func (Bookshelf) Hash() (uint64, uint64) { + return hashBookshelf, 0 +} + +func (b BrewingStand) Hash() (uint64, uint64) { + return hashBrewingStand, uint64(boolByte(b.LeftSlot)) | uint64(boolByte(b.MiddleSlot))<<1 | uint64(boolByte(b.RightSlot))<<2 +} + +func (Bricks) Hash() (uint64, uint64) { + return hashBricks, 0 +} + +func (c Cactus) Hash() (uint64, uint64) { + return hashCactus, uint64(c.Age) +} + +func (c Cake) Hash() (uint64, uint64) { + return hashCake, uint64(c.Bites) +} + +func (Calcite) Hash() (uint64, uint64) { + return hashCalcite, 0 +} + +func (c Campfire) Hash() (uint64, uint64) { + return hashCampfire, uint64(c.Facing) | uint64(boolByte(c.Extinguished))<<2 | uint64(c.Type.Uint8())<<3 +} + +func (c Carpet) Hash() (uint64, uint64) { + return hashCarpet, uint64(c.Colour.Uint8()) +} + +func (c Carrot) Hash() (uint64, uint64) { + return hashCarrot, uint64(c.Growth) +} + +func (c Chest) Hash() (uint64, uint64) { + return hashChest, uint64(c.Facing) +} + +func (ChiseledQuartz) Hash() (uint64, uint64) { + return hashChiseledQuartz, 0 +} + +func (Clay) Hash() (uint64, uint64) { + return hashClay, 0 +} + +func (Coal) Hash() (uint64, uint64) { + return hashCoal, 0 +} + +func (c CoalOre) Hash() (uint64, uint64) { + return hashCoalOre, uint64(c.Type.Uint8()) +} + +func (c Cobblestone) Hash() (uint64, uint64) { + return hashCobblestone, uint64(boolByte(c.Mossy)) +} + +func (Cobweb) Hash() (uint64, uint64) { + return hashCobweb, 0 +} + +func (c CocoaBean) Hash() (uint64, uint64) { + return hashCocoaBean, uint64(c.Facing) | uint64(c.Age)<<2 +} + +func (c Composter) Hash() (uint64, uint64) { + return hashComposter, uint64(c.Level) +} + +func (c Concrete) Hash() (uint64, uint64) { + return hashConcrete, uint64(c.Colour.Uint8()) +} + +func (c ConcretePowder) Hash() (uint64, uint64) { + return hashConcretePowder, uint64(c.Colour.Uint8()) +} + +func (c Copper) Hash() (uint64, uint64) { + return hashCopper, uint64(c.Type.Uint8()) | uint64(c.Oxidation.Uint8())<<2 | uint64(boolByte(c.Waxed))<<4 +} + +func (c CopperBars) Hash() (uint64, uint64) { + return hashCopperBars, uint64(c.Oxidation.Uint8()) | uint64(boolByte(c.Waxed))<<2 +} + +func (c CopperChain) Hash() (uint64, uint64) { + return hashCopperChain, uint64(c.Axis) | uint64(c.Oxidation.Uint8())<<2 | uint64(boolByte(c.Waxed))<<4 +} + +func (d CopperDoor) Hash() (uint64, uint64) { + return hashCopperDoor, uint64(d.Oxidation.Uint8()) | uint64(boolByte(d.Waxed))<<2 | uint64(d.Facing)<<3 | uint64(boolByte(d.Open))<<5 | uint64(boolByte(d.Top))<<6 | uint64(boolByte(d.Right))<<7 +} + +func (c CopperGolemStatue) Hash() (uint64, uint64) { + return hashCopperGolemStatue, uint64(c.Facing) | uint64(c.Oxidation.Uint8())<<2 | uint64(boolByte(c.Waxed))<<4 +} + +func (c CopperGrate) Hash() (uint64, uint64) { + return hashCopperGrate, uint64(c.Oxidation.Uint8()) | uint64(boolByte(c.Waxed))<<2 +} + +func (c CopperLantern) Hash() (uint64, uint64) { + return hashCopperLantern, uint64(boolByte(c.Hanging)) | uint64(c.Oxidation.Uint8())<<1 | uint64(boolByte(c.Waxed))<<3 +} + +func (c CopperOre) Hash() (uint64, uint64) { + return hashCopperOre, uint64(c.Type.Uint8()) +} + +func (t CopperTorch) Hash() (uint64, uint64) { + return hashCopperTorch, uint64(t.Facing) +} + +func (t CopperTrapdoor) Hash() (uint64, uint64) { + return hashCopperTrapdoor, uint64(t.Oxidation.Uint8()) | uint64(boolByte(t.Waxed))<<2 | uint64(t.Facing)<<3 | uint64(boolByte(t.Open))<<5 | uint64(boolByte(t.Top))<<6 +} + +func (c Coral) Hash() (uint64, uint64) { + return hashCoral, uint64(c.Type.Uint8()) | uint64(boolByte(c.Dead))<<3 +} + +func (c CoralBlock) Hash() (uint64, uint64) { + return hashCoralBlock, uint64(c.Type.Uint8()) | uint64(boolByte(c.Dead))<<3 +} + +func (CraftingTable) Hash() (uint64, uint64) { + return hashCraftingTable, 0 +} + +func (DeadBush) Hash() (uint64, uint64) { + return hashDeadBush, 0 +} + +func (p DecoratedPot) Hash() (uint64, uint64) { + return hashDecoratedPot, uint64(p.Facing) +} + +func (d Deepslate) Hash() (uint64, uint64) { + return hashDeepslate, uint64(d.Type.Uint8()) | uint64(d.Axis)<<2 +} + +func (d DeepslateBricks) Hash() (uint64, uint64) { + return hashDeepslateBricks, uint64(boolByte(d.Cracked)) +} + +func (d DeepslateTiles) Hash() (uint64, uint64) { + return hashDeepslateTiles, uint64(boolByte(d.Cracked)) +} + +func (Diamond) Hash() (uint64, uint64) { + return hashDiamond, 0 +} + +func (d DiamondOre) Hash() (uint64, uint64) { + return hashDiamondOre, uint64(d.Type.Uint8()) +} + +func (d Diorite) Hash() (uint64, uint64) { + return hashDiorite, uint64(boolByte(d.Polished)) +} + +func (d Dirt) Hash() (uint64, uint64) { + return hashDirt, uint64(boolByte(d.Coarse)) +} + +func (DirtPath) Hash() (uint64, uint64) { + return hashDirtPath, 0 +} + +func (d DoubleFlower) Hash() (uint64, uint64) { + return hashDoubleFlower, uint64(boolByte(d.UpperPart)) | uint64(d.Type.Uint8())<<1 +} + +func (d DoubleTallGrass) Hash() (uint64, uint64) { + return hashDoubleTallGrass, uint64(boolByte(d.UpperPart)) | uint64(d.Type.Uint8())<<1 +} + +func (DragonEgg) Hash() (uint64, uint64) { + return hashDragonEgg, 0 +} + +func (DriedKelp) Hash() (uint64, uint64) { + return hashDriedKelp, 0 +} + +func (Dripstone) Hash() (uint64, uint64) { + return hashDripstone, 0 +} + +func (Emerald) Hash() (uint64, uint64) { + return hashEmerald, 0 +} + +func (e EmeraldOre) Hash() (uint64, uint64) { + return hashEmeraldOre, uint64(e.Type.Uint8()) +} + +func (EnchantingTable) Hash() (uint64, uint64) { + return hashEnchantingTable, 0 +} + +func (EndBricks) Hash() (uint64, uint64) { + return hashEndBricks, 0 +} + +func (e EndRod) Hash() (uint64, uint64) { + return hashEndRod, uint64(e.Facing) +} + +func (EndStone) Hash() (uint64, uint64) { + return hashEndStone, 0 +} + +func (c EnderChest) Hash() (uint64, uint64) { + return hashEnderChest, uint64(c.Facing) +} + +func (f Farmland) Hash() (uint64, uint64) { + return hashFarmland, uint64(f.Hydration) +} + +func (Fern) Hash() (uint64, uint64) { + return hashFern, 0 +} + +func (f Fire) Hash() (uint64, uint64) { + return hashFire, uint64(f.Type.Uint8()) | uint64(f.Age)<<1 +} + +func (FletchingTable) Hash() (uint64, uint64) { + return hashFletchingTable, 0 +} + +func (f Flower) Hash() (uint64, uint64) { + return hashFlower, uint64(f.Type.Uint8()) +} + +func (f Froglight) Hash() (uint64, uint64) { + return hashFroglight, uint64(f.Type.Uint8()) | uint64(f.Axis)<<2 +} + +func (f Furnace) Hash() (uint64, uint64) { + return hashFurnace, uint64(f.Facing) | uint64(boolByte(f.Lit))<<2 +} + +func (Glass) Hash() (uint64, uint64) { + return hashGlass, 0 +} + +func (GlassPane) Hash() (uint64, uint64) { + return hashGlassPane, 0 +} + +func (t GlazedTerracotta) Hash() (uint64, uint64) { + return hashGlazedTerracotta, uint64(t.Colour.Uint8()) | uint64(t.Facing)<<4 +} + +func (Glowstone) Hash() (uint64, uint64) { + return hashGlowstone, 0 +} + +func (Gold) Hash() (uint64, uint64) { + return hashGold, 0 +} + +func (g GoldOre) Hash() (uint64, uint64) { + return hashGoldOre, uint64(g.Type.Uint8()) +} + +func (g Granite) Hash() (uint64, uint64) { + return hashGranite, uint64(boolByte(g.Polished)) +} + +func (Grass) Hash() (uint64, uint64) { + return hashGrass, 0 +} + +func (Gravel) Hash() (uint64, uint64) { + return hashGravel, 0 +} + +func (g Grindstone) Hash() (uint64, uint64) { + return hashGrindstone, uint64(g.Attach.Uint8()) | uint64(g.Facing)<<2 +} + +func (h HayBale) Hash() (uint64, uint64) { + return hashHayBale, uint64(h.Axis) +} + +func (Honeycomb) Hash() (uint64, uint64) { + return hashHoneycomb, 0 +} + +func (h Hopper) Hash() (uint64, uint64) { + return hashHopper, uint64(h.Facing) | uint64(boolByte(h.Powered))<<3 +} + +func (InfestedCobblestone) Hash() (uint64, uint64) { + return hashInfestedCobblestone, 0 +} + +func (i InfestedDeepslate) Hash() (uint64, uint64) { + return hashInfestedDeepslate, uint64(i.Axis) +} + +func (InfestedStone) Hash() (uint64, uint64) { + return hashInfestedStone, 0 +} + +func (i InfestedStoneBricks) Hash() (uint64, uint64) { + return hashInfestedStoneBricks, uint64(i.Type.Uint8()) +} + +func (InvisibleBedrock) Hash() (uint64, uint64) { + return hashInvisibleBedrock, 0 +} + +func (Iron) Hash() (uint64, uint64) { + return hashIron, 0 +} + +func (IronBars) Hash() (uint64, uint64) { + return hashIronBars, 0 +} + +func (c IronChain) Hash() (uint64, uint64) { + return hashIronChain, uint64(c.Axis) +} + +func (i IronOre) Hash() (uint64, uint64) { + return hashIronOre, uint64(i.Type.Uint8()) +} + +func (i ItemFrame) Hash() (uint64, uint64) { + return hashItemFrame, uint64(i.Facing) | uint64(boolByte(i.Glowing))<<3 +} + +func (Jukebox) Hash() (uint64, uint64) { + return hashJukebox, 0 +} + +func (k Kelp) Hash() (uint64, uint64) { + return hashKelp, uint64(k.Age) +} + +func (l Ladder) Hash() (uint64, uint64) { + return hashLadder, uint64(l.Facing) +} + +func (l Lantern) Hash() (uint64, uint64) { + return hashLantern, uint64(boolByte(l.Hanging)) | uint64(l.Type.Uint8())<<1 +} + +func (Lapis) Hash() (uint64, uint64) { + return hashLapis, 0 +} + +func (l LapisOre) Hash() (uint64, uint64) { + return hashLapisOre, uint64(l.Type.Uint8()) +} + +func (l Lava) Hash() (uint64, uint64) { + return hashLava, uint64(boolByte(l.Still)) | uint64(l.Depth)<<1 | uint64(boolByte(l.Falling))<<9 +} + +func (l Leaves) Hash() (uint64, uint64) { + return hashLeaves, uint64(l.Type.Uint8()) | uint64(boolByte(l.Persistent))<<4 | uint64(boolByte(l.ShouldUpdate))<<5 +} + +func (l Lectern) Hash() (uint64, uint64) { + return hashLectern, uint64(l.Facing) +} + +func (l Lever) Hash() (uint64, uint64) { + return hashLever, uint64(boolByte(l.Powered)) | uint64(l.Facing)<<1 | uint64(l.Direction)<<4 +} + +func (l Light) Hash() (uint64, uint64) { + return hashLight, uint64(l.Level) +} + +func (LilyPad) Hash() (uint64, uint64) { + return hashLilyPad, 0 +} + +func (l LitPumpkin) Hash() (uint64, uint64) { + return hashLitPumpkin, uint64(l.Facing) +} + +func (l Log) Hash() (uint64, uint64) { + return hashLog, uint64(l.Wood.Uint8()) | uint64(boolByte(l.Stripped))<<4 | uint64(l.Axis)<<5 +} + +func (l Loom) Hash() (uint64, uint64) { + return hashLoom, uint64(l.Facing) +} + +func (Magma) Hash() (uint64, uint64) { + return hashMagma, 0 +} + +func (Melon) Hash() (uint64, uint64) { + return hashMelon, 0 +} + +func (m MelonSeeds) Hash() (uint64, uint64) { + return hashMelonSeeds, uint64(m.Growth) | uint64(m.Direction)<<8 +} + +func (MossCarpet) Hash() (uint64, uint64) { + return hashMossCarpet, 0 +} + +func (Mud) Hash() (uint64, uint64) { + return hashMud, 0 +} + +func (MudBricks) Hash() (uint64, uint64) { + return hashMudBricks, 0 +} + +func (m MuddyMangroveRoots) Hash() (uint64, uint64) { + return hashMuddyMangroveRoots, uint64(m.Axis) +} + +func (NetherBrickFence) Hash() (uint64, uint64) { + return hashNetherBrickFence, 0 +} + +func (n NetherBricks) Hash() (uint64, uint64) { + return hashNetherBricks, uint64(n.Type.Uint8()) +} + +func (NetherGoldOre) Hash() (uint64, uint64) { + return hashNetherGoldOre, 0 +} + +func (NetherQuartzOre) Hash() (uint64, uint64) { + return hashNetherQuartzOre, 0 +} + +func (NetherSprouts) Hash() (uint64, uint64) { + return hashNetherSprouts, 0 +} + +func (n NetherWart) Hash() (uint64, uint64) { + return hashNetherWart, uint64(n.Age) +} + +func (n NetherWartBlock) Hash() (uint64, uint64) { + return hashNetherWartBlock, uint64(boolByte(n.Warped)) +} + +func (Netherite) Hash() (uint64, uint64) { + return hashNetherite, 0 +} + +func (Netherrack) Hash() (uint64, uint64) { + return hashNetherrack, 0 +} + +func (Note) Hash() (uint64, uint64) { + return hashNote, 0 +} + +func (o Obsidian) Hash() (uint64, uint64) { + return hashObsidian, uint64(boolByte(o.Crying)) +} + +func (PackedIce) Hash() (uint64, uint64) { + return hashPackedIce, 0 +} + +func (PackedMud) Hash() (uint64, uint64) { + return hashPackedMud, 0 +} + +func (p PinkPetals) Hash() (uint64, uint64) { + return hashPinkPetals, uint64(p.AdditionalCount) | uint64(p.Facing)<<8 +} + +func (p Planks) Hash() (uint64, uint64) { + return hashPlanks, uint64(p.Wood.Uint8()) +} + +func (Podzol) Hash() (uint64, uint64) { + return hashPodzol, 0 +} + +func (b PolishedBlackstoneBrick) Hash() (uint64, uint64) { + return hashPolishedBlackstoneBrick, uint64(boolByte(b.Cracked)) +} + +func (PolishedTuff) Hash() (uint64, uint64) { + return hashPolishedTuff, 0 +} + +func (p Potato) Hash() (uint64, uint64) { + return hashPotato, uint64(p.Growth) +} + +func (p Prismarine) Hash() (uint64, uint64) { + return hashPrismarine, uint64(p.Type.Uint8()) +} + +func (p Pumpkin) Hash() (uint64, uint64) { + return hashPumpkin, uint64(boolByte(p.Carved)) | uint64(p.Facing)<<1 +} + +func (p PumpkinSeeds) Hash() (uint64, uint64) { + return hashPumpkinSeeds, uint64(p.Growth) | uint64(p.Direction)<<8 +} + +func (Purpur) Hash() (uint64, uint64) { + return hashPurpur, 0 +} + +func (p PurpurPillar) Hash() (uint64, uint64) { + return hashPurpurPillar, uint64(p.Axis) +} + +func (q Quartz) Hash() (uint64, uint64) { + return hashQuartz, uint64(boolByte(q.Smooth)) +} + +func (QuartzBricks) Hash() (uint64, uint64) { + return hashQuartzBricks, 0 +} + +func (q QuartzPillar) Hash() (uint64, uint64) { + return hashQuartzPillar, uint64(q.Axis) +} + +func (RawCopper) Hash() (uint64, uint64) { + return hashRawCopper, 0 +} + +func (RawGold) Hash() (uint64, uint64) { + return hashRawGold, 0 +} + +func (RawIron) Hash() (uint64, uint64) { + return hashRawIron, 0 +} + +func (RedstoneBlock) Hash() (uint64, uint64) { + return hashRedstoneBlock, 0 +} + +func (r RedstoneOre) Hash() (uint64, uint64) { + return hashRedstoneOre, uint64(r.Type.Uint8()) | uint64(boolByte(r.Lit))<<1 +} + +func (t RedstoneTorch) Hash() (uint64, uint64) { + return hashRedstoneTorch, uint64(t.Facing) | uint64(boolByte(t.Lit))<<3 +} + +func (r RedstoneWire) Hash() (uint64, uint64) { + return hashRedstoneWire, uint64(r.Power) +} + +func (ReinforcedDeepslate) Hash() (uint64, uint64) { + return hashReinforcedDeepslate, 0 +} + +func (Resin) Hash() (uint64, uint64) { + return hashResin, 0 +} + +func (r ResinBricks) Hash() (uint64, uint64) { + return hashResinBricks, uint64(boolByte(r.Chiseled)) +} + +func (s Sand) Hash() (uint64, uint64) { + return hashSand, uint64(boolByte(s.Red)) +} + +func (s Sandstone) Hash() (uint64, uint64) { + return hashSandstone, uint64(s.Type.Uint8()) | uint64(boolByte(s.Red))<<2 +} + +func (SeaLantern) Hash() (uint64, uint64) { + return hashSeaLantern, 0 +} + +func (s SeaPickle) Hash() (uint64, uint64) { + return hashSeaPickle, uint64(s.AdditionalCount) | uint64(boolByte(s.Dead))<<8 +} + +func (ShortGrass) Hash() (uint64, uint64) { + return hashShortGrass, 0 +} + +func (Shroomlight) Hash() (uint64, uint64) { + return hashShroomlight, 0 +} + +func (s Sign) Hash() (uint64, uint64) { + return hashSign, uint64(s.Wood.Uint8()) | uint64(s.Attach.Uint8())<<4 +} + +func (s Skull) Hash() (uint64, uint64) { + return hashSkull, uint64(s.Type.Uint8()) | uint64(s.Attach.FaceUint8())<<3 +} + +func (s Slab) Hash() (uint64, uint64) { + return hashSlab, world.BlockHash(s.Block) | uint64(boolByte(s.Top))<<32 | uint64(boolByte(s.Double))<<33 +} + +func (Slime) Hash() (uint64, uint64) { + return hashSlime, 0 +} + +func (SmithingTable) Hash() (uint64, uint64) { + return hashSmithingTable, 0 +} + +func (s Smoker) Hash() (uint64, uint64) { + return hashSmoker, uint64(s.Facing) | uint64(boolByte(s.Lit))<<2 +} + +func (SmoothBasalt) Hash() (uint64, uint64) { + return hashSmoothBasalt, 0 +} + +func (Snow) Hash() (uint64, uint64) { + return hashSnow, 0 +} + +func (SoulSand) Hash() (uint64, uint64) { + return hashSoulSand, 0 +} + +func (SoulSoil) Hash() (uint64, uint64) { + return hashSoulSoil, 0 +} + +func (s Sponge) Hash() (uint64, uint64) { + return hashSponge, uint64(boolByte(s.Wet)) +} + +func (SporeBlossom) Hash() (uint64, uint64) { + return hashSporeBlossom, 0 +} + +func (g StainedGlass) Hash() (uint64, uint64) { + return hashStainedGlass, uint64(g.Colour.Uint8()) +} + +func (p StainedGlassPane) Hash() (uint64, uint64) { + return hashStainedGlassPane, uint64(p.Colour.Uint8()) +} + +func (t StainedTerracotta) Hash() (uint64, uint64) { + return hashStainedTerracotta, uint64(t.Colour.Uint8()) +} + +func (s Stairs) Hash() (uint64, uint64) { + return hashStairs, world.BlockHash(s.Block) | uint64(boolByte(s.UpsideDown))<<32 | uint64(s.Facing)<<33 +} + +func (s Stone) Hash() (uint64, uint64) { + return hashStone, uint64(boolByte(s.Smooth)) +} + +func (s StoneBricks) Hash() (uint64, uint64) { + return hashStoneBricks, uint64(s.Type.Uint8()) +} + +func (s Stonecutter) Hash() (uint64, uint64) { + return hashStonecutter, uint64(s.Facing) +} + +func (s String) Hash() (uint64, uint64) { + return hashString, uint64(boolByte(s.Attached)) | uint64(boolByte(s.Disarmed))<<1 | uint64(boolByte(s.Powered))<<2 | uint64(boolByte(s.Suspended))<<3 +} + +func (c SugarCane) Hash() (uint64, uint64) { + return hashSugarCane, uint64(c.Age) +} + +func (TNT) Hash() (uint64, uint64) { + return hashTNT, 0 +} + +func (Terracotta) Hash() (uint64, uint64) { + return hashTerracotta, 0 +} + +func (t Torch) Hash() (uint64, uint64) { + return hashTorch, uint64(t.Facing) | uint64(t.Type.Uint8())<<3 +} + +func (t Tuff) Hash() (uint64, uint64) { + return hashTuff, uint64(boolByte(t.Chiseled)) +} + +func (t TuffBricks) Hash() (uint64, uint64) { + return hashTuffBricks, uint64(boolByte(t.Chiseled)) +} + +func (v Vines) Hash() (uint64, uint64) { + return hashVines, uint64(boolByte(v.NorthDirection)) | uint64(boolByte(v.EastDirection))<<1 | uint64(boolByte(v.SouthDirection))<<2 | uint64(boolByte(v.WestDirection))<<3 +} + +func (w Wall) Hash() (uint64, uint64) { + return hashWall, world.BlockHash(w.Block) | uint64(w.NorthConnection.Uint8())<<32 | uint64(w.EastConnection.Uint8())<<34 | uint64(w.SouthConnection.Uint8())<<36 | uint64(w.WestConnection.Uint8())<<38 | uint64(boolByte(w.Post))<<40 +} + +func (w Water) Hash() (uint64, uint64) { + return hashWater, uint64(boolByte(w.Still)) | uint64(w.Depth)<<1 | uint64(boolByte(w.Falling))<<9 +} + +func (s WheatSeeds) Hash() (uint64, uint64) { + return hashWheatSeeds, uint64(s.Growth) +} + +func (w Wood) Hash() (uint64, uint64) { + return hashWood, uint64(w.Wood.Uint8()) | uint64(boolByte(w.Stripped))<<4 | uint64(w.Axis)<<5 +} + +func (d WoodDoor) Hash() (uint64, uint64) { + return hashWoodDoor, uint64(d.Wood.Uint8()) | uint64(d.Facing)<<4 | uint64(boolByte(d.Open))<<6 | uint64(boolByte(d.Top))<<7 | uint64(boolByte(d.Right))<<8 +} + +func (w WoodFence) Hash() (uint64, uint64) { + return hashWoodFence, uint64(w.Wood.Uint8()) +} + +func (f WoodFenceGate) Hash() (uint64, uint64) { + return hashWoodFenceGate, uint64(f.Wood.Uint8()) | uint64(f.Facing)<<4 | uint64(boolByte(f.Open))<<6 | uint64(boolByte(f.Lowered))<<7 +} + +func (t WoodTrapdoor) Hash() (uint64, uint64) { + return hashWoodTrapdoor, uint64(t.Wood.Uint8()) | uint64(t.Facing)<<4 | uint64(boolByte(t.Open))<<6 | uint64(boolByte(t.Top))<<7 +} + +func (w Wool) Hash() (uint64, uint64) { + return hashWool, uint64(w.Colour.Uint8()) +} diff --git a/server/block/hay_bale.go b/server/block/hay_bale.go new file mode 100644 index 0000000..e0ddfae --- /dev/null +++ b/server/block/hay_bale.go @@ -0,0 +1,75 @@ +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/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" +) + +// HayBale is a decorative, flammable block that can also be used to +// feed horses, breed llamas, reduce fall damage, and extend campfire smokes. +type HayBale struct { + solid + + // Axis is the axis which the hay bale block faces. + Axis cube.Axis +} + +// Instrument ... +func (HayBale) Instrument() sound.Instrument { + return sound.Banjo() +} + +// EntityLand ... +func (h HayBale) EntityLand(_ cube.Pos, _ *world.Tx, e world.Entity, distance *float64) { + if _, ok := e.(fallDistanceEntity); ok { + *distance *= 0.2 + } +} + +// UseOnBlock ... +func (h HayBale) 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, h) + if !used { + return + } + h.Axis = face.Axis() + + place(tx, pos, h, user, ctx) + return placed(ctx) +} + +// FlammabilityInfo ... +func (HayBale) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(60, 20, false) +} + +// BreakInfo ... +func (h HayBale) BreakInfo() BreakInfo { + return newBreakInfo(0.5, alwaysHarvestable, hoeEffective, oneOf(h)) +} + +// CompostChance ... +func (HayBale) CompostChance() float64 { + return 0.85 +} + +// EncodeItem ... +func (HayBale) EncodeItem() (name string, meta int16) { + return "minecraft:hay_block", 0 +} + +// EncodeBlock ... +func (h HayBale) EncodeBlock() (name string, properties map[string]interface{}) { + return "minecraft:hay_block", map[string]interface{}{"pillar_axis": h.Axis.String(), "deprecated": int32(0)} +} + +// allHayBales ... +func allHayBales() (haybale []world.Block) { + for _, a := range cube.Axes() { + haybale = append(haybale, HayBale{Axis: a}) + } + return +} diff --git a/server/block/honeycomb.go b/server/block/honeycomb.go new file mode 100644 index 0000000..e68f9da --- /dev/null +++ b/server/block/honeycomb.go @@ -0,0 +1,30 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/world/sound" +) + +// Honeycomb is a decorative blocks crafted from honeycombs. +type Honeycomb struct { + solid +} + +// Instrument ... +func (h Honeycomb) Instrument() sound.Instrument { + return sound.Flute() +} + +// BreakInfo ... +func (h Honeycomb) BreakInfo() BreakInfo { + return newBreakInfo(0.6, alwaysHarvestable, nothingEffective, oneOf(h)) +} + +// EncodeItem ... +func (Honeycomb) EncodeItem() (name string, meta int16) { + return "minecraft:honeycomb_block", 0 +} + +// EncodeBlock ... +func (Honeycomb) EncodeBlock() (string, map[string]any) { + return "minecraft:honeycomb_block", nil +} diff --git a/server/block/hopper.go b/server/block/hopper.go new file mode 100644 index 0000000..14a86a6 --- /dev/null +++ b/server/block/hopper.go @@ -0,0 +1,286 @@ +package block + +import ( + "fmt" + "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/item/inventory" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" + "strings" + "sync" +) + +// Hopper is a low-capacity storage block that can be used to collect item entities directly above it, as well as to +// transfer items into and out of other containers. +type Hopper struct { + transparent + sourceWaterDisplacer + + // Facing is the direction the hopper is facing. + Facing cube.Face + // Powered is whether the hopper is powered or not. If the hopper is powered it will be locked and will stop + // moving items into or out of itself. + Powered bool + // CustomName is the custom name of the hopper. This name is displayed when the hopper is opened, and may include + // colour codes. + CustomName string + + // LastTick is the last world tick that the hopper was ticked. + LastTick int64 + // TransferCooldown is the duration in ticks until the hopper can transfer items again. + TransferCooldown int64 + // CollectCooldown is the duration in ticks until the hopper can collect items again. + CollectCooldown int64 + + inventory *inventory.Inventory + viewerMu *sync.RWMutex + viewers map[ContainerViewer]struct{} +} + +// NewHopper creates a new initialised hopper. The inventory is properly initialised. +func NewHopper() Hopper { + m := new(sync.RWMutex) + v := make(map[ContainerViewer]struct{}, 1) + return Hopper{ + inventory: inventory.New(5, func(slot int, _, item item.Stack) { + m.RLock() + defer m.RUnlock() + for viewer := range v { + viewer.ViewSlotChange(slot, item) + } + }), + viewerMu: m, + viewers: v, + } +} + +// Model ... +func (Hopper) Model() world.BlockModel { + return model.Hopper{} +} + +// SideClosed ... +func (Hopper) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// CanRedstoneWireStepDown ... +func (Hopper) CanRedstoneWireStepDown(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// BreakInfo ... +func (h Hopper) BreakInfo() BreakInfo { + return newBreakInfo(3, pickaxeHarvestable, pickaxeEffective, oneOf(Hopper{})).withBlastResistance(24).withBreakHandler(func(pos cube.Pos, tx *world.Tx, u item.User) { + for _, i := range h.Inventory(tx, pos).Clear() { + dropItem(tx, i, pos.Vec3()) + } + }) +} + +// Inventory returns the inventory of the hopper. +func (h Hopper) Inventory(*world.Tx, cube.Pos) *inventory.Inventory { + return h.inventory +} + +// WithName returns the hopper after applying a specific name to the block. +func (h Hopper) WithName(a ...any) world.Item { + h.CustomName = strings.TrimSuffix(fmt.Sprintln(a...), "\n") + return h +} + +// AddViewer adds a viewer to the hopper, so that it is updated whenever the inventory of the hopper is changed. +func (h Hopper) AddViewer(v ContainerViewer, _ *world.Tx, _ cube.Pos) { + h.viewerMu.Lock() + defer h.viewerMu.Unlock() + h.viewers[v] = struct{}{} +} + +// RemoveViewer removes a viewer from the hopper, so that slot updates in the inventory are no longer sent to it. +func (h Hopper) RemoveViewer(v ContainerViewer, _ *world.Tx, _ cube.Pos) { + h.viewerMu.Lock() + defer h.viewerMu.Unlock() + delete(h.viewers, v) +} + +// Activate ... +func (Hopper) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + if opener, ok := u.(ContainerOpener); ok { + opener.OpenBlockContainer(pos, tx) + return true + } + return false +} + +// UseOnBlock ... +func (h Hopper) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, h) + if !used { + return false + } + + //noinspection GoAssignmentToReceiver + h = NewHopper() + h.Facing = cube.FaceDown + if h.Facing != face { + h.Facing = face.Opposite() + } + + place(tx, pos, h, user, ctx) + return placed(ctx) +} + +// Tick ... +func (h Hopper) Tick(currentTick int64, pos cube.Pos, tx *world.Tx) { + h.TransferCooldown-- + h.CollectCooldown-- + h.LastTick = currentTick + + if !h.Powered && h.TransferCooldown <= 0 { + inserted := h.insertItem(pos, tx) + extracted := h.extractItem(pos, tx) + if inserted || extracted { + h.TransferCooldown = 8 + } + } + + tx.SetBlock(pos, h, nil) +} + +// HopperInsertable represents a block that can have its contents inserted into by a hopper. +type HopperInsertable interface { + // InsertItem handles the insert logic for that block. + InsertItem(h Hopper, pos cube.Pos, tx *world.Tx) bool +} + +// insertItem inserts an item into a block that can receive contents from the hopper. +func (h Hopper) insertItem(pos cube.Pos, tx *world.Tx) bool { + destPos := pos.Side(h.Facing) + dest := tx.Block(destPos) + + if e, ok := dest.(HopperInsertable); ok { + return e.InsertItem(h, destPos, tx) + } + + if container, ok := dest.(Container); ok { + for sourceSlot, sourceStack := range h.inventory.Slots() { + if sourceStack.Empty() { + continue + } + + _, err := container.Inventory(tx, pos).AddItem(sourceStack.Grow(-sourceStack.Count() + 1)) + if err != nil { + // The destination is full. + return false + } + + _ = h.inventory.SetItem(sourceSlot, sourceStack.Grow(-1)) + + if hopper, ok := dest.(Hopper); ok { + hopper.TransferCooldown = 8 + tx.SetBlock(destPos, hopper, nil) + } + + return true + } + } + return false +} + +// HopperExtractable represents a block that can have its contents extracted by a hopper. +type HopperExtractable interface { + // ExtractItem handles the extract logic for that block. + ExtractItem(h Hopper, pos cube.Pos, tx *world.Tx) bool +} + +// extractItem extracts an item from a container into the hopper. +func (h Hopper) extractItem(pos cube.Pos, tx *world.Tx) bool { + originPos := pos.Side(cube.FaceUp) + origin := tx.Block(originPos) + + if e, ok := origin.(HopperExtractable); ok { + return e.ExtractItem(h, originPos, tx) + } + + if containerOrigin, ok := origin.(Container); ok { + for slot, stack := range containerOrigin.Inventory(tx, originPos).Slots() { + if stack.Empty() { + // We don't have any items to extract. + continue + } + + _, err := h.inventory.AddItem(stack.Grow(-stack.Count() + 1)) + if err != nil { + // The hopper is full. + continue + } + + _ = containerOrigin.Inventory(tx, originPos).SetItem(slot, stack.Grow(-1)) + + if hopper, ok := origin.(Hopper); ok { + hopper.TransferCooldown = 8 + tx.SetBlock(originPos, hopper, nil) + } + + return true + } + } + return false +} + +// EncodeItem ... +func (Hopper) EncodeItem() (name string, meta int16) { + return "minecraft:hopper", 0 +} + +// EncodeBlock ... +func (h Hopper) EncodeBlock() (string, map[string]any) { + return "minecraft:hopper", map[string]any{ + "facing_direction": int32(h.Facing), + "toggle_bit": h.Powered, + } +} + +// EncodeNBT ... +func (h Hopper) EncodeNBT() map[string]any { + if h.inventory == nil { + facing, powered, customName := h.Facing, h.Powered, h.CustomName + //noinspection GoAssignmentToReceiver + h = NewHopper() + h.Facing, h.Powered, h.CustomName = facing, powered, customName + } + m := map[string]any{ + "Items": nbtconv.InvToNBT(h.inventory), + "TransferCooldown": int32(h.TransferCooldown), + "id": "Hopper", + } + if h.CustomName != "" { + m["CustomName"] = h.CustomName + } + return m +} + +// DecodeNBT ... +func (h Hopper) DecodeNBT(data map[string]any) any { + facing, powered := h.Facing, h.Powered + //noinspection GoAssignmentToReceiver + h = NewHopper() + h.Facing = facing + h.Powered = powered + h.CustomName = nbtconv.String(data, "CustomName") + h.TransferCooldown = int64(nbtconv.Int32(data, "TransferCooldown")) + nbtconv.InvFromNBT(h.inventory, nbtconv.Slice(data, "Items")) + return h +} + +// allHoppers ... +func allHoppers() (hoppers []world.Block) { + for _, f := range cube.Faces() { + hoppers = append(hoppers, Hopper{Facing: f}) + hoppers = append(hoppers, Hopper{Facing: f, Powered: true}) + } + return hoppers +} diff --git a/server/block/infested_cobblestone.go b/server/block/infested_cobblestone.go new file mode 100644 index 0000000..67e5853 --- /dev/null +++ b/server/block/infested_cobblestone.go @@ -0,0 +1,23 @@ +package block + +// InfestedCobblestone is a block that hides a silverfish. It looks identical to cobblestone. +// TODO: spawn a silverfish on break (without silk touch) once silverfish are implemented. +type InfestedCobblestone struct { + solid + flute +} + +// BreakInfo ... +func (i InfestedCobblestone) BreakInfo() BreakInfo { + return newBreakInfo(1, pickaxeHarvestable, pickaxeEffective, silkTouchOnlyDrop(i)).withBlastResistance(0.75) +} + +// EncodeItem ... +func (InfestedCobblestone) EncodeItem() (name string, meta int16) { + return "minecraft:infested_cobblestone", 0 +} + +// EncodeBlock ... +func (InfestedCobblestone) EncodeBlock() (string, map[string]any) { + return "minecraft:infested_cobblestone", nil +} diff --git a/server/block/infested_deepslate.go b/server/block/infested_deepslate.go new file mode 100644 index 0000000..9b4061c --- /dev/null +++ b/server/block/infested_deepslate.go @@ -0,0 +1,53 @@ +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" +) + +// InfestedDeepslate is a block that hides a silverfish. It looks identical to deepslate. +// TODO: spawn a silverfish on break (without silk touch) once silverfish are implemented. +type InfestedDeepslate struct { + solid + flute + + // Axis is the axis which the deepslate faces. + Axis cube.Axis +} + +// BreakInfo ... +func (i InfestedDeepslate) BreakInfo() BreakInfo { + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, silkTouchOnlyDrop(i)).withBlastResistance(0.75) +} + +// UseOnBlock ... +func (i InfestedDeepslate) 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, i) + if !used { + return + } + i.Axis = face.Axis() + + place(tx, pos, i, user, ctx) + return placed(ctx) +} + +// EncodeItem ... +func (InfestedDeepslate) EncodeItem() (name string, meta int16) { + return "minecraft:infested_deepslate", 0 +} + +// EncodeBlock ... +func (i InfestedDeepslate) EncodeBlock() (string, map[string]any) { + return "minecraft:infested_deepslate", map[string]any{"pillar_axis": i.Axis.String()} +} + +// allInfestedDeepslate ... +func allInfestedDeepslate() (s []world.Block) { + for _, axis := range cube.Axes() { + s = append(s, InfestedDeepslate{Axis: axis}) + } + return +} diff --git a/server/block/infested_stone.go b/server/block/infested_stone.go new file mode 100644 index 0000000..09b8923 --- /dev/null +++ b/server/block/infested_stone.go @@ -0,0 +1,23 @@ +package block + +// InfestedStone is a block that hides a silverfish. It looks identical to stone. +// TODO: spawn a silverfish on break (without silk touch) once silverfish are implemented. +type InfestedStone struct { + solid + flute +} + +// BreakInfo ... +func (i InfestedStone) BreakInfo() BreakInfo { + return newBreakInfo(0.75, pickaxeHarvestable, pickaxeEffective, silkTouchOnlyDrop(i)).withBlastResistance(0.75) +} + +// EncodeItem ... +func (InfestedStone) EncodeItem() (name string, meta int16) { + return "minecraft:infested_stone", 0 +} + +// EncodeBlock ... +func (InfestedStone) EncodeBlock() (string, map[string]any) { + return "minecraft:infested_stone", nil +} diff --git a/server/block/infested_stone_bricks.go b/server/block/infested_stone_bricks.go new file mode 100644 index 0000000..6a4eae2 --- /dev/null +++ b/server/block/infested_stone_bricks.go @@ -0,0 +1,38 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/world" +) + +// InfestedStoneBricks is a block that hides a silverfish. It looks identical to stone bricks. +// TODO: spawn a silverfish on break (without silk touch) once silverfish are implemented. +type InfestedStoneBricks struct { + solid + flute + + // Type is the type of stone bricks of the block. + Type StoneBricksType +} + +// BreakInfo ... +func (i InfestedStoneBricks) BreakInfo() BreakInfo { + return newBreakInfo(0.75, pickaxeHarvestable, pickaxeEffective, silkTouchOnlyDrop(i)).withBlastResistance(0.75) +} + +// EncodeItem ... +func (i InfestedStoneBricks) EncodeItem() (name string, meta int16) { + return "minecraft:infested_" + i.Type.String(), 0 +} + +// EncodeBlock ... +func (i InfestedStoneBricks) EncodeBlock() (string, map[string]any) { + return "minecraft:infested_" + i.Type.String(), nil +} + +// allInfestedStoneBricks returns a list of all infested stone bricks variants. +func allInfestedStoneBricks() (s []world.Block) { + for _, t := range StoneBricksTypes() { + s = append(s, InfestedStoneBricks{Type: t}) + } + return +} diff --git a/server/block/invisible_bedrock.go b/server/block/invisible_bedrock.go new file mode 100644 index 0000000..54a6aec --- /dev/null +++ b/server/block/invisible_bedrock.go @@ -0,0 +1,18 @@ +package block + +// InvisibleBedrock is an indestructible, solid block, similar to bedrock and has the appearance of air. +// It shares many of its properties with barriers. +type InvisibleBedrock struct { + transparent + solid +} + +// EncodeItem ... +func (InvisibleBedrock) EncodeItem() (name string, meta int16) { + return "minecraft:invisible_bedrock", 0 +} + +// EncodeBlock ... +func (InvisibleBedrock) EncodeBlock() (string, map[string]any) { + return "minecraft:invisible_bedrock", nil +} diff --git a/server/block/iron.go b/server/block/iron.go new file mode 100644 index 0000000..87903c9 --- /dev/null +++ b/server/block/iron.go @@ -0,0 +1,38 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world/sound" +) + +// Iron is a precious metal block made from 9 iron ingots. +type Iron struct { + solid +} + +// Instrument ... +func (i Iron) Instrument() sound.Instrument { + return sound.IronXylophone() +} + +// BreakInfo ... +func (i Iron) BreakInfo() BreakInfo { + return newBreakInfo(5, func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierStone.HarvestLevel + }, pickaxeEffective, oneOf(i)).withBlastResistance(30) +} + +// PowersBeacon ... +func (Iron) PowersBeacon() bool { + return true +} + +// EncodeItem ... +func (Iron) EncodeItem() (name string, meta int16) { + return "minecraft:iron_block", 0 +} + +// EncodeBlock ... +func (Iron) EncodeBlock() (string, map[string]any) { + return "minecraft:iron_block", nil +} diff --git a/server/block/iron_bars.go b/server/block/iron_bars.go new file mode 100644 index 0000000..24ecdce --- /dev/null +++ b/server/block/iron_bars.go @@ -0,0 +1,33 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// IronBars are blocks that serve a similar purpose to glass panes, but made of iron instead of glass. +type IronBars struct { + transparent + thin + sourceWaterDisplacer +} + +// BreakInfo ... +func (i IronBars) BreakInfo() BreakInfo { + return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(i)).withBlastResistance(30) +} + +// SideClosed ... +func (i IronBars) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// EncodeItem ... +func (IronBars) EncodeItem() (name string, meta int16) { + return "minecraft:iron_bars", 0 +} + +// EncodeBlock ... +func (IronBars) EncodeBlock() (string, map[string]any) { + return "minecraft:iron_bars", nil +} diff --git a/server/block/iron_chain.go b/server/block/iron_chain.go new file mode 100644 index 0000000..a5c50a5 --- /dev/null +++ b/server/block/iron_chain.go @@ -0,0 +1,63 @@ +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/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// IronChain is a metallic decoration block. +type IronChain struct { + transparent + sourceWaterDisplacer + + // Axis is the axis which the chain faces. + Axis cube.Axis +} + +// SideClosed ... +func (IronChain) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// UseOnBlock ... +func (c IronChain) 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, c) + if !used { + return + } + c.Axis = face.Axis() + + place(tx, pos, c, user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (c IronChain) BreakInfo() BreakInfo { + return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(c)).withBlastResistance(30) +} + +// EncodeItem ... +func (IronChain) EncodeItem() (name string, meta int16) { + return "minecraft:iron_chain", 0 +} + +// EncodeBlock ... +func (c IronChain) EncodeBlock() (string, map[string]any) { + return "minecraft:iron_chain", map[string]any{"pillar_axis": c.Axis.String()} +} + +// Model ... +func (c IronChain) Model() world.BlockModel { + return model.Chain{Axis: c.Axis} +} + +// allIronChains ... +func allIronChains() (chains []world.Block) { + for _, axis := range cube.Axes() { + chains = append(chains, IronChain{Axis: axis}) + } + return +} diff --git a/server/block/iron_ore.go b/server/block/iron_ore.go new file mode 100644 index 0000000..6a78ef6 --- /dev/null +++ b/server/block/iron_ore.go @@ -0,0 +1,36 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" +) + +// IronOre is a mineral block found underground. +type IronOre struct { + solid + bassDrum + + // Type is the type of iron ore. + Type OreType +} + +// BreakInfo ... +func (i IronOre) BreakInfo() BreakInfo { + return newBreakInfo(i.Type.Hardness(), func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierStone.HarvestLevel + }, pickaxeEffective, oreDrops(item.RawIron{}, i)).withBlastResistance(15) +} + +// SmeltInfo ... +func (IronOre) SmeltInfo() item.SmeltInfo { + return newOreSmeltInfo(item.NewStack(item.IronIngot{}, 1), 0.7) +} + +// EncodeItem ... +func (i IronOre) EncodeItem() (name string, meta int16) { + return "minecraft:" + i.Type.Prefix() + "iron_ore", 0 +} + +// EncodeBlock ... +func (i IronOre) EncodeBlock() (string, map[string]any) { + return "minecraft:" + i.Type.Prefix() + "iron_ore", nil +} diff --git a/server/block/item_frame.go b/server/block/item_frame.go new file mode 100644 index 0000000..48bdd03 --- /dev/null +++ b/server/block/item_frame.go @@ -0,0 +1,169 @@ +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/v2" +) + +// ItemFrame is a block entity that displays the item or block that is inside it. +type ItemFrame struct { + empty + transparent + sourceWaterDisplacer + + // Facing is the direction from the frame to the block. + Facing cube.Face + // Item is the item that is displayed inside the frame. + Item item.Stack + // Rotations is the number of rotations for the item in the frame. Each rotation is 45 degrees, with the exception + // being maps having 90 degree rotations. + Rotations int + // DropChance is the chance of the item dropping when the frame is broken. In vanilla, this is always 1.0. + DropChance float64 + // Glowing makes the frame the glowing variant. + Glowing bool +} + +// Activate ... +func (i ItemFrame) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, ctx *item.UseContext) bool { + if !i.Item.Empty() { + // TODO: Item frames with maps can only be rotated four times. + i.Rotations = (i.Rotations + 1) % 8 + tx.PlaySound(pos.Vec3Centre(), sound.ItemFrameRotate{}) + } else if held, _ := u.HeldItems(); !held.Empty() { + 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) + tx.PlaySound(pos.Vec3Centre(), sound.ItemAdd{}) + } else { + return true + } + + tx.SetBlock(pos, i, nil) + return true +} + +// Punch ... +func (i ItemFrame) Punch(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User) { + if i.Item.Empty() { + return + } + + if g, ok := u.(interface { + GameMode() world.GameMode + }); ok { + if rand.Float64() <= i.DropChance && !g.GameMode().CreativeInventory() { + dropItem(tx, i.Item, pos.Vec3Centre()) + } + } + i.Item, i.Rotations = item.Stack{}, 0 + tx.PlaySound(pos.Vec3Centre(), sound.ItemFrameRemove{}) + tx.SetBlock(pos, i, nil) +} + +// UseOnBlock ... +func (i ItemFrame) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, face, used := firstReplaceable(tx, pos, face, i) + if !used { + return false + } + if _, ok := tx.Block(pos.Side(face.Opposite())).Model().(model.Empty); ok { + // TODO: Allow exceptions for pressure plates. + return false + } + i.Facing = face.Opposite() + i.DropChance = 1.0 + + place(tx, pos, i, user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (i ItemFrame) BreakInfo() BreakInfo { + return newBreakInfo(0.25, alwaysHarvestable, nothingEffective, oneOf(ItemFrame{Glowing: i.Glowing})).withBreakHandler(func(pos cube.Pos, tx *world.Tx, _ item.User) { + if !i.Item.Empty() { + dropItem(tx, i.Item, pos.Vec3Centre()) + } + }) +} + +// EncodeItem ... +func (i ItemFrame) EncodeItem() (name string, meta int16) { + if i.Glowing { + return "minecraft:glow_frame", 0 + } + return "minecraft:frame", 0 +} + +// EncodeBlock ... +func (i ItemFrame) EncodeBlock() (name string, properties map[string]any) { + name = "minecraft:frame" + if i.Glowing { + name = "minecraft:glow_frame" + } + return name, map[string]any{ + "facing_direction": int32(i.Facing.Opposite()), + "item_frame_map_bit": uint8(0), // TODO: When maps are added, set this to true if the item is a map. + "item_frame_photo_bit": uint8(0), // Only implemented in Education Edition. + } +} + +// DecodeNBT ... +func (i ItemFrame) DecodeNBT(data map[string]any) any { + i.DropChance = float64(nbtconv.Float32(data, "ItemDropChance")) + i.Rotations = int(nbtconv.Uint8(data, "ItemRotation")) + i.Item = nbtconv.MapItem(data, "Item") + return i +} + +// EncodeNBT ... +func (i ItemFrame) EncodeNBT() map[string]any { + m := map[string]any{ + "ItemDropChance": float32(i.DropChance), + "ItemRotation": uint8(i.Rotations), + "id": "ItemFrame", + } + if i.Glowing { + m["id"] = "GlowItemFrame" + } + if !i.Item.Empty() { + m["Item"] = nbtconv.WriteItem(i.Item, true) + } + return m +} + +// Pick returns the item that is picked when the block is picked. +func (i ItemFrame) Pick() item.Stack { + if i.Item.Empty() { + return item.NewStack(ItemFrame{Glowing: i.Glowing}, 1) + } + return i.Item.Grow(-i.Item.Count() + 1) +} + +// SideClosed ... +func (ItemFrame) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// NeighbourUpdateTick ... +func (i ItemFrame) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if _, ok := tx.Block(pos.Side(i.Facing)).Model().(model.Empty); ok { + // TODO: Allow exceptions for pressure plates. + breakBlock(i, pos, tx) + } +} + +// allItemFrames ... +func allItemFrames() (frames []world.Block) { + for _, f := range cube.Faces() { + frames = append(frames, ItemFrame{Facing: f, Glowing: true}) + frames = append(frames, ItemFrame{Facing: f, Glowing: false}) + } + return +} diff --git a/server/block/jukebox.go b/server/block/jukebox.go new file mode 100644 index 0000000..20520be --- /dev/null +++ b/server/block/jukebox.go @@ -0,0 +1,134 @@ +package block + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/block/cube" + "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" + "time" +) + +// Jukebox is a block used to play music discs. +type Jukebox struct { + solid + bass + + // Item is the music disc played by the jukebox. + Item item.Stack +} + +// InsertItem ... +func (j Jukebox) InsertItem(h Hopper, pos cube.Pos, tx *world.Tx) bool { + if !j.Item.Empty() { + return false + } + + for sourceSlot, sourceStack := range h.inventory.Slots() { + if sourceStack.Empty() { + continue + } + + if m, ok := sourceStack.Item().(item.MusicDisc); ok { + j.Item = sourceStack + tx.SetBlock(pos, j, nil) + _ = h.inventory.SetItem(sourceSlot, sourceStack.Grow(-1)) + tx.PlaySound(pos.Vec3Centre(), sound.MusicDiscPlay{DiscType: m.DiscType}) + return true + } + } + + return false +} + +// ExtractItem ... +func (j Jukebox) ExtractItem(_ Hopper, _ cube.Pos, _ *world.Tx) bool { + // TODO: This functionality requires redstone to be implemented. + return false +} + +// FuelInfo ... +func (j Jukebox) FuelInfo() item.FuelInfo { + return newFuelInfo(time.Second * 15) +} + +// BreakInfo ... +func (j Jukebox) BreakInfo() BreakInfo { + return newBreakInfo(2, alwaysHarvestable, axeEffective, oneOf(Jukebox{})).withBlastResistance(30).withBreakHandler(func(pos cube.Pos, tx *world.Tx, u item.User) { + if _, hasDisc := j.Disc(); hasDisc { + dropItem(tx, j.Item, pos.Vec3()) + tx.PlaySound(pos.Vec3Centre(), sound.MusicDiscEnd{}) + } + }) +} + +// jukeboxUser represents an item.User that can use a jukebox. +type jukeboxUser interface { + item.User + // SendJukeboxPopup sends a jukebox popup to the item.User. + SendJukeboxPopup(a ...any) +} + +// Activate ... +func (j Jukebox) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, ctx *item.UseContext) bool { + if _, hasDisc := j.Disc(); hasDisc { + dropItem(tx, j.Item, pos.Side(cube.FaceUp).Vec3Middle()) + + j.Item = item.Stack{} + tx.SetBlock(pos, j, nil) + tx.PlaySound(pos.Vec3Centre(), sound.MusicDiscEnd{}) + } else if held, _ := u.HeldItems(); !held.Empty() { + if m, ok := held.Item().(item.MusicDisc); ok { + j.Item = held + + tx.SetBlock(pos, j, nil) + tx.PlaySound(pos.Vec3Centre(), sound.MusicDiscEnd{}) + ctx.SubtractFromCount(1) + + tx.PlaySound(pos.Vec3Centre(), sound.MusicDiscPlay{DiscType: m.DiscType}) + if u, ok := u.(jukeboxUser); ok { + u.SendJukeboxPopup(fmt.Sprintf("Now playing: %v - %v", m.DiscType.Author(), m.DiscType.DisplayName())) + } + } + } + return true +} + +// Disc returns the currently playing music disc +func (j Jukebox) Disc() (sound.DiscType, bool) { + if !j.Item.Empty() { + if m, ok := j.Item.Item().(item.MusicDisc); ok { + return m.DiscType, true + } + } + return sound.DiscType{}, false +} + +// EncodeNBT ... +func (j Jukebox) EncodeNBT() map[string]any { + m := map[string]any{"id": "Jukebox"} + if _, hasDisc := j.Disc(); hasDisc { + m["RecordItem"] = nbtconv.WriteItem(j.Item, true) + } + return m +} + +// DecodeNBT ... +func (j Jukebox) DecodeNBT(data map[string]any) any { + s := nbtconv.MapItem(data, "RecordItem") + if _, ok := s.Item().(item.MusicDisc); ok { + j.Item = s + } + return j +} + +// EncodeItem ... +func (Jukebox) EncodeItem() (name string, meta int16) { + return "minecraft:jukebox", 0 +} + +// EncodeBlock ... +func (Jukebox) EncodeBlock() (string, map[string]any) { + return "minecraft:jukebox", nil +} diff --git a/server/block/kelp.go b/server/block/kelp.go new file mode 100644 index 0000000..7140bbf --- /dev/null +++ b/server/block/kelp.go @@ -0,0 +1,155 @@ +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" +) + +// Kelp is an underwater block which can grow on top of solids underwater. +type Kelp struct { + empty + transparent + sourceWaterDisplacer + + // Age is the age of the kelp block which can be 0-25. If age is 25, kelp won't grow any further. + Age int +} + +// SmeltInfo ... +func (k Kelp) SmeltInfo() item.SmeltInfo { + return newFoodSmeltInfo(item.NewStack(item.DriedKelp{}, 1), 0.1) +} + +// BoneMeal ... +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) + if kelp, ok := block.(Kelp); ok { + if kelp.Age == 25 { + break + } + continue + } + if water, ok := block.(Water); ok && water.Depth == 8 { + tx.SetBlock(currentPos, Kelp{Age: k.Age + 1}, nil) + return item.BoneMealResultSmall + } + break + } + return item.BoneMealResultNone +} + +// BreakInfo ... +func (k Kelp) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(k)) +} + +// CompostChance ... +func (Kelp) CompostChance() float64 { + return 0.3 +} + +// EncodeItem ... +func (Kelp) EncodeItem() (name string, meta int16) { + return "minecraft:kelp", 0 +} + +// EncodeBlock ... +func (k Kelp) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:kelp", map[string]any{"kelp_age": int32(k.Age)} +} + +// SideClosed will always return false since kelp doesn't close any side. +func (Kelp) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// withRandomAge returns a new Kelp block with its age value randomized between 0 and 24. +func (k Kelp) withRandomAge() Kelp { + k.Age = rand.IntN(25) + return k +} + +// UseOnBlock ... +func (k Kelp) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, k) + if !used { + return + } + + below := pos.Side(cube.FaceDown) + belowBlock := tx.Block(below) + if _, kelp := belowBlock.(Kelp); !kelp { + if !belowBlock.Model().FaceSolid(below, cube.FaceUp, tx) { + return false + } + } + + liquid, ok := tx.Liquid(pos) + if !ok { + return false + } else if _, ok := liquid.(Water); !ok || liquid.LiquidDepth() < 8 { + return false + } + + // When first placed, kelp gets a random age between 0 and 24. + place(tx, pos, k.withRandomAge(), user, ctx) + return placed(ctx) +} + +// NeighbourUpdateTick ... +func (k Kelp) NeighbourUpdateTick(pos, changedNeighbour cube.Pos, tx *world.Tx) { + if _, ok := tx.Liquid(pos); !ok { + breakBlock(k, pos, tx) + return + } + if changedNeighbour[1]-1 == pos.Y() { + // When a kelp block is broken above, the kelp block underneath it gets a new random age. + tx.SetBlock(pos, k.withRandomAge(), nil) + } + + below := pos.Side(cube.FaceDown) + belowBlock := tx.Block(below) + if _, kelp := belowBlock.(Kelp); !kelp { + if !belowBlock.Model().FaceSolid(below, cube.FaceUp, tx) { + breakBlock(k, pos, tx) + } + } +} + +// RandomTick ... +func (k Kelp) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + // Every random tick, there's a 14% chance for Kelp to grow if its age is below 25. + if r.IntN(100) < 15 && k.Age < 25 { + abovePos := pos.Side(cube.FaceUp) + + liquid, ok := tx.Liquid(abovePos) + + // For kelp to grow, there must be only water above. + if !ok { + return + } else if _, ok := liquid.(Water); ok { + switch tx.Block(abovePos).(type) { + case Air, Water: + tx.SetBlock(abovePos, Kelp{Age: k.Age + 1}, nil) + if liquid.LiquidDepth() < 8 { + // When kelp grows into a water block, the water block becomes a source block. + tx.SetLiquid(abovePos, Water{Still: true, Depth: 8, Falling: false}) + } + } + } + } +} + +// allKelp returns all possible states of a kelp block. +func allKelp() (b []world.Block) { + for i := 0; i < 26; i++ { + b = append(b, Kelp{Age: i}) + } + return +} diff --git a/server/block/ladder.go b/server/block/ladder.go new file mode 100644 index 0000000..91a5200 --- /dev/null +++ b/server/block/ladder.go @@ -0,0 +1,103 @@ +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/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" + "time" +) + +// Ladder is a wooden block used for climbing walls either vertically or horizontally. They can be placed only on +// the sides of other blocks. +type Ladder struct { + transparent + sourceWaterDisplacer + + // Facing is the side of the block the ladder is currently attached to. + Facing cube.Direction +} + +// NeighbourUpdateTick ... +func (l Ladder) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if _, ok := tx.Block(pos.Side(l.Facing.Face().Opposite())).(LightDiffuser); ok { + breakBlock(l, pos, tx) + } +} + +// UseOnBlock ... +func (l Ladder) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, face, used := firstReplaceable(tx, pos, face, l) + if !used { + return false + } + if face == cube.FaceUp || face == cube.FaceDown { + return false + } + if _, ok := tx.Block(pos.Side(face.Opposite())).(LightDiffuser); ok { + found := false + for _, i := range []cube.Face{cube.FaceSouth, cube.FaceNorth, cube.FaceEast, cube.FaceWest} { + if diffuser, ok := tx.Block(pos.Side(i)).(LightDiffuser); !ok || diffuser.LightDiffusionLevel() == 15 { + found = true + face = i.Opposite() + break + } + } + if !found { + return false + } + } + l.Facing = face.Direction() + + place(tx, pos, l, user, ctx) + return placed(ctx) +} + +// EntityInside ... +func (l Ladder) EntityInside(_ cube.Pos, _ *world.Tx, e world.Entity) { + if fallEntity, ok := e.(fallDistanceEntity); ok { + fallEntity.ResetFallDistance() + } +} + +// SideClosed ... +func (l Ladder) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// BreakInfo ... +func (l Ladder) BreakInfo() BreakInfo { + return newBreakInfo(0.4, alwaysHarvestable, axeEffective, oneOf(l)) +} + +// FuelInfo ... +func (Ladder) FuelInfo() item.FuelInfo { + return newFuelInfo(time.Second * 15) +} + +// EncodeItem ... +func (l Ladder) EncodeItem() (name string, meta int16) { + return "minecraft:ladder", 0 +} + +// EncodeBlock ... +func (l Ladder) EncodeBlock() (string, map[string]any) { + if l.Facing == unknownDirection { + return "minecraft:ladder", map[string]any{"facing_direction": int32(0)} + } + return "minecraft:ladder", map[string]any{"facing_direction": int32(l.Facing + 2)} +} + +// Model ... +func (l Ladder) Model() world.BlockModel { + return model.Ladder{Facing: l.Facing} +} + +// allLadders ... +func allLadders() (b []world.Block) { + for _, f := range append(cube.Directions(), unknownDirection) { + b = append(b, Ladder{Facing: f}) + } + return +} diff --git a/server/block/lantern.go b/server/block/lantern.go new file mode 100644 index 0000000..1177ae7 --- /dev/null +++ b/server/block/lantern.go @@ -0,0 +1,110 @@ +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/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// Lantern is a light emitting block. +type Lantern struct { + transparent + sourceWaterDisplacer + + // Hanging determines if a lantern is hanging off a block. + Hanging bool + // Type of fire lighting the lantern. + Type FireType +} + +// Model ... +func (l Lantern) Model() world.BlockModel { + return model.Lantern{Hanging: l.Hanging} +} + +// NeighbourUpdateTick ... +func (l Lantern) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if l.Hanging { + up := pos.Side(cube.FaceUp) + if _, ok := tx.Block(up).(IronChain); !ok && !tx.Block(up).Model().FaceSolid(up, cube.FaceDown, tx) { + breakBlock(l, pos, tx) + } + } else { + down := pos.Side(cube.FaceDown) + if !tx.Block(down).Model().FaceSolid(down, cube.FaceUp, tx) { + breakBlock(l, pos, tx) + } + } +} + +// LightEmissionLevel ... +func (l Lantern) LightEmissionLevel() uint8 { + return l.Type.LightLevel() +} + +// UseOnBlock ... +func (l Lantern) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, face, used := firstReplaceable(tx, pos, face, l) + if !used { + return false + } + if face == cube.FaceDown { + upPos := pos.Side(cube.FaceUp) + if _, ok := tx.Block(upPos).(IronChain); !ok && !tx.Block(upPos).Model().FaceSolid(upPos, cube.FaceDown, tx) { + face = cube.FaceUp + } + } + if face != cube.FaceDown { + downPos := pos.Side(cube.FaceDown) + if !tx.Block(downPos).Model().FaceSolid(downPos, cube.FaceUp, tx) { + return false + } + } + l.Hanging = face == cube.FaceDown + + place(tx, pos, l, user, ctx) + return placed(ctx) +} + +// SideClosed ... +func (l Lantern) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// BreakInfo ... +func (l Lantern) BreakInfo() BreakInfo { + return newBreakInfo(3.5, pickaxeHarvestable, pickaxeEffective, oneOf(l)) +} + +// EncodeItem ... +func (l Lantern) EncodeItem() (name string, meta int16) { + switch l.Type { + case NormalFire(): + return "minecraft:lantern", 0 + case SoulFire(): + return "minecraft:soul_lantern", 0 + } + panic("invalid fire type") +} + +// EncodeBlock ... +func (l Lantern) EncodeBlock() (name string, properties map[string]any) { + switch l.Type { + case NormalFire(): + return "minecraft:lantern", map[string]any{"hanging": l.Hanging} + case SoulFire(): + return "minecraft:soul_lantern", map[string]any{"hanging": l.Hanging} + } + panic("invalid fire type") +} + +// allLanterns ... +func allLanterns() (lanterns []world.Block) { + for _, f := range FireTypes() { + lanterns = append(lanterns, Lantern{Hanging: false, Type: f}) + lanterns = append(lanterns, Lantern{Hanging: true, Type: f}) + } + return +} diff --git a/server/block/lapis.go b/server/block/lapis.go new file mode 100644 index 0000000..38266f3 --- /dev/null +++ b/server/block/lapis.go @@ -0,0 +1,27 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" +) + +// Lapis is a decorative mineral block that is crafted from lapis lazuli. +type Lapis struct { + solid +} + +// BreakInfo ... +func (l Lapis) BreakInfo() BreakInfo { + return newBreakInfo(3, func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierStone.HarvestLevel + }, pickaxeEffective, oneOf(l)) +} + +// EncodeItem ... +func (Lapis) EncodeItem() (name string, meta int16) { + return "minecraft:lapis_block", 0 +} + +// EncodeBlock ... +func (Lapis) EncodeBlock() (string, map[string]any) { + return "minecraft:lapis_block", nil +} diff --git a/server/block/lapis_ore.go b/server/block/lapis_ore.go new file mode 100644 index 0000000..56b12ab --- /dev/null +++ b/server/block/lapis_ore.go @@ -0,0 +1,36 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" +) + +// LapisOre is an ore block from which lapis lazuli is obtained. +type LapisOre struct { + solid + bassDrum + + // Type is the type of lapis ore. + Type OreType +} + +// BreakInfo ... +func (l LapisOre) BreakInfo() BreakInfo { + return newBreakInfo(l.Type.Hardness(), func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierStone.HarvestLevel + }, pickaxeEffective, multiOreDrops(item.LapisLazuli{}, l, 4, 9)).withXPDropRange(2, 5).withBlastResistance(15) +} + +// SmeltInfo ... +func (LapisOre) SmeltInfo() item.SmeltInfo { + return newOreSmeltInfo(item.NewStack(item.LapisLazuli{}, 1), 0.2) +} + +// EncodeItem ... +func (l LapisOre) EncodeItem() (name string, meta int16) { + return "minecraft:" + l.Type.Prefix() + "lapis_ore", 0 +} + +// EncodeBlock ... +func (l LapisOre) EncodeBlock() (string, map[string]any) { + return "minecraft:" + l.Type.Prefix() + "lapis_ore", nil +} diff --git a/server/block/lava.go b/server/block/lava.go new file mode 100644 index 0000000..9899d7b --- /dev/null +++ b/server/block/lava.go @@ -0,0 +1,249 @@ +package block + +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/world" + "github.com/df-mc/dragonfly/server/world/sound" +) + +// Lava is a light-emitting fluid block that causes fire damage. +type Lava struct { + empty + + // Still makes the lava not spread whenever it is updated. Still lava cannot be acquired in the game + // without world editing. + Still bool + // Depth is the depth of the water. This is a number from 1-8, where 8 is a source block and 1 is the + // smallest possible lava block. + Depth int + // Falling specifies if the lava is falling. Falling lava will always appear as a source block, but its + // behaviour differs when it starts spreading. + Falling bool +} + +// neighboursLavaFlammable returns true if one a block adjacent to the passed position is flammable. +func neighboursLavaFlammable(pos cube.Pos, tx *world.Tx) bool { + for i := cube.Face(0); i < 6; i++ { + if flammable, ok := tx.Block(pos.Side(i)).(Flammable); ok && flammable.FlammabilityInfo().LavaFlammable { + return true + } + } + return false +} + +// ReplaceableBy ... +func (l Lava) ReplaceableBy(b world.Block) bool { + if _, ok := b.(LiquidRemovable); ok { + _, displacer := b.(world.LiquidDisplacer) + _, liquid := b.(world.Liquid) + return displacer || liquid + } + return true +} + +// EntityInside ... +func (l Lava) EntityInside(_ cube.Pos, _ *world.Tx, e world.Entity) { + if fallEntity, ok := e.(fallDistanceEntity); ok { + fallEntity.ResetFallDistance() + } + if flammable, ok := e.(flammableEntity); ok { + if l, ok := e.(livingEntity); ok { + l.Hurt(4, LavaDamageSource{}) + } + flammable.SetOnFire(15 * time.Second) + } +} + +// RandomTick ... +func (l Lava) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + i := r.IntN(3) + if i > 0 { + for j := 0; j < i; j++ { + pos = pos.Add(cube.Pos{r.IntN(3) - 1, 1, r.IntN(3) - 1}) + if _, ok := tx.Block(pos).(Air); ok { + if neighboursLavaFlammable(pos, tx) { + Fire{}.Start(tx, pos) + } + } + } + } else { + for j := 0; j < 3; j++ { + pos = pos.Add(cube.Pos{r.IntN(3) - 1, 0, r.IntN(3) - 1}) + if _, ok := tx.Block(pos.Side(cube.FaceUp)).(Air); ok { + if flammable, ok := tx.Block(pos).(Flammable); ok && flammable.FlammabilityInfo().LavaFlammable && flammable.FlammabilityInfo().Encouragement > 0 { + Fire{}.Start(tx, pos) + } + } + } + } +} + +// HasLiquidDrops ... +func (Lava) HasLiquidDrops() bool { + return false +} + +// LiquidRemoveBlock plays a fizz sound at the position of the removed block. +func (Lava) LiquidRemoveBlock(pos cube.Pos, tx *world.Tx, _ world.Block) { + tx.PlaySound(pos.Vec3Centre(), sound.Fizz{}) +} + +// LightDiffusionLevel always returns 2. +func (Lava) LightDiffusionLevel() uint8 { + return 2 +} + +// LightEmissionLevel returns 15. +func (Lava) LightEmissionLevel() uint8 { + return 15 +} + +// NeighbourUpdateTick ... +func (l Lava) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if !l.Harden(pos, tx, nil) { + tx.ScheduleBlockUpdate(pos, l, tx.World().Dimension().LavaSpreadDuration()) + } +} + +// ScheduledTick ... +func (l Lava) ScheduledTick(pos cube.Pos, tx *world.Tx, _ *rand.Rand) { + if !l.Harden(pos, tx, nil) { + tickLiquid(l, pos, tx) + } +} + +// LiquidDepth returns the depth of the lava. +func (l Lava) LiquidDepth() int { + return l.Depth +} + +// SpreadDecay always returns 2. +func (Lava) SpreadDecay() int { + return 2 +} + +// WithDepth returns a new Lava block with the depth passed and falling if set to true. +func (l Lava) WithDepth(depth int, falling bool) world.Liquid { + l.Depth = depth + l.Falling = falling + l.Still = false + return l +} + +// LiquidFalling checks if the lava is falling. +func (l Lava) LiquidFalling() bool { + return l.Falling +} + +// BlastResistance always returns 500. +func (Lava) BlastResistance() float64 { + return 500 +} + +// LiquidType returns 10 as a unique identifier for the lava liquid. +func (Lava) LiquidType() string { + return "lava" +} + +// Harden handles the hardening logic of lava. +func (l Lava) Harden(pos cube.Pos, tx *world.Tx, flownIntoBy *cube.Pos) bool { + var ok bool + var water, b world.Block + + if flownIntoBy == nil { + var water, b world.Block + _, soulSoilFound := tx.Block(pos.Side(cube.FaceDown)).(SoulSoil) + pos.Neighbours(func(neighbour cube.Pos) { + if b != nil || neighbour[1] == pos[1]-1 { + return + } + if _, ok := tx.Block(neighbour).(BlueIce); ok { + if soulSoilFound { + b = Basalt{} + } + return + } + if waterBlock, ok := tx.Block(neighbour).(Water); ok { + water = waterBlock + if l.Depth == 8 && !l.Falling { + b = Obsidian{} + return + } + b = Cobblestone{} + } + }, tx.Range()) + if b != nil { + ctx := event.C(tx) + if tx.World().Handler().HandleLiquidHarden(ctx, pos, l, water, b); ctx.Cancelled() { + return false + } + tx.PlaySound(pos.Vec3Centre(), sound.Fizz{}) + tx.SetBlock(pos, b, nil) + return true + } + return false + } + water, ok = tx.Block(*flownIntoBy).(Water) + if !ok { + return false + } + + if l.Depth == 8 && !l.Falling { + b = Obsidian{} + } else { + b = Cobblestone{} + } + ctx := event.C(tx) + if tx.World().Handler().HandleLiquidHarden(ctx, pos, l, water, b); ctx.Cancelled() { + return false + } + tx.SetBlock(pos, b, nil) + tx.PlaySound(pos.Vec3Centre(), sound.Fizz{}) + return true +} + +// EncodeBlock ... +func (l Lava) EncodeBlock() (name string, properties map[string]any) { + if l.Depth < 1 || l.Depth > 8 { + panic("invalid lava depth, must be between 1 and 8") + } + v := 8 - l.Depth + if l.Falling { + v += 8 + } + if l.Still { + return "minecraft:lava", map[string]any{"liquid_depth": int32(v)} + } + return "minecraft:flowing_lava", map[string]any{"liquid_depth": int32(v)} +} + +// allLava returns a list of all lava states. +func allLava() (b []world.Block) { + f := func(still, falling bool) { + b = append(b, Lava{Still: still, Falling: falling, Depth: 8}) + b = append(b, Lava{Still: still, Falling: falling, Depth: 7}) + b = append(b, Lava{Still: still, Falling: falling, Depth: 6}) + b = append(b, Lava{Still: still, Falling: falling, Depth: 5}) + b = append(b, Lava{Still: still, Falling: falling, Depth: 4}) + b = append(b, Lava{Still: still, Falling: falling, Depth: 3}) + b = append(b, Lava{Still: still, Falling: falling, Depth: 2}) + b = append(b, Lava{Still: still, Falling: falling, Depth: 1}) + } + f(true, true) + f(true, false) + f(false, false) + f(false, true) + return +} + +// LavaDamageSource is used for damage caused by being in lava. +type LavaDamageSource struct{} + +func (LavaDamageSource) ReducedByResistance() bool { return true } +func (LavaDamageSource) ReducedByArmour() bool { return true } +func (LavaDamageSource) Fire() bool { return true } +func (LavaDamageSource) IgnoreTotem() bool { return false } diff --git a/server/block/leaves.go b/server/block/leaves.go new file mode 100644 index 0000000..668043e --- /dev/null +++ b/server/block/leaves.go @@ -0,0 +1,162 @@ +package block + +import ( + "math/rand/v2" + + "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/world" + "github.com/go-gl/mathgl/mgl64" +) + +// Leaves are blocks that grow as part of trees which mainly drop saplings and sticks. +type Leaves struct { + leaves + sourceWaterDisplacer + + // Type is the type of the leaves. + Type LeavesType + // Persistent specifies if the leaves are persistent, meaning they will not decay as a result of no wood + // being nearby. + Persistent bool + + ShouldUpdate bool +} + +// UseOnBlock makes leaves persistent when they are placed so that they don't decay. +func (l Leaves) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, l) + if !used { + return + } + l.Persistent = true + + place(tx, pos, l, user, ctx) + return placed(ctx) +} + +// findLog ... +func findLog(pos cube.Pos, tx *world.Tx, visited *[]cube.Pos, distance int) bool { + for _, v := range *visited { + if v == pos { + return false + } + } + *visited = append(*visited, pos) + + if log, ok := tx.Block(pos).(Log); ok && !log.Stripped { + return true + } + if _, ok := tx.Block(pos).(Leaves); !ok || distance > 6 { + return false + } + logFound := false + pos.Neighbours(func(neighbour cube.Pos) { + if !logFound && findLog(neighbour, tx, visited, distance+1) { + logFound = true + } + }, tx.Range()) + return logFound +} + +// RandomTick ... +func (l Leaves) RandomTick(pos cube.Pos, tx *world.Tx, _ *rand.Rand) { + if !l.Persistent && l.ShouldUpdate { + if findLog(pos, tx, &[]cube.Pos{}, 0) { + l.ShouldUpdate = false + tx.SetBlock(pos, l, nil) + return + } + ctx := event.C(tx) + if tx.World().Handler().HandleLeavesDecay(ctx, pos); ctx.Cancelled() { + // Prevent immediate re-updating. + l.ShouldUpdate = false + tx.SetBlock(pos, l, nil) + return + } + tx.SetBlock(pos, nil, nil) + for _, drop := range l.BreakInfo().Drops(item.ToolNone{}, nil) { + dropItem(tx, drop, pos.Vec3Centre()) + } + } +} + +// NeighbourUpdateTick ... +func (l Leaves) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if !l.Persistent && !l.ShouldUpdate { + l.ShouldUpdate = true + tx.SetBlock(pos, l, nil) + } +} + +// FlammabilityInfo ... +func (l Leaves) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(30, 60, true) +} + +// BreakInfo ... +func (l Leaves) BreakInfo() BreakInfo { + return newBreakInfo(0.2, alwaysHarvestable, func(t item.Tool) bool { + return t.ToolType() == item.TypeShears || t.ToolType() == item.TypeHoe + }, func(t item.Tool, enchantments []item.Enchantment) []item.Stack { + if t.ToolType() == item.TypeShears || hasSilkTouch(enchantments) { + return []item.Stack{item.NewStack(l, 1)} + } + fortune := fortuneLevel(enchantments) + var drops []item.Stack + + // TODO: Drop saplings. + + stickChances := []float64{0.02, 0.022222222, 0.025, 0.033333333} + if rand.Float64() < stickChances[min(fortune, 3)] { + drops = append(drops, item.NewStack(item.Stick{}, rand.IntN(2)+1)) + } + if wood, ok := l.Type.Wood(); ok && (wood == OakWood() || wood == DarkOakWood()) { + appleChances := []float64{0.005, 0.005555556, 0.00625, 0.008333333} + if rand.Float64() < appleChances[min(fortune, 3)] { + drops = append(drops, item.NewStack(item.Apple{}, 1)) + } + } + return drops + }) +} + +// CompostChance ... +func (Leaves) CompostChance() float64 { + return 0.3 +} + +// EncodeItem ... +func (l Leaves) EncodeItem() (name string, meta int16) { + return "minecraft:" + l.Type.String(), 0 +} + +// LightDiffusionLevel ... +func (Leaves) LightDiffusionLevel() uint8 { + return 1 +} + +// SideClosed ... +func (Leaves) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// EncodeBlock ... +func (l Leaves) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:" + l.Type.String(), map[string]any{"persistent_bit": l.Persistent, "update_bit": l.ShouldUpdate} +} + +// allLeaves returns a list of all possible leaves states. +func allLeaves() (leaves []world.Block) { + f := func(persistent, update bool) { + for _, t := range LeavesTypes() { + leaves = append(leaves, Leaves{Type: t, Persistent: persistent, ShouldUpdate: update}) + } + } + f(true, true) + f(true, false) + f(false, true) + f(false, false) + return +} diff --git a/server/block/leaves_type.go b/server/block/leaves_type.go new file mode 100644 index 0000000..2b43f70 --- /dev/null +++ b/server/block/leaves_type.go @@ -0,0 +1,140 @@ +package block + +// LeavesType represents a type of leaves block, including wood leaves and azalea leaves. +type LeavesType struct { + leavesType +} + +// OakLeaves returns oak leaves. +func OakLeaves() LeavesType { + return LeavesType{0} +} + +// SpruceLeaves returns spruce leaves. +func SpruceLeaves() LeavesType { + return LeavesType{1} +} + +// BirchLeaves returns birch leaves. +func BirchLeaves() LeavesType { + return LeavesType{2} +} + +// JungleLeaves returns jungle leaves. +func JungleLeaves() LeavesType { + return LeavesType{3} +} + +// AcaciaLeaves returns acacia leaves. +func AcaciaLeaves() LeavesType { + return LeavesType{4} +} + +// DarkOakLeaves returns dark oak leaves. +func DarkOakLeaves() LeavesType { + return LeavesType{5} +} + +// MangroveLeaves returns mangrove leaves. +func MangroveLeaves() LeavesType { + return LeavesType{6} +} + +// CherryLeaves returns cherry leaves. +func CherryLeaves() LeavesType { + return LeavesType{7} +} + +// PaleOakLeaves returns pale oak leaves. +func PaleOakLeaves() LeavesType { + return LeavesType{8} +} + +// AzaleaLeaves returns azalea leaves. +func AzaleaLeaves() LeavesType { + return LeavesType{9} +} + +// FloweringAzaleaLeaves returns flowering azalea leaves. +func FloweringAzaleaLeaves() LeavesType { + return LeavesType{10} +} + +// LeavesTypes returns all supported leaves types. +func LeavesTypes() []LeavesType { + return []LeavesType{ + OakLeaves(), + SpruceLeaves(), + BirchLeaves(), + JungleLeaves(), + AcaciaLeaves(), + DarkOakLeaves(), + MangroveLeaves(), + CherryLeaves(), + PaleOakLeaves(), + AzaleaLeaves(), + FloweringAzaleaLeaves(), + } +} + +// WoodLeavesTypes returns all supported leaves types that have an underlying wood type. +func WoodLeavesTypes() []LeavesType { + return []LeavesType{ + OakLeaves(), + SpruceLeaves(), + BirchLeaves(), + JungleLeaves(), + AcaciaLeaves(), + DarkOakLeaves(), + MangroveLeaves(), + CherryLeaves(), + PaleOakLeaves(), + } +} + +type leavesType uint8 + +// Uint8 returns the leaves type as a uint8. +func (t leavesType) Uint8() uint8 { + return uint8(t) +} + +// String returns the Bedrock identifier suffix for the leaves type. +func (t leavesType) String() string { + if wood, ok := t.Wood(); ok { + return wood.String() + "_leaves" + } + switch t { + case 9: + return "azalea_leaves" + case 10: + return "azalea_leaves_flowered" + } + panic("unknown leaves type") +} + +// Wood returns the underlying wood type of the leaves if there is one. +func (t leavesType) Wood() (WoodType, bool) { + switch t { + case 0: + return OakWood(), true + case 1: + return SpruceWood(), true + case 2: + return BirchWood(), true + case 3: + return JungleWood(), true + case 4: + return AcaciaWood(), true + case 5: + return DarkOakWood(), true + case 6: + return MangroveWood(), true + case 7: + return CherryWood(), true + case 8: + return PaleOakWood(), true + default: + return WoodType{}, false + } +} diff --git a/server/block/lectern.go b/server/block/lectern.go new file mode 100644 index 0000000..76524f8 --- /dev/null +++ b/server/block/lectern.go @@ -0,0 +1,169 @@ +package block + +import ( + "fmt" + "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" + "time" +) + +// Lectern is a librarian's job site block found in villages. It is used to hold books for multiple players to read in +// multiplayer. +// TODO: Redstone functionality. +type Lectern struct { + bass + sourceWaterDisplacer + + // Facing represents the direction the Lectern is facing. + Facing cube.Direction + // Book is the book currently held by the Lectern. + Book item.Stack + // Page is the page the Lectern is currently on in the book. + Page int +} + +// Model ... +func (Lectern) Model() world.BlockModel { + return model.Lectern{} +} + +// FuelInfo ... +func (Lectern) FuelInfo() item.FuelInfo { + return newFuelInfo(time.Second * 15) +} + +// SideClosed ... +func (Lectern) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// BreakInfo ... +func (l Lectern) BreakInfo() BreakInfo { + d := []item.Stack{item.NewStack(Lectern{}, 1)} + if !l.Book.Empty() { + d = append(d, l.Book) + } + return newBreakInfo(2.5, alwaysHarvestable, axeEffective, simpleDrops(d...)) +} + +// UseOnBlock ... +func (l Lectern) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, l) + if !used { + return false + } + l.Facing = user.Rotation().Direction().Opposite() + place(tx, pos, l, user, ctx) + return placed(ctx) +} + +// readableBook represents a book that can be read through a lectern. +type readableBook interface { + // TotalPages returns the total number of pages in the book. + TotalPages() int + // Page returns a specific page from the book and true when the page exists. It will otherwise return an empty string + // and false. + Page(page int) (string, bool) +} + +// Activate ... +func (l Lectern) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, ctx *item.UseContext) bool { + if !l.Book.Empty() { + if opener, ok := u.(ContainerOpener); ok { + opener.OpenBlockContainer(pos, tx) + return true + } + return false + } + + held, _ := u.HeldItems() + if _, ok := held.Item().(readableBook); !ok { + // We can't put a non-book item on the lectern. + return false + } + + l.Book, l.Page = held, 0 + tx.SetBlock(pos, l, nil) + + tx.PlaySound(pos.Vec3Centre(), sound.LecternBookPlace{}) + ctx.SubtractFromCount(1) + return true +} + +// Punch ... +func (l Lectern) Punch(pos cube.Pos, _ cube.Face, tx *world.Tx, _ item.User) { + if l.Book.Empty() { + // We can't remove a book from the lectern if there isn't one. + return + } + + dropItem(tx, l.Book, pos.Side(cube.FaceUp).Vec3Middle()) + + l.Book = item.Stack{} + tx.SetBlock(pos, l, nil) + tx.PlaySound(pos.Vec3Centre(), sound.Attack{}) +} + +// TurnPage updates the page the lectern is currently on to the page given. +func (l Lectern) TurnPage(pos cube.Pos, tx *world.Tx, page int) error { + if page == l.Page { + // We're already on the correct page, so we don't need to do anything. + return nil + } + if l.Book.Empty() { + return fmt.Errorf("lectern at %v is empty", pos) + } + if r, ok := l.Book.Item().(readableBook); ok && (page >= r.TotalPages() || page < 0) { + return fmt.Errorf("page number %d is out of bounds", page) + } + l.Page = page + tx.SetBlock(pos, l, nil) + return nil +} + +// EncodeNBT ... +func (l Lectern) EncodeNBT() map[string]any { + m := map[string]any{ + "hasBook": boolByte(!l.Book.Empty()), + "page": int32(l.Page), + "id": "Lectern", + } + if r, ok := l.Book.Item().(readableBook); ok { + m["book"] = nbtconv.WriteItem(l.Book, true) + m["totalPages"] = int32(r.TotalPages()) + } + return m +} + +// DecodeNBT ... +func (l Lectern) DecodeNBT(m map[string]any) any { + l.Page = int(nbtconv.Int32(m, "page")) + l.Book = nbtconv.MapItem(m, "book") + return l +} + +// EncodeItem ... +func (Lectern) EncodeItem() (name string, meta int16) { + return "minecraft:lectern", 0 +} + +// EncodeBlock ... +func (l Lectern) EncodeBlock() (string, map[string]any) { + return "minecraft:lectern", map[string]any{ + "minecraft:cardinal_direction": l.Facing.String(), + "powered_bit": uint8(0), // We don't support redstone, anyway. + } +} + +// allLecterns ... +func allLecterns() (lecterns []world.Block) { + for _, f := range cube.Directions() { + lecterns = append(lecterns, Lectern{Facing: f}) + } + return +} diff --git a/server/block/lever.go b/server/block/lever.go new file mode 100644 index 0000000..10a2cac --- /dev/null +++ b/server/block/lever.go @@ -0,0 +1,132 @@ +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/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" +) + +// Lever is a non-solid block that can provide switchable redstone power. +type Lever struct { + empty + transparent + flowingWaterDisplacer + + // Powered is if the lever is switched on. + Powered bool + // Facing is the face of the block that the lever is attached to. + Facing cube.Face + // Direction is the direction the lever is pointing. This is only used for levers that are attached on up or down + // faces. Currently, only North and West directions are supported due to Bedrock Edition limitations. + Direction cube.Direction +} + +// RedstoneSource ... +func (l Lever) RedstoneSource() bool { + return true +} + +// WeakPower ... +func (l Lever) WeakPower(cube.Pos, cube.Face, *world.Tx, bool) int { + if l.Powered { + return 15 + } + return 0 +} + +// StrongPower ... +func (l Lever) StrongPower(_ cube.Pos, face cube.Face, _ *world.Tx, _ bool) int { + if l.Powered && l.Facing == face { + return 15 + } + return 0 +} + +// SideClosed ... +func (l Lever) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// NeighbourUpdateTick ... +func (l Lever) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + supportPos := pos.Side(l.Facing.Opposite()) + if !tx.Block(supportPos).Model().FaceSolid(supportPos, l.Facing, tx) { + breakBlock(l, pos, tx) + } +} + +// UseOnBlock ... +func (l Lever) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, face, used := firstReplaceable(tx, pos, face, l) + if !used { + return false + } + supportPos := pos.Side(face.Opposite()) + if !tx.Block(supportPos).Model().FaceSolid(supportPos, face, tx) { + return false + } + + l.Powered = false + l.Facing = face + l.Direction = cube.North + if face.Axis() == cube.Y && user.Rotation().Direction().Face().Axis() == cube.X { + l.Direction = cube.West + } + place(tx, pos, l, user, ctx) + return placed(ctx) +} + +// Activate ... +func (l Lever) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, _ item.User, _ *item.UseContext) bool { + l.Powered = !l.Powered + tx.SetBlock(pos, l, nil) + if l.Powered { + tx.PlaySound(pos.Vec3Centre(), sound.PowerOn{}) + } else { + tx.PlaySound(pos.Vec3Centre(), sound.PowerOff{}) + } + updateDirectionalRedstone(pos, tx, l.Facing.Opposite()) + return true +} + +// BreakInfo ... +func (l Lever) BreakInfo() BreakInfo { + return newBreakInfo(0.5, alwaysHarvestable, nothingEffective, oneOf(Lever{})).withBreakHandler(func(pos cube.Pos, tx *world.Tx, _ item.User) { + updateDirectionalRedstone(pos, tx, l.Facing.Opposite()) + }) +} + +// EncodeItem ... +func (l Lever) EncodeItem() (name string, meta int16) { + return "minecraft:lever", 0 +} + +// EncodeBlock ... +func (l Lever) EncodeBlock() (string, map[string]any) { + direction := l.Facing.String() + if l.Facing == cube.FaceDown || l.Facing == cube.FaceUp { + axis := "east_west" + if l.Direction == cube.North { + axis = "north_south" + } + direction += "_" + axis + } + return "minecraft:lever", map[string]any{"open_bit": l.Powered, "lever_direction": direction} +} + +// allLevers ... +func allLevers() (all []world.Block) { + f := func(facing cube.Face, direction cube.Direction) { + all = append(all, Lever{Facing: facing, Direction: direction}) + all = append(all, Lever{Facing: facing, Direction: direction, Powered: true}) + } + for _, facing := range cube.Faces() { + f(facing, cube.North) + if facing == cube.FaceDown || facing == cube.FaceUp { + f(facing, cube.West) + } + } + return +} diff --git a/server/block/light.go b/server/block/light.go new file mode 100644 index 0000000..3eb4b4b --- /dev/null +++ b/server/block/light.go @@ -0,0 +1,48 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "strconv" +) + +// Light is an invisible block that can produce any light level. +type Light struct { + empty + replaceable + transparent + flowingWaterDisplacer + + // Level is the light level that the light block produces. It is a number from 0-15, where 15 is the + // brightest and 0 is no light at all. + Level int +} + +// SideClosed ... +func (Light) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// EncodeItem ... +func (l Light) EncodeItem() (name string, meta int16) { + return "minecraft:light_block_" + strconv.Itoa(l.Level), 0 +} + +// LightEmissionLevel ... +func (l Light) LightEmissionLevel() uint8 { + return uint8(l.Level) +} + +// EncodeBlock ... +func (l Light) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:light_block_" + strconv.Itoa(l.Level), nil +} + +// allLight returns all possible light blocks. +func allLight() []world.Block { + m := make([]world.Block, 0, 16) + for i := 0; i < 16; i++ { + m = append(m, Light{Level: i}) + } + return m +} diff --git a/server/block/lily_pad.go b/server/block/lily_pad.go new file mode 100644 index 0000000..3399356 --- /dev/null +++ b/server/block/lily_pad.go @@ -0,0 +1,65 @@ +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/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// LilyPad is a short, flat solid block that can be found naturally growing only on water, in swamps and wheat +// farm rooms in woodland mansions. +type LilyPad struct { + transparent +} + +// HasLiquidDrops ... +func (LilyPad) HasLiquidDrops() bool { + return true +} + +// CompostChance ... +func (LilyPad) CompostChance() float64 { + return 0.65 +} + +// NeighbourUpdateTick ... +func (l LilyPad) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if liq, ok := tx.Liquid(pos.Side(cube.FaceDown)); !ok || liq.LiquidType() != "water" || liq.LiquidDepth() < 8 { + breakBlock(l, pos, tx) + } +} + +// UseOnBlock ... +func (l LilyPad) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, l) + if !used { + return false + } + if liq, ok := tx.Liquid(pos.Side(cube.FaceDown)); !ok || liq.LiquidType() != "water" || liq.LiquidDepth() < 8 { + return false + } + place(tx, pos, l, user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (l LilyPad) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(l)) +} + +// EncodeItem ... +func (LilyPad) EncodeItem() (name string, meta int16) { + return "minecraft:waterlily", 0 +} + +// Model ... +func (LilyPad) Model() world.BlockModel { + return model.LilyPad{} +} + +// EncodeBlock ... +func (LilyPad) EncodeBlock() (string, map[string]any) { + return "minecraft:waterlily", nil +} diff --git a/server/block/liquid.go b/server/block/liquid.go new file mode 100644 index 0000000..df72fa2 --- /dev/null +++ b/server/block/liquid.go @@ -0,0 +1,358 @@ +package block + +import ( + "math" + "sync" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/event" + "github.com/df-mc/dragonfly/server/world" +) + +// LiquidRemovable represents a block that may be removed by a liquid flowing into it. When this happens, the +// block's drops are dropped at the position if HasLiquidDrops returns true. +type LiquidRemovable interface { + HasLiquidDrops() bool +} + +// sourceWaterDisplacer may be embedded to allow displacing water source blocks. +type sourceWaterDisplacer struct{} + +// CanDisplace returns true if the world.Liquid passed is of the type Water, not falling and has a depth of 8. +func (s sourceWaterDisplacer) CanDisplace(b world.Liquid) bool { + w, ok := b.(Water) + return ok && !w.Falling && w.Depth == 8 +} + +// flowingWaterDisplacer may be embedded to allow displacing water source blocks or flowing water. +type flowingWaterDisplacer struct{} + +// CanDisplace returns true if the world.Liquid passed is of the type Water. +func (s flowingWaterDisplacer) CanDisplace(b world.Liquid) bool { + _, ok := b.(Water) + return ok +} + +// tickLiquid ticks the liquid block passed at a specific position in the world. Depending on the surroundings +// and the liquid block, the liquid will either spread or decrease in depth. Additionally, the liquid might +// be turned into a solid block if a different liquid is next to it. +func tickLiquid(b world.Liquid, pos cube.Pos, tx *world.Tx) { + if !source(b) && !sourceAround(b, pos, tx) { + var res world.Liquid + if b.LiquidDepth()-4 > 0 { + res = b.WithDepth(b.LiquidDepth()-2*b.SpreadDecay(), false) + } + ctx := event.C(tx) + if tx.World().Handler().HandleLiquidDecay(ctx, pos, b, res); ctx.Cancelled() { + return + } + tx.SetLiquid(pos, res) + return + } + displacer, _ := tx.Block(pos).(world.LiquidDisplacer) + + canFlowBelow := canFlowInto(b, tx, pos.Side(cube.FaceDown), false) + if b.LiquidFalling() && !canFlowBelow { + b = b.WithDepth(8, true) + } else if canFlowBelow { + below := pos.Side(cube.FaceDown) + if displacer == nil || !displacer.SideClosed(pos, below, tx) { + flowInto(b.WithDepth(8, true), pos, below, tx, true) + } + } + + depth, decay := b.LiquidDepth(), b.SpreadDecay() + if depth <= decay { + // Current depth is smaller than the decay, so spreading will result in nothing. + return + } + if source(b) || !canFlowBelow { + paths := calculateLiquidPaths(b, pos, tx, displacer) + if len(paths) == 0 { + spreadOutwards(b, pos, tx, displacer) + return + } + + smallestLen := len(paths[0]) + for _, path := range paths { + if len(path) <= smallestLen { + flowInto(b, pos, path[0], tx, false) + } + } + } +} + +// source checks if a liquid is a source block. +func source(b world.Liquid) bool { + return b.LiquidDepth() == 8 && !b.LiquidFalling() +} + +// spreadOutwards spreads the liquid outwards into the horizontal directions. +func spreadOutwards(b world.Liquid, pos cube.Pos, tx *world.Tx, displacer world.LiquidDisplacer) { + pos.Neighbours(func(neighbour cube.Pos) { + if neighbour[1] == pos[1] { + if displacer == nil || !displacer.SideClosed(pos, neighbour, tx) { + flowInto(b, pos, neighbour, tx, false) + } + } + }, tx.Range()) +} + +// sourceAround checks if there is a source in the blocks around the position passed. +func sourceAround(b world.Liquid, pos cube.Pos, tx *world.Tx) (sourcePresent bool) { + pos.Neighbours(func(neighbour cube.Pos) { + if neighbour[1] == pos[1]-1 { + // We don't care about water below this one. + return + } + side, ok := tx.Liquid(neighbour) + if !ok || side.LiquidType() != b.LiquidType() { + return + } + if displacer, ok := tx.Block(neighbour).(world.LiquidDisplacer); ok && displacer.SideClosed(neighbour, pos, tx) { + // The side towards this liquid was closed, so this cannot function as a source for this + // liquid. + return + } + if neighbour[1] == pos[1]+1 || source(side) || side.LiquidDepth() > b.LiquidDepth() { + sourcePresent = true + } + }, tx.Range()) + return +} + +// flowInto makes the liquid passed flow into the position passed in a world. If successful, the block at that +// position will be broken and the liquid with a lower depth will replace it. +func flowInto(b world.Liquid, src, pos cube.Pos, tx *world.Tx, falling bool) bool { + newDepth := b.LiquidDepth() - b.SpreadDecay() + if falling { + newDepth = b.LiquidDepth() + } + if newDepth <= 0 && !falling { + return false + } + existing := tx.Block(pos) + if existingLiquid, alsoLiquid := existing.(world.Liquid); alsoLiquid && existingLiquid.LiquidType() == b.LiquidType() { + if existingLiquid.LiquidDepth() >= newDepth || existingLiquid.LiquidFalling() { + // The existing liquid had a higher depth than the one we're propagating, or it was falling + // (basically considered full depth), so no need to continue. + return true + } + ctx := event.C(tx) + if tx.World().Handler().HandleLiquidFlow(ctx, src, pos, b.WithDepth(newDepth, falling), existing); ctx.Cancelled() { + return false + } + tx.SetLiquid(pos, b.WithDepth(newDepth, falling)) + return true + } else if alsoLiquid { + existingLiquid.Harden(pos, tx, &src) + return false + } + displacer, isDisplacer := existing.(world.LiquidDisplacer) + if isDisplacer { + if _, ok := tx.Liquid(pos); ok { + // We've got a liquid displacer, and it's got a liquid within it, so we can't flow into this. + return false + } + } + _, isRemovable := existing.(LiquidRemovable) + if !isRemovable && (!isDisplacer || !displacer.CanDisplace(b.WithDepth(newDepth, falling))) { + // Can't flow into this block. + return false + } + ctx := event.C(tx) + if tx.World().Handler().HandleLiquidFlow(ctx, src, pos, b.WithDepth(newDepth, falling), existing); ctx.Cancelled() { + return false + } + + if isRemovable { + if _, air := existing.(Air); !air { + tx.SetBlock(pos, nil, nil) + b.LiquidRemoveBlock(pos, tx, existing) + } + } + tx.SetLiquid(pos, b.WithDepth(newDepth, falling)) + return true +} + +// liquidPath represents a path to an empty lower block or a block that can be flown into by a liquid, which +// the liquid tends to flow into. All paths with the lowest length will be filled with water. +type liquidPath []cube.Pos + +// calculateLiquidPaths calculates paths in the world that the liquid passed can flow in to reach lower +// grounds, starting at the position passed. +// If none of these paths can be found, the returned slice has a length of 0. +func calculateLiquidPaths(b world.Liquid, pos cube.Pos, tx *world.Tx, displacer world.LiquidDisplacer) []liquidPath { + queue := liquidQueuePool.Get().(*liquidQueue) + defer func() { + queue.Reset() + liquidQueuePool.Put(queue) + }() + queue.PushBack(liquidNode{x: pos[0], z: pos[2], depth: int8(b.LiquidDepth())}) + decay := int8(b.SpreadDecay()) + + paths := make([]liquidPath, 0, 3) + first := true + + for queue.Len() != 0 { + node := queue.Front() + neighA, neighB, neighC, neighD := node.neighbours(decay * 2) + if !first || (displacer == nil || !displacer.SideClosed(pos, cube.Pos{neighA.x, pos[1], neighA.z}, tx)) { + if spreadNeighbour(b, pos, tx, neighA, queue) { + queue.shortestPath = neighA.Len() + paths = append(paths, neighA.Path(pos)) + } + } + if !first || (displacer == nil || !displacer.SideClosed(pos, cube.Pos{neighB.x, pos[1], neighB.z}, tx)) { + if spreadNeighbour(b, pos, tx, neighB, queue) { + queue.shortestPath = neighB.Len() + paths = append(paths, neighB.Path(pos)) + } + } + if !first || (displacer == nil || !displacer.SideClosed(pos, cube.Pos{neighC.x, pos[1], neighC.z}, tx)) { + if spreadNeighbour(b, pos, tx, neighC, queue) { + queue.shortestPath = neighC.Len() + paths = append(paths, neighC.Path(pos)) + } + } + if !first || (displacer == nil || !displacer.SideClosed(pos, cube.Pos{neighD.x, pos[1], neighD.z}, tx)) { + if spreadNeighbour(b, pos, tx, neighD, queue) { + queue.shortestPath = neighD.Len() + paths = append(paths, neighD.Path(pos)) + } + } + first = false + } + return paths +} + +// spreadNeighbour attempts to spread a path node into the neighbour passed. Note that this does not spread +// the liquid, it only spreads the node used to calculate flow paths. +func spreadNeighbour(b world.Liquid, src cube.Pos, tx *world.Tx, node liquidNode, queue *liquidQueue) bool { + if node.depth+3 <= 0 { + // Depth has reached zero or below, can't spread any further. + return false + } + if node.Len() > queue.shortestPath { + // This path is longer than any existing path, so don't spread any further. + return false + } + pos := cube.Pos{node.x, src[1], node.z} + if !canFlowInto(b, tx, pos, true) { + // Can't flow into this block, can't spread any further. + return false + } + pos[1]-- + if canFlowInto(b, tx, pos, false) { + return true + } + queue.PushBack(node) + return false +} + +// canFlowInto checks if a liquid can flow into the block present in the world at a specific block position. +func canFlowInto(b world.Liquid, tx *world.Tx, pos cube.Pos, sideways bool) bool { + bl := tx.Block(pos) + if _, air := bl.(Air); air { + // Fast route for air: A type assert to a concrete type is much faster than a type assert to an interface. + return true + } + if _, ok := bl.(LiquidRemovable); ok { + if liq, ok := bl.(world.Liquid); ok && sideways { + if (liq.LiquidDepth() == 8 && !liq.LiquidFalling()) || liq.LiquidType() != b.LiquidType() { + // Can't flow into a liquid if it has a depth of 8 or if it doesn't have the same type. + return false + } + } + return true + } + if dis, ok := bl.(world.LiquidDisplacer); ok { + res := b.WithDepth(b.LiquidDepth()-b.SpreadDecay(), !sideways) + if dis.CanDisplace(res) { + return true + } + } + return false +} + +// liquidNode represents a position that is part of a flow path for a liquid. +type liquidNode struct { + x, z int + depth int8 + previous *liquidNode +} + +// neighbours returns the four horizontal neighbours of the node with decreased depth. +func (node liquidNode) neighbours(decay int8) (a, b, c, d liquidNode) { + return liquidNode{x: node.x - 1, z: node.z, depth: node.depth - decay, previous: &node}, + liquidNode{x: node.x + 1, z: node.z, depth: node.depth - decay, previous: &node}, + liquidNode{x: node.x, z: node.z - 1, depth: node.depth - decay, previous: &node}, + liquidNode{x: node.x, z: node.z + 1, depth: node.depth - decay, previous: &node} +} + +// Len returns the length of the path created by the node. +func (node liquidNode) Len() int { + i := 1 + for { + if node.previous == nil { + return i - 1 + } + //noinspection GoAssignmentToReceiver + node = *node.previous + i++ + } +} + +// Path converts the liquid node into a path. +func (node liquidNode) Path(src cube.Pos) liquidPath { + l := node.Len() + path := make(liquidPath, l) + i := l - 1 + for { + if node.previous == nil { + return path + } + path[i] = cube.Pos{node.x, src[1], node.z} + + //noinspection GoAssignmentToReceiver + node = *node.previous + i-- + } +} + +// liquidQueuePool is use to re-use liquid node queues. +var liquidQueuePool = sync.Pool{ + New: func() any { + return &liquidQueue{ + nodes: make([]liquidNode, 0, 64), + shortestPath: math.MaxInt8, + } + }, +} + +// liquidQueue represents a queue that may be used to push nodes into and take them out of it. +type liquidQueue struct { + nodes []liquidNode + i int + shortestPath int +} + +func (q *liquidQueue) PushBack(node liquidNode) { + q.nodes = append(q.nodes, node) +} + +func (q *liquidQueue) Front() liquidNode { + v := q.nodes[q.i] + q.i++ + return v +} + +func (q *liquidQueue) Len() int { + return len(q.nodes) - q.i +} + +func (q *liquidQueue) Reset() { + q.nodes = q.nodes[:0] + q.i = 0 + q.shortestPath = math.MaxInt8 +} diff --git a/server/block/lit_pumpkin.go b/server/block/lit_pumpkin.go new file mode 100644 index 0000000..5cd7ac7 --- /dev/null +++ b/server/block/lit_pumpkin.go @@ -0,0 +1,55 @@ +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" +) + +// LitPumpkin is a decorative light emitting block crafted with a Carved Pumpkin & Torch +type LitPumpkin struct { + solid + + // Facing is the direction the pumpkin is facing. + Facing cube.Direction +} + +// LightEmissionLevel ... +func (l LitPumpkin) LightEmissionLevel() uint8 { + return 15 +} + +// UseOnBlock ... +func (l LitPumpkin) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, l) + if !used { + return + } + l.Facing = user.Rotation().Direction().Opposite() + + place(tx, pos, l, user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (l LitPumpkin) BreakInfo() BreakInfo { + return newBreakInfo(1, alwaysHarvestable, axeEffective, oneOf(l)) +} + +// EncodeItem ... +func (l LitPumpkin) EncodeItem() (name string, meta int16) { + return "minecraft:lit_pumpkin", 0 +} + +// EncodeBlock ... +func (l LitPumpkin) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:lit_pumpkin", map[string]any{"minecraft:cardinal_direction": l.Facing.String()} +} + +func allLitPumpkins() (pumpkins []world.Block) { + for i := cube.Direction(0); i <= 3; i++ { + pumpkins = append(pumpkins, LitPumpkin{Facing: i}) + } + return +} diff --git a/server/block/log.go b/server/block/log.go new file mode 100644 index 0000000..61cd441 --- /dev/null +++ b/server/block/log.go @@ -0,0 +1,118 @@ +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" +) + +// Log is a naturally occurring block found in trees, primarily used to create planks. It comes in six +// species: oak, spruce, birch, jungle, acacia, and dark oak. +// Stripped log is a variant obtained by using an axe on a log. +type Log struct { + solid + bass + + // Wood is the type of wood of the log. This field must have one of the values found in the material + // package. + Wood WoodType + // Stripped specifies if the log is stripped or not. + Stripped bool + // Axis is the axis which the log block faces. + Axis cube.Axis +} + +// FlammabilityInfo ... +func (l Log) FlammabilityInfo() FlammabilityInfo { + if !l.Wood.Flammable() { + return newFlammabilityInfo(0, 0, false) + } + return newFlammabilityInfo(5, 5, true) +} + +// BreakInfo ... +func (l Log) BreakInfo() BreakInfo { + return newBreakInfo(2, alwaysHarvestable, axeEffective, oneOf(l)) +} + +// SmeltInfo ... +func (Log) SmeltInfo() item.SmeltInfo { + return newSmeltInfo(item.NewStack(item.Charcoal{}, 1), 0.15) +} + +// FuelInfo ... +func (l Log) FuelInfo() item.FuelInfo { + if !l.Wood.Flammable() { + return item.FuelInfo{} + } + return newFuelInfo(time.Second * 15) +} + +// UseOnBlock handles the rotational placing of logs. +func (l Log) 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, l) + if !used { + return + } + l.Axis = face.Axis() + + place(tx, pos, l, user, ctx) + return placed(ctx) +} + +// Strip ... +func (l Log) Strip() (world.Block, world.Sound, bool) { + return Log{Axis: l.Axis, Wood: l.Wood, Stripped: true}, nil, !l.Stripped +} + +// EncodeItem ... +func (l Log) EncodeItem() (name string, meta int16) { + if !l.Stripped { + switch l.Wood { + case CrimsonWood(), WarpedWood(): + return "minecraft:" + l.Wood.String() + "_stem", 0 + default: + return "minecraft:" + l.Wood.String() + "_log", 0 + } + } + switch l.Wood { + case CrimsonWood(), WarpedWood(): + return "minecraft:stripped_" + l.Wood.String() + "_stem", 0 + default: + return "minecraft:stripped_" + l.Wood.String() + "_log", 0 + } +} + +// EncodeBlock ... +func (l Log) EncodeBlock() (name string, properties map[string]any) { + if !l.Stripped { + switch l.Wood { + case CrimsonWood(), WarpedWood(): + return "minecraft:" + l.Wood.String() + "_stem", map[string]any{"pillar_axis": l.Axis.String()} + default: + return "minecraft:" + l.Wood.String() + "_log", map[string]any{"pillar_axis": l.Axis.String()} + } + } + switch l.Wood { + case CrimsonWood(), WarpedWood(): + return "minecraft:stripped_" + l.Wood.String() + "_stem", map[string]any{"pillar_axis": l.Axis.String()} + default: + return "minecraft:stripped_" + l.Wood.String() + "_log", map[string]any{"pillar_axis": l.Axis.String()} + } +} + +// 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}) + } + } + return +} diff --git a/server/block/loom.go b/server/block/loom.go new file mode 100644 index 0000000..69a82fe --- /dev/null +++ b/server/block/loom.go @@ -0,0 +1,67 @@ +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" +) + +// Loom is a block used to apply patterns on banners. It is also used as a shepherd's job site block that is found in +// villages. +type Loom struct { + solid + bass + + // Facing is the direction the loom is facing. + Facing cube.Direction +} + +// FuelInfo ... +func (Loom) FuelInfo() item.FuelInfo { + return newFuelInfo(time.Second * 15) +} + +// BreakInfo ... +func (l Loom) BreakInfo() BreakInfo { + return newBreakInfo(2.5, alwaysHarvestable, axeEffective, oneOf(l)) +} + +// Activate ... +func (Loom) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + if opener, ok := u.(ContainerOpener); ok { + opener.OpenBlockContainer(pos, tx) + return true + } + return false +} + +// UseOnBlock ... +func (l Loom) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, l) + if !used { + return + } + l.Facing = user.Rotation().Direction().Opposite() + place(tx, pos, l, user, ctx) + return placed(ctx) +} + +// EncodeItem ... +func (Loom) EncodeItem() (name string, meta int16) { + return "minecraft:loom", 0 +} + +// EncodeBlock ... +func (l Loom) EncodeBlock() (name string, properties map[string]interface{}) { + return "minecraft:loom", map[string]interface{}{"direction": int32(horizontalDirection(l.Facing))} +} + +// allLooms ... +func allLooms() (looms []world.Block) { + for _, d := range cube.Directions() { + looms = append(looms, Loom{Facing: d}) + } + return +} diff --git a/server/block/magma.go b/server/block/magma.go new file mode 100644 index 0000000..351e4fd --- /dev/null +++ b/server/block/magma.go @@ -0,0 +1,59 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/enchantment" + "github.com/df-mc/dragonfly/server/world" +) + +// Magma is a light-emitting Nether block that damages entities standing on it. +type Magma struct { + solid + bassDrum +} + +// LightEmissionLevel ... +func (Magma) LightEmissionLevel() uint8 { + return 3 +} + +// EntityStepOn ... +func (Magma) EntityStepOn(_ cube.Pos, _ *world.Tx, e world.Entity) { + if fireProof, ok := e.(interface{ FireProof() bool }); ok && fireProof.FireProof() { + return + } + // TODO: Check for Frost Walker once the enchantment is implemented in Dragonfly. + if sneaking, ok := e.(interface{ Sneaking() bool }); ok && sneaking.Sneaking() { + return + } + if l, ok := e.(livingEntity); ok { + l.Hurt(1, MagmaDamageSource{}) + } +} + +// BreakInfo ... +func (m Magma) BreakInfo() BreakInfo { + return newBreakInfo(0.5, pickaxeHarvestable, pickaxeEffective, oneOf(m)).withBlastResistance(30) +} + +// EncodeItem ... +func (Magma) EncodeItem() (name string, meta int16) { + return "minecraft:magma", 0 +} + +// EncodeBlock ... +func (Magma) EncodeBlock() (string, map[string]any) { + return "minecraft:magma", nil +} + +// MagmaDamageSource is used for damage caused by standing on a magma block. +type MagmaDamageSource struct{} + +func (MagmaDamageSource) ReducedByResistance() bool { return true } +func (MagmaDamageSource) ReducedByArmour() bool { return true } +func (MagmaDamageSource) Fire() bool { return true } +func (MagmaDamageSource) AffectedByEnchantment(e item.EnchantmentType) bool { + return e == enchantment.FireProtection +} +func (MagmaDamageSource) IgnoreTotem() bool { return false } diff --git a/server/block/melon.go b/server/block/melon.go new file mode 100644 index 0000000..511b779 --- /dev/null +++ b/server/block/melon.go @@ -0,0 +1,30 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" +) + +// Melon is a fruit block that grows from melon stems. +type Melon struct { + solid +} + +// BreakInfo ... +func (m Melon) BreakInfo() BreakInfo { + return newBreakInfo(1, alwaysHarvestable, axeEffective, discreteDrops(item.MelonSlice{}, m, 3, 7, 9)) +} + +// CompostChance ... +func (Melon) CompostChance() float64 { + return 0.65 +} + +// EncodeItem ... +func (Melon) EncodeItem() (name string, meta int16) { + return "minecraft:melon_block", 0 +} + +// EncodeBlock ... +func (Melon) EncodeBlock() (string, map[string]any) { + return "minecraft:melon_block", nil +} diff --git a/server/block/melon_seeds.go b/server/block/melon_seeds.go new file mode 100644 index 0000000..2fcfeec --- /dev/null +++ b/server/block/melon_seeds.go @@ -0,0 +1,118 @@ +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" +) + +// MelonSeeds grow melon blocks. +type MelonSeeds struct { + crop + + // direction is the direction from the stem to the melon. + Direction cube.Face +} + +// SameCrop ... +func (MelonSeeds) SameCrop(c Crop) bool { + _, ok := c.(MelonSeeds) + return ok +} + +// NeighbourUpdateTick ... +func (m MelonSeeds) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if _, ok := tx.Block(pos.Side(cube.FaceDown)).(Farmland); !ok { + breakBlock(m, pos, tx) + } else if m.Direction != cube.FaceDown { + if _, ok := tx.Block(pos.Side(m.Direction)).(Melon); !ok { + m.Direction = cube.FaceDown + tx.SetBlock(pos, m, nil) + } + } +} + +// RandomTick ... +func (m MelonSeeds) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + if r.Float64() <= m.CalculateGrowthChance(pos, tx) && tx.Light(pos) >= 8 { + if m.Growth < 7 { + m.Growth++ + tx.SetBlock(pos, m, nil) + } else { + directions := cube.Directions() + for _, i := range directions { + if _, ok := tx.Block(pos.Side(i.Face())).(Melon); ok { + return + } + } + direction := directions[r.IntN(len(directions))].Face() + stemPos := pos.Side(direction) + if _, ok := tx.Block(stemPos).(Air); ok { + switch tx.Block(stemPos.Side(cube.FaceDown)).(type) { + case Farmland, Dirt, Grass: + m.Direction = direction + tx.SetBlock(pos, m, nil) + tx.SetBlock(stemPos, Melon{}, nil) + } + } + } + } +} + +// BoneMeal ... +func (m MelonSeeds) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { + if m.Growth == 7 { + return item.BoneMealResultNone + } + m.Growth = min(m.Growth+rand.IntN(4)+2, 7) + tx.SetBlock(pos, m, nil) + return item.BoneMealResultSmall +} + +// UseOnBlock ... +func (m MelonSeeds) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, m) + if !used { + return false + } + + if _, ok := tx.Block(pos.Side(cube.FaceDown)).(Farmland); !ok { + return false + } + + place(tx, pos, m, user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (m MelonSeeds) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(m)) +} + +// CompostChance ... +func (MelonSeeds) CompostChance() float64 { + return 0.3 +} + +// EncodeItem ... +func (m MelonSeeds) EncodeItem() (name string, meta int16) { + return "minecraft:melon_seeds", 0 +} + +// EncodeBlock ... +func (m MelonSeeds) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:melon_stem", map[string]any{"facing_direction": int32(m.Direction), "growth": int32(m.Growth)} +} + +// allMelonStems ... +func allMelonStems() (stems []world.Block) { + for i := 0; i <= 7; i++ { + for j := cube.Face(0); j <= 5; j++ { + stems = append(stems, MelonSeeds{crop: crop{Growth: i}, Direction: j}) + } + } + return +} diff --git a/server/block/model.go b/server/block/model.go new file mode 100644 index 0000000..405a2b1 --- /dev/null +++ b/server/block/model.go @@ -0,0 +1,63 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/model" + "github.com/df-mc/dragonfly/server/world" +) + +// solid represents a block that is fully solid. It always returns a model.Solid when Model is called. +type solid struct{} + +// Model ... +func (solid) Model() world.BlockModel { + return model.Solid{} +} + +// empty represents a block that is fully empty/transparent, such as air or a plant. It always returns a +// model.Empty when Model is called. +type empty struct{} + +// Model ... +func (empty) Model() world.BlockModel { + return model.Empty{} +} + +// chest represents a block that has a model of a chest. +type chest struct{} + +// Model ... +func (chest) Model() world.BlockModel { + return model.Chest{} +} + +// carpet represents a block that has a model of a carpet. +type carpet struct{} + +// Model ... +func (carpet) Model() world.BlockModel { + return model.Carpet{} +} + +// tilledGrass represents a block that has a model of farmland or dirt paths. +type tilledGrass struct{} + +// Model ... +func (tilledGrass) Model() world.BlockModel { + return model.TilledGrass{} +} + +// leaves represents a block that has a model of leaves. A full block but with no solid faces. +type leaves struct{} + +// Model ... +func (leaves) Model() world.BlockModel { + return model.Leaves{} +} + +// thin represents a thin, partial block such as a glass pane or an iron bar, that connects to nearby solid faces. +type thin struct{} + +// Model ... +func (thin) Model() world.BlockModel { + return model.Thin{} +} diff --git a/server/block/model/anvil.go b/server/block/model/anvil.go new file mode 100644 index 0000000..899e258 --- /dev/null +++ b/server/block/model/anvil.go @@ -0,0 +1,22 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Anvil is a model used by anvils. +type Anvil struct { + // Facing is the direction that the anvil is facing. + Facing cube.Direction +} + +// BBox ... +func (a Anvil) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return []cube.BBox{full.Stretch(a.Facing.RotateLeft().Face().Axis(), -0.125)} +} + +// FaceSolid ... +func (Anvil) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/bed.go b/server/block/model/bed.go new file mode 100644 index 0000000..58854ce --- /dev/null +++ b/server/block/model/bed.go @@ -0,0 +1,19 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Bed is a model used for beds. This model works for both parts of the bed. +type Bed struct{} + +// BBox ... +func (b Bed) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return []cube.BBox{cube.Box(0, 0, 0, 1, 0.5625, 1)} +} + +// FaceSolid ... +func (Bed) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/brewing_stand.go b/server/block/model/brewing_stand.go new file mode 100644 index 0000000..83f8e6e --- /dev/null +++ b/server/block/model/brewing_stand.go @@ -0,0 +1,22 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// BrewingStand is a model used by brewing stands. +type BrewingStand struct{} + +// BBox ... +func (b BrewingStand) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return []cube.BBox{ + full.ExtendTowards(cube.FaceUp, -0.875), + full.Stretch(cube.X, -0.4375).Stretch(cube.Z, -0.4375).ExtendTowards(cube.FaceDown, 0.125), + } +} + +// FaceSolid ... +func (b BrewingStand) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/cactus.go b/server/block/model/cactus.go new file mode 100644 index 0000000..b7566c5 --- /dev/null +++ b/server/block/model/cactus.go @@ -0,0 +1,19 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Cactus is the model for a Cactus. It is just barely not a full block, having a slightly reduced width and depth. +type Cactus struct{} + +// BBox returns a physics.BBox that is slightly smaller than a full block. +func (Cactus) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return []cube.BBox{cube.Box(0.025, 0, 0.025, 0.975, 1, 0.975)} +} + +// FaceSolid always returns false. +func (Cactus) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/cake.go b/server/block/model/cake.go new file mode 100644 index 0000000..ab575c9 --- /dev/null +++ b/server/block/model/cake.go @@ -0,0 +1,24 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Cake is a model used by cake blocks. +type Cake struct { + // Bites is the amount of bites that were taken from the cake. A cake can have up to 7 bites taken from it, before + // being consumed entirely. + Bites int +} + +// BBox returns an BBox with a size that depends on the amount of bites taken. +func (c Cake) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return []cube.BBox{cube.Box(0.0625, 0, 0.0625, 0.9375, 0.5, 0.9375). + ExtendTowards(cube.FaceWest, -(float64(c.Bites) / 8))} +} + +// FaceSolid always returns false. +func (c Cake) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/campfire.go b/server/block/model/campfire.go new file mode 100644 index 0000000..ae13f83 --- /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.BlockSource) []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.BlockSource) bool { + return face == cube.FaceDown +} diff --git a/server/block/model/carpet.go b/server/block/model/carpet.go new file mode 100644 index 0000000..4d5ce15 --- /dev/null +++ b/server/block/model/carpet.go @@ -0,0 +1,19 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Carpet is a model for carpet-like extremely thin blocks. +type Carpet struct{} + +// BBox returns a flat BBox with a width of 0.0625. +func (Carpet) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return []cube.BBox{cube.Box(0, 0, 0, 1, 0.0625, 1)} +} + +// FaceSolid always returns false. +func (Carpet) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/chain.go b/server/block/model/chain.go new file mode 100644 index 0000000..459b431 --- /dev/null +++ b/server/block/model/chain.go @@ -0,0 +1,22 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Chain is a model used by chain blocks. +type Chain struct { + // Axis is the axis which the chain faces. + Axis cube.Axis +} + +// BBox ... +func (c Chain) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return []cube.BBox{cube.Box(0.40625, 0.40625, 0.40625, 0.59375, 0.59375, 0.59375).Stretch(c.Axis, 0.40625)} +} + +// FaceSolid ... +func (Chain) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/chest.go b/server/block/model/chest.go new file mode 100644 index 0000000..2adde25 --- /dev/null +++ b/server/block/model/chest.go @@ -0,0 +1,20 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Chest is the model of a chest. It is just barely not a full block, having a slightly reduced with on all +// axes. +type Chest struct{} + +// BBox returns a physics.BBox that is slightly smaller than a full block. +func (Chest) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return []cube.BBox{cube.Box(0.025, 0, 0.025, 0.975, 0.95, 0.975)} +} + +// FaceSolid always returns false. +func (Chest) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/cocoa_bean.go b/server/block/model/cocoa_bean.go new file mode 100644 index 0000000..51e3c17 --- /dev/null +++ b/server/block/model/cocoa_bean.go @@ -0,0 +1,30 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// CocoaBean is a model used by cocoa bean blocks. +type CocoaBean struct { + // Facing is the face that the cocoa bean faces. It is the opposite of the face that the CocoaBean is attached to. + Facing cube.Direction + // Age is the age of the CocoaBean. The age influences the size of the CocoaBean. The maximum age value of a cocoa + // bean is 3. + Age int +} + +// BBox returns a single physics.BBox whose size depends on the age of the CocoaBean. +func (c CocoaBean) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return []cube.BBox{full. + Stretch(c.Facing.RotateRight().Face().Axis(), -(6-float64(c.Age))/16). + ExtendTowards(cube.FaceUp, -0.25). + ExtendTowards(cube.FaceDown, -((7-float64(c.Age)*2)/16)). + ExtendTowards(c.Facing.Face(), -0.0625). + ExtendTowards(c.Facing.Opposite().Face(), -((11 - float64(c.Age)*2) / 16))} +} + +// FaceSolid always returns false. +func (c CocoaBean) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/composter.go b/server/block/model/composter.go new file mode 100644 index 0000000..338b256 --- /dev/null +++ b/server/block/model/composter.go @@ -0,0 +1,32 @@ +package model + +import ( + "math" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Composter is a model used by composter blocks. It is solid on all sides apart from the top, and the height of the +// inside area depends on the level of compost inside the composter. +type Composter struct { + // Level is the level of compost inside the composter. + Level int +} + +// BBox ... +func (c Composter) BBox(_ cube.Pos, _ world.BlockSource) []cube.BBox { + compostHeight := math.Abs(math.Min(float64(c.Level), 7)*0.125 - 0.0625) + return []cube.BBox{ + cube.Box(0, 0, 0, 1, 1, 0.125), + cube.Box(0, 0, 0.875, 1, 1, 1), + cube.Box(0.875, 0, 0, 1, 1, 1), + cube.Box(0, 0, 0, 0.125, 1, 1), + cube.Box(0.125, 0, 0.125, 0.875, 0.125+compostHeight, 0.875), + } +} + +// FaceSolid returns true for all faces other than the top. +func (Composter) FaceSolid(_ cube.Pos, face cube.Face, _ world.BlockSource) bool { + return face != cube.FaceUp +} diff --git a/server/block/model/decorated_pot.go b/server/block/model/decorated_pot.go new file mode 100644 index 0000000..d9a7e34 --- /dev/null +++ b/server/block/model/decorated_pot.go @@ -0,0 +1,20 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// DecoratedPot is the model for a DecoratedPot. It is just barely not a full block, having a slightly reduced width and +// depth. +type DecoratedPot struct{} + +// BBox returns a physics.BBox that is slightly smaller than a full block. +func (DecoratedPot) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return []cube.BBox{cube.Box(0.025, 0, 0.025, 0.975, 1, 0.975)} +} + +// FaceSolid always returns true for the top and bottom face, and false for all other faces. +func (DecoratedPot) FaceSolid(_ cube.Pos, face cube.Face, _ world.BlockSource) bool { + return face.Axis() == cube.Y +} diff --git a/server/block/model/doc.go b/server/block/model/doc.go new file mode 100644 index 0000000..63ffda9 --- /dev/null +++ b/server/block/model/doc.go @@ -0,0 +1,5 @@ +// Package model has world.BlockModel implementations for each world.Block implementation in the block package. These +// models may be reused by blocks that share the same model. +// Some block models require additional fields and knowledge about the surrounding blocks to calculate the correct +// model. +package model diff --git a/server/block/model/door.go b/server/block/model/door.go new file mode 100644 index 0000000..66cb5a8 --- /dev/null +++ b/server/block/model/door.go @@ -0,0 +1,34 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Door is a model used for doors. It has no solid faces and a bounding box that changes depending on +// the direction of the door, whether it is open, and the side of its hinge. +type Door struct { + // Facing is the direction that the door is facing when closed. + Facing cube.Direction + // Open specifies if the Door is open. The direction it opens towards depends on the Right field. + Open bool + // Right specifies the attachment side of the door and, with that, the direction it opens in. + Right bool +} + +// BBox returns a physics.BBox that depends on if the Door is open, what direction it is facing and whether it is +// attached to the right/left side of a block. +func (d Door) BBox(cube.Pos, world.BlockSource) []cube.BBox { + if d.Open { + if d.Right { + return []cube.BBox{full.ExtendTowards(d.Facing.RotateLeft().Face(), -0.8125)} + } + return []cube.BBox{full.ExtendTowards(d.Facing.RotateRight().Face(), -0.8125)} + } + return []cube.BBox{full.ExtendTowards(d.Facing.Face(), -0.8125)} +} + +// FaceSolid always returns false. +func (d Door) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/empty.go b/server/block/model/empty.go new file mode 100644 index 0000000..949ae97 --- /dev/null +++ b/server/block/model/empty.go @@ -0,0 +1,19 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Empty is a model that is completely empty. It has no collision boxes or solid faces. +type Empty struct{} + +// BBox returns an empty slice. +func (Empty) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return nil +} + +// FaceSolid always returns false. +func (Empty) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/enchanting_table.go b/server/block/model/enchanting_table.go new file mode 100644 index 0000000..57ecb1a --- /dev/null +++ b/server/block/model/enchanting_table.go @@ -0,0 +1,19 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// EnchantingTable is a model used by enchanting tables. +type EnchantingTable struct{} + +// BBox ... +func (EnchantingTable) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return []cube.BBox{cube.Box(0, 0, 0, 1, 0.75, 1)} +} + +// FaceSolid ... +func (EnchantingTable) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/end_rod.go b/server/block/model/end_rod.go new file mode 100644 index 0000000..f26f8fd --- /dev/null +++ b/server/block/model/end_rod.go @@ -0,0 +1,22 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// EndRod is a model used by end rod blocks. +type EndRod struct { + // Axis is the axis which the end rod faces. + Axis cube.Axis +} + +// BBox ... +func (e EndRod) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return []cube.BBox{cube.Box(0.375, 0.375, 0.375, 0.625, 0.625, 0.625).Stretch(e.Axis, 0.375)} +} + +// FaceSolid ... +func (EndRod) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/fence.go b/server/block/model/fence.go new file mode 100644 index 0000000..b21d170 --- /dev/null +++ b/server/block/model/fence.go @@ -0,0 +1,75 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Fence is a model used by fences of any type. It can attach to blocks with solid faces and other fences of the same +// type and has a model height just slightly over 1. +type Fence struct { + // Wood specifies if the Fence is made from wood. This field is used to check if two fences are able to attach to + // each other. + Wood bool +} + +const ( + fenceHeight = 1.5 + fenceInset = 0.375 +) + +// BBox returns multiple physics.BBox depending on how many connections it has with the surrounding blocks. +func (f Fence) BBox(pos cube.Pos, s world.BlockSource) []cube.BBox { + boxes := make([]cube.BBox, 0, 2) + + connectWest, connectEast := f.checkConnection(pos, cube.FaceWest, s), f.checkConnection(pos, cube.FaceEast, s) + connectNorth, connectSouth := f.checkConnection(pos, cube.FaceNorth, s), f.checkConnection(pos, cube.FaceSouth, s) + + // Check if we have any connections on the Z axis + if connectWest || connectEast { + sideBox := cube.Box(0, 0, 0, 1, fenceHeight, 1).Stretch(cube.Z, -fenceInset) + if connectWest { + boxes = append(boxes, sideBox.ExtendTowards(cube.FaceEast, -fenceInset)) + } + if connectEast { + boxes = append(boxes, sideBox.ExtendTowards(cube.FaceWest, -fenceInset)) + } + } + + // Check if we have any connections on the X axis + if connectNorth || connectSouth { + sideBox := cube.Box(0, 0, 0, 1, fenceHeight, 1).Stretch(cube.X, -fenceInset) + if connectNorth { + boxes = append(boxes, sideBox.ExtendTowards(cube.FaceSouth, -fenceInset)) + } + if connectSouth { + boxes = append(boxes, sideBox.ExtendTowards(cube.FaceNorth, -fenceInset)) + } + } + + // If no connections, create a center post box + if len(boxes) == 0 { + boxes = append(boxes, cube.Box(fenceInset, 0, fenceInset, 1-fenceInset, fenceHeight, 1-fenceInset)) + } + + return boxes +} + +// FaceSolid returns true if the face is cube.FaceDown or cube.FaceUp. +func (f Fence) FaceSolid(_ cube.Pos, face cube.Face, _ world.BlockSource) bool { + return face == cube.FaceDown || face == cube.FaceUp +} + +// checkConnection checks if the block at the given position and face has a connection to the current fence block. +func (f Fence) checkConnection(pos cube.Pos, face cube.Face, src world.BlockSource) bool { + sidePos := pos.Side(face) + sideBlock := src.Block(sidePos) + if fence, ok := sideBlock.Model().(Fence); ok && fence.Wood == f.Wood { + return true + } + if sideBlock.Model().FaceSolid(sidePos, face.Opposite(), src) { + return true + } + _, ok := sideBlock.Model().(FenceGate) + return ok +} diff --git a/server/block/model/fence_gate.go b/server/block/model/fence_gate.go new file mode 100644 index 0000000..e87728e --- /dev/null +++ b/server/block/model/fence_gate.go @@ -0,0 +1,28 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// FenceGate is a model used by fence gates. The model is completely zero-ed when the FenceGate is opened. +type FenceGate struct { + // Facing is the facing direction of the FenceGate. A fence gate can only be opened in this direction or the + // direction opposite to it. + Facing cube.Direction + // Open specifies if the FenceGate is open. In this case, BBox returns an empty slice. + Open bool +} + +// BBox returns up to one physics.BBox depending on the facing direction of the FenceGate and whether it is open. +func (f FenceGate) BBox(cube.Pos, world.BlockSource) []cube.BBox { + if f.Open { + return nil + } + return []cube.BBox{cube.Box(0, 0, 0, 1, 1.5, 1).Stretch(f.Facing.Face().Axis(), -0.375)} +} + +// FaceSolid always returns false. +func (f FenceGate) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/grindstone.go b/server/block/model/grindstone.go new file mode 100644 index 0000000..85c2a58 --- /dev/null +++ b/server/block/model/grindstone.go @@ -0,0 +1,22 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Grindstone is a model used by grindstones. +type Grindstone struct { + // Axis is the axis the grindstone is attached to. + Axis cube.Axis +} + +// BBox ... +func (g Grindstone) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return []cube.BBox{cube.Box(0.125, 0.125, 0.125, 0.825, 0.825, 0.825).Stretch(g.Axis, 0.125)} +} + +// FaceSolid always returns false. +func (g Grindstone) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/hopper.go b/server/block/model/hopper.go new file mode 100644 index 0000000..da0c18d --- /dev/null +++ b/server/block/model/hopper.go @@ -0,0 +1,23 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Hopper is a model used by hoppers. +type Hopper struct{} + +// BBox returns a physics.BBox that spans a full block. +func (h Hopper) BBox(cube.Pos, world.BlockSource) []cube.BBox { + bbox := []cube.BBox{full.ExtendTowards(cube.FaceUp, -0.375)} + for _, f := range cube.HorizontalFaces() { + bbox = append(bbox, full.ExtendTowards(f, -0.875)) + } + return bbox +} + +// FaceSolid only returns true for the top face of the hopper. +func (Hopper) FaceSolid(_ cube.Pos, face cube.Face, _ world.BlockSource) bool { + return face == cube.FaceUp +} diff --git a/server/block/model/ladder.go b/server/block/model/ladder.go new file mode 100644 index 0000000..e2f1193 --- /dev/null +++ b/server/block/model/ladder.go @@ -0,0 +1,22 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Ladder is the model for a ladder block. +type Ladder struct { + // Facing is the side opposite to the block the Ladder is currently attached to. + Facing cube.Direction +} + +// BBox returns one physics.BBox that depends on the facing direction of the Ladder. +func (l Ladder) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return []cube.BBox{full.ExtendTowards(l.Facing.Face(), -0.8125)} +} + +// FaceSolid always returns false. +func (l Ladder) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/lantern.go b/server/block/model/lantern.go new file mode 100644 index 0000000..18eecd0 --- /dev/null +++ b/server/block/model/lantern.go @@ -0,0 +1,25 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Lantern is a model for the lantern block. It can be placed on the ground or hanging from the ceiling. +type Lantern struct { + // Hanging specifies if the lantern is hanging from a block or if it's placed on the ground. + Hanging bool +} + +// BBox returns a physics.BBox attached to either the ceiling or to the ground. +func (l Lantern) BBox(cube.Pos, world.BlockSource) []cube.BBox { + if l.Hanging { + return []cube.BBox{cube.Box(0.3125, 0.125, 0.3125, 0.6875, 0.625, 0.6875)} + } + return []cube.BBox{cube.Box(0.3125, 0, 0.3125, 0.6875, 0.5, 0.6875)} +} + +// FaceSolid always returns false. +func (l Lantern) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/leaves.go b/server/block/model/leaves.go new file mode 100644 index 0000000..20a62a6 --- /dev/null +++ b/server/block/model/leaves.go @@ -0,0 +1,20 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Leaves is a model for leaves-like blocks. These blocks have a full collision box, but none of their faces +// are solid. +type Leaves struct{} + +// BBox returns a physics.BBox that spans a full block. +func (Leaves) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return []cube.BBox{full} +} + +// FaceSolid always returns false. +func (Leaves) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/lectern.go b/server/block/model/lectern.go new file mode 100644 index 0000000..bb153f9 --- /dev/null +++ b/server/block/model/lectern.go @@ -0,0 +1,19 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Lectern is a model used by lecterns. +type Lectern struct{} + +// BBox ... +func (Lectern) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return []cube.BBox{cube.Box(0, 0, 0, 1, 0.9, 1)} +} + +// FaceSolid ... +func (Lectern) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/lily_pad.go b/server/block/model/lily_pad.go new file mode 100644 index 0000000..6033286 --- /dev/null +++ b/server/block/model/lily_pad.go @@ -0,0 +1,19 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// LilyPad is a model for the lily pad block. +type LilyPad struct{} + +// BBox ... +func (LilyPad) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return []cube.BBox{cube.Box(0.0625, 0, 0.0625, 0.9375, 0.015625, 0.9375)} +} + +// FaceSolid ... +func (LilyPad) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/skull.go b/server/block/model/skull.go new file mode 100644 index 0000000..2407510 --- /dev/null +++ b/server/block/model/skull.go @@ -0,0 +1,28 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Skull is a model used by skull blocks. +type Skull struct { + // Direction is the direction the skull is facing. + Direction cube.Face + // Hanging specifies if the Skull is hanging on a wall. + Hanging bool +} + +// BBox ... +func (s Skull) BBox(cube.Pos, world.BlockSource) []cube.BBox { + box := cube.Box(0.25, 0, 0.25, 0.75, 0.5, 0.75) + if !s.Hanging { + return []cube.BBox{box} + } + return []cube.BBox{box.TranslateTowards(s.Direction.Opposite(), 0.25).TranslateTowards(cube.FaceUp, 0.25)} +} + +// FaceSolid ... +func (Skull) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/slab.go b/server/block/model/slab.go new file mode 100644 index 0000000..6cd8bd1 --- /dev/null +++ b/server/block/model/slab.go @@ -0,0 +1,37 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Slab is the model of a slab-like block, which is either a half block or a full block, depending on if the +// slab is double. +type Slab struct { + // Double and Top specify if the Slab is a double slab and if it's in the top slot respectively. If Double is true, + // the BBox returned is always a full block. + Double, Top bool +} + +// BBox returns either a physics.BBox spanning a full block or a half block in the top/bottom part of the block, +// depending on the Double and Top fields. +func (s Slab) BBox(cube.Pos, world.BlockSource) []cube.BBox { + if s.Double { + return []cube.BBox{full} + } + if s.Top { + return []cube.BBox{cube.Box(0, 0.5, 0, 1, 1, 1)} + } + return []cube.BBox{cube.Box(0, 0, 0, 1, 0.5, 1)} +} + +// FaceSolid returns true if the Slab is double, or if the face is cube.FaceUp when the Top field is true, or if the +// face is cube.FaceDown when the Top field is false. +func (s Slab) FaceSolid(_ cube.Pos, face cube.Face, _ world.BlockSource) bool { + if s.Double { + return true + } else if s.Top { + return face == cube.FaceUp + } + return face == cube.FaceDown +} diff --git a/server/block/model/solid.go b/server/block/model/solid.go new file mode 100644 index 0000000..3e7ef90 --- /dev/null +++ b/server/block/model/solid.go @@ -0,0 +1,23 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Solid is the model of a fully solid block. Blocks with this model, such as stone or wooden planks, have a +// 1x1x1 collision box. +type Solid struct{} + +// full is a BBox that occupies a full block. +var full = cube.Box(0, 0, 0, 1, 1, 1) + +// BBox returns a physics.BBox spanning a full block. +func (Solid) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return []cube.BBox{full} +} + +// FaceSolid always returns true. +func (Solid) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return true +} diff --git a/server/block/model/stair.go b/server/block/model/stair.go new file mode 100644 index 0000000..8f98160 --- /dev/null +++ b/server/block/model/stair.go @@ -0,0 +1,118 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// Stair is a model for stair-like blocks. These have different solid sides depending on the direction the +// stairs are facing, the surrounding blocks and whether it is upside down or not. +type Stair struct { + // Facing specifies the direction that the full side of the Stair faces. + Facing cube.Direction + // UpsideDown turns the Stair upside-down, meaning the full side of the Stair is turned to the top side of the + // block. + UpsideDown bool +} + +// BBox returns a slice of physics.BBox depending on if the Stair is upside down and which direction it is facing. +// Additionally, these BBoxs depend on the Stair blocks surrounding this one, which can influence the model. +func (s Stair) BBox(pos cube.Pos, bs world.BlockSource) []cube.BBox { + b := []cube.BBox{cube.Box(0, 0, 0, 1, 0.5, 1)} + if s.UpsideDown { + b[0] = cube.Box(0, 0.5, 0, 1, 1, 1) + } + t := s.cornerType(pos, bs) + + face, oppositeFace := s.Facing.Face(), s.Facing.Opposite().Face() + switch t { + case noCorner, cornerRightInner, cornerLeftInner: + b = append(b, cube.Box(0.5, 0.5, 0.5, 0.5, 1, 0.5). + ExtendTowards(face, 0.5). + Stretch(s.Facing.RotateRight().Face().Axis(), 0.5)) + } + switch t { + case cornerRightOuter: + b = append(b, cube.Box(0.5, 0.5, 0.5, 0.5, 1, 0.5). + ExtendTowards(face, 0.5). + ExtendTowards(s.Facing.RotateLeft().Face(), 0.5)) + case cornerLeftOuter: + b = append(b, cube.Box(0.5, 0.5, 0.5, 0.5, 1, 0.5). + ExtendTowards(face, 0.5). + ExtendTowards(s.Facing.RotateRight().Face(), 0.5)) + case cornerRightInner: + b = append(b, cube.Box(0.5, 0.5, 0.5, 0.5, 1, 0.5). + ExtendTowards(oppositeFace, 0.5). + ExtendTowards(s.Facing.RotateRight().Face(), 0.5)) + case cornerLeftInner: + b = append(b, cube.Box(0.5, 0.5, 0.5, 0.5, 1, 0.5). + ExtendTowards(oppositeFace, 0.5). + ExtendTowards(s.Facing.RotateLeft().Face(), 0.5)) + } + if s.UpsideDown { + for i := range b[1:] { + b[i+1] = b[i+1].Translate(mgl64.Vec3{0, -0.5}) + } + } + return b +} + +// FaceSolid returns true for all faces of the Stair that are completely filled. +func (s Stair) FaceSolid(pos cube.Pos, face cube.Face, bs world.BlockSource) bool { + // Stairs always have a closed side at the top or bottom based on their orientation. + if (face == cube.FaceUp && s.UpsideDown) || (face == cube.FaceDown && !s.UpsideDown) { + return true + } + + switch t := s.cornerType(pos, bs); t { + case cornerRightOuter, cornerLeftOuter: + // Small corner blocks, they do not block water flowing out horizontally. + return false + case noCorner: + // Not a corner, so only block directly behind the stairs. + return s.Facing.Face() == face + case cornerRightInner: + return face == s.Facing.RotateRight().Face() || face == s.Facing.Face() + default: + return face == s.Facing.RotateLeft().Face() || face == s.Facing.Face() + } +} + +const ( + noCorner = iota + cornerRightInner + cornerLeftInner + cornerRightOuter + cornerLeftOuter +) + +// cornerType returns the type of the corner that the stairs form, or 0 if it does not form a corner with any +// other stairs. +func (s Stair) cornerType(pos cube.Pos, bs world.BlockSource) uint8 { + rotatedFacing := s.Facing.RotateRight() + if closedSide, ok := bs.Block(pos.Side(s.Facing.Face())).Model().(Stair); ok && closedSide.UpsideDown == s.UpsideDown { + if closedSide.Facing == rotatedFacing { + return cornerLeftOuter + } else if closedSide.Facing == rotatedFacing.Opposite() { + // This will only form a corner if there is not a stair on the right of this one with the same + // direction. + if side, ok := bs.Block(pos.Side(s.Facing.RotateRight().Face())).Model().(Stair); !ok || side.Facing != s.Facing || side.UpsideDown != s.UpsideDown { + return cornerRightOuter + } + return noCorner + } + } + if openSide, ok := bs.Block(pos.Side(s.Facing.Opposite().Face())).Model().(Stair); ok && openSide.UpsideDown == s.UpsideDown { + if openSide.Facing == rotatedFacing { + // This will only form a corner if there is not a stair on the right of this one with the same + // direction. + if side, ok := bs.Block(pos.Side(s.Facing.RotateRight().Face())).Model().(Stair); !ok || side.Facing != s.Facing || side.UpsideDown != s.UpsideDown { + return cornerRightInner + } + } else if openSide.Facing == rotatedFacing.Opposite() { + return cornerLeftInner + } + } + return noCorner +} diff --git a/server/block/model/stonecutter.go b/server/block/model/stonecutter.go new file mode 100644 index 0000000..24bd380 --- /dev/null +++ b/server/block/model/stonecutter.go @@ -0,0 +1,19 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Stonecutter is a model used by stonecutters. +type Stonecutter struct{} + +// BBox ... +func (Stonecutter) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return []cube.BBox{cube.Box(0, 0, 0, 1, 0.5625, 1)} +} + +// FaceSolid ... +func (Stonecutter) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/thin.go b/server/block/model/thin.go new file mode 100644 index 0000000..62c0d2a --- /dev/null +++ b/server/block/model/thin.go @@ -0,0 +1,66 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Thin is a model for thin, partial blocks such as a glass pane or an iron bar. It changes its bounding box depending +// on solid faces next to it. +type Thin struct{} + +const ( + thinHeight = 1 + thinInset = 7.0 / 16.0 +) + +// BBox returns a slice of physics.BBox that depends on the blocks surrounding the Thin block. Thin blocks can connect +// to any other Thin block, wall or solid faces of other blocks. +func (t Thin) BBox(pos cube.Pos, s world.BlockSource) []cube.BBox { + boxes := make([]cube.BBox, 0, 2) + + // Check if we have any connections on the Z axis + connectWest, connectEast := t.checkConnection(pos, cube.FaceWest, s), t.checkConnection(pos, cube.FaceEast, s) + if connectWest || connectEast { + box := cube.Box(0, 0, 0, 1, thinHeight, 1).Stretch(cube.Z, -thinInset) + if !connectWest { + box = box.ExtendTowards(cube.FaceWest, -thinInset) + } else if !connectEast { + box = box.ExtendTowards(cube.FaceEast, -thinInset) + } + boxes = append(boxes, box) + } + + // Check if we have any connections on the X axis + connectNorth, connectSouth := t.checkConnection(pos, cube.FaceNorth, s), t.checkConnection(pos, cube.FaceSouth, s) + if connectNorth || connectSouth { + box := cube.Box(0, 0, 0, 1, thinHeight, 1).Stretch(cube.X, -thinInset) + if !connectNorth { + box = box.ExtendTowards(cube.FaceNorth, -thinInset) + } else if !connectSouth { + box = box.ExtendTowards(cube.FaceSouth, -thinInset) + } + boxes = append(boxes, box) + } + + // If no connections, create a center post box + if len(boxes) == 0 { + boxes = append(boxes, cube.Box(0, 0, 0, 1, thinHeight, 1).Stretch(cube.X, -thinInset).Stretch(cube.Z, -thinInset)) + } + + return boxes +} + +// FaceSolid returns true if the face passed is cube.FaceDown. +func (t Thin) FaceSolid(_ cube.Pos, face cube.Face, _ world.BlockSource) bool { + return face == cube.FaceDown +} + +// checkConnection checks if the block at the given position and face has a connection to the current thin block. +func (t Thin) checkConnection(pos cube.Pos, face cube.Face, s world.BlockSource) bool { + sidePos := pos.Side(face) + sideBlock := s.Block(sidePos) + _, isThin := sideBlock.Model().(Thin) + _, isWall := sideBlock.Model().(Wall) + return isThin || isWall || sideBlock.Model().FaceSolid(sidePos, face.Opposite(), s) +} diff --git a/server/block/model/tilled_grass.go b/server/block/model/tilled_grass.go new file mode 100644 index 0000000..cd609b3 --- /dev/null +++ b/server/block/model/tilled_grass.go @@ -0,0 +1,19 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// TilledGrass is a model used for grass that has been tilled in some way, such as dirt paths and farmland. +type TilledGrass struct{} + +// BBox returns a physics.BBox that spans an entire block. +func (TilledGrass) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return []cube.BBox{full.ExtendTowards(cube.FaceUp, -0.0625)} +} + +// FaceSolid always returns true. +func (TilledGrass) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return true +} diff --git a/server/block/model/trapdoor.go b/server/block/model/trapdoor.go new file mode 100644 index 0000000..dac7778 --- /dev/null +++ b/server/block/model/trapdoor.go @@ -0,0 +1,37 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Trapdoor is a model used for trapdoors. It has no solid faces and a bounding box that changes depending on +// the direction of the trapdoor. +type Trapdoor struct { + // Facing is the facing direction of the Trapdoor. In addition to the texture, it influences the direction in which + // the Trapdoor is opened. + Facing cube.Direction + // Open and Top specify if the Trapdoor is opened and if it's in the top or bottom part of a block respectively. + Open, Top bool +} + +// BBox returns a physics.BBox that depends on the facing direction of the Trapdoor and whether it is open and in the +// top part of the block. +func (t Trapdoor) BBox(cube.Pos, world.BlockSource) []cube.BBox { + if t.Open { + return []cube.BBox{full.ExtendTowards(t.Facing.Face(), -0.8125)} + } else if t.Top { + return []cube.BBox{cube.Box(0, 0.8125, 0, 1, 1, 1)} + } + return []cube.BBox{cube.Box(0, 0, 0, 1, 0.1875, 1)} +} + +// FaceSolid returns true if the face is completely filled with the trapdoor. +func (t Trapdoor) FaceSolid(_ cube.Pos, face cube.Face, _ world.BlockSource) bool { + if t.Open { + return t.Facing.Face().Opposite() == face + } else if t.Top { + return face == cube.FaceUp + } + return face == cube.FaceDown +} diff --git a/server/block/model/wall.go b/server/block/model/wall.go new file mode 100644 index 0000000..284c151 --- /dev/null +++ b/server/block/model/wall.go @@ -0,0 +1,47 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Wall is a model used by all wall types. +type Wall struct { + // NorthConnection is the height of the connection for the north direction. + NorthConnection float64 + // EastConnection is the height of the connection for the east direction. + EastConnection float64 + // SouthConnection is the height of the connection for the south direction. + SouthConnection float64 + // WestConnection is the height of the connection for the west direction. + WestConnection float64 + // Post is if the wall is the full height of a block or not. + Post bool +} + +// BBox ... +func (w Wall) BBox(cube.Pos, world.BlockSource) []cube.BBox { + postHeight := 0.8125 + if w.Post { + postHeight = 1 + } + boxes := []cube.BBox{cube.Box(0.25, 0, 0.25, 0.75, postHeight, 0.75)} + if w.NorthConnection > 0 { + boxes = append(boxes, cube.Box(0.25, 0, 0, 0.75, w.NorthConnection, 0.25)) + } + if w.EastConnection > 0 { + boxes = append(boxes, cube.Box(0.75, 0, 0.25, 1, w.EastConnection, 0.75)) + } + if w.SouthConnection > 0 { + boxes = append(boxes, cube.Box(0.25, 0, 0.75, 0.75, w.SouthConnection, 1)) + } + if w.WestConnection > 0 { + boxes = append(boxes, cube.Box(0, 0, 0.25, 0.25, w.WestConnection, 0.75)) + } + return boxes +} + +// FaceSolid returns true if the face is in the Y axis. +func (w Wall) FaceSolid(_ cube.Pos, face cube.Face, _ world.BlockSource) bool { + return face.Axis() == cube.Y +} diff --git a/server/block/moss_carpet.go b/server/block/moss_carpet.go new file mode 100644 index 0000000..3f086cd --- /dev/null +++ b/server/block/moss_carpet.go @@ -0,0 +1,66 @@ +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" +) + +// MossCarpet is a thin decorative variant of the moss block. +type MossCarpet struct { + carpet + transparent + sourceWaterDisplacer +} + +// SideClosed ... +func (MossCarpet) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// HasLiquidDrops ... +func (MossCarpet) HasLiquidDrops() bool { + return true +} + +// NeighbourUpdateTick ... +func (m MossCarpet) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if _, ok := tx.Block(pos.Side(cube.FaceDown)).(Air); ok { + breakBlock(m, pos, tx) + } +} + +// UseOnBlock ... +func (m MossCarpet) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, m) + if !used { + return + } + if _, ok := tx.Block(pos.Side(cube.FaceDown)).(Air); ok { + return + } + + place(tx, pos, m, user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (m MossCarpet) BreakInfo() BreakInfo { + return newBreakInfo(0.1, alwaysHarvestable, nothingEffective, oneOf(m)) +} + +// CompostChance ... +func (MossCarpet) CompostChance() float64 { + return 0.3 +} + +// EncodeItem ... +func (m MossCarpet) EncodeItem() (name string, meta int16) { + return "minecraft:moss_carpet", 0 +} + +// EncodeBlock ... +func (m MossCarpet) EncodeBlock() (string, map[string]any) { + return "minecraft:moss_carpet", nil +} diff --git a/server/block/mud.go b/server/block/mud.go new file mode 100644 index 0000000..13c225e --- /dev/null +++ b/server/block/mud.go @@ -0,0 +1,32 @@ +package block + +import "github.com/df-mc/dragonfly/server/world" + +// Mud is a decorative block obtained by using a water bottle on a dirt block. +type Mud struct { + solid +} + +// SoilFor ... +func (Mud) SoilFor(block world.Block) bool { + switch block.(type) { + case ShortGrass, Fern, DoubleTallGrass, Flower, DoubleFlower, NetherSprouts, PinkPetals, DeadBush: + return true + } + return false +} + +// BreakInfo ... +func (m Mud) BreakInfo() BreakInfo { + return newBreakInfo(0.5, alwaysHarvestable, shovelEffective, oneOf(m)) +} + +// EncodeItem ... +func (Mud) EncodeItem() (name string, meta int16) { + return "minecraft:mud", 0 +} + +// EncodeBlock ... +func (Mud) EncodeBlock() (string, map[string]any) { + return "minecraft:mud", nil +} diff --git a/server/block/mud_bricks.go b/server/block/mud_bricks.go new file mode 100644 index 0000000..1acf700 --- /dev/null +++ b/server/block/mud_bricks.go @@ -0,0 +1,22 @@ +package block + +// MudBricks are a decorative block obtained by crafting 4 packed mud. +type MudBricks struct { + solid + bassDrum +} + +// BreakInfo ... +func (m MudBricks) BreakInfo() BreakInfo { + return newBreakInfo(1.5, alwaysHarvestable, nothingEffective, oneOf(m)).withBlastResistance(15) +} + +// EncodeItem ... +func (MudBricks) EncodeItem() (name string, meta int16) { + return "minecraft:mud_bricks", 0 +} + +// EncodeBlock ... +func (MudBricks) EncodeBlock() (string, map[string]any) { + return "minecraft:mud_bricks", nil +} diff --git a/server/block/muddy_mangrove_roots.go b/server/block/muddy_mangrove_roots.go new file mode 100644 index 0000000..548e934 --- /dev/null +++ b/server/block/muddy_mangrove_roots.go @@ -0,0 +1,60 @@ +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" +) + +// MuddyMangroveRoots are a decorative variant of mangrove roots. +type MuddyMangroveRoots struct { + solid + + // Axis is the axis which the muddy mangrove roots faces. + Axis cube.Axis +} + +// BreakInfo ... +func (m MuddyMangroveRoots) BreakInfo() BreakInfo { + return newBreakInfo(0.7, alwaysHarvestable, shovelEffective, oneOf(m)) +} + +// SoilFor ... +func (MuddyMangroveRoots) SoilFor(block world.Block) bool { + switch block.(type) { + case ShortGrass, Fern, DoubleTallGrass, Flower, DoubleFlower, NetherSprouts, PinkPetals: + return true + } + return false +} + +// UseOnBlock ... +func (m MuddyMangroveRoots) 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, m) + if !used { + return + } + m.Axis = face.Axis() + + place(tx, pos, m, user, ctx) + return placed(ctx) +} + +// EncodeItem ... +func (MuddyMangroveRoots) EncodeItem() (name string, meta int16) { + return "minecraft:muddy_mangrove_roots", 0 +} + +// EncodeBlock ... +func (m MuddyMangroveRoots) EncodeBlock() (string, map[string]any) { + return "minecraft:muddy_mangrove_roots", map[string]any{"pillar_axis": m.Axis.String()} +} + +// allMuddyMangroveRoots ... +func allMuddyMangroveRoots() (roots []world.Block) { + for _, axis := range cube.Axes() { + roots = append(roots, MuddyMangroveRoots{Axis: axis}) + } + return +} diff --git a/server/block/nether_brick_fence.go b/server/block/nether_brick_fence.go new file mode 100644 index 0000000..841b1cb --- /dev/null +++ b/server/block/nether_brick_fence.go @@ -0,0 +1,38 @@ +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" +) + +// NetherBrickFence is the nether brick variant of the fence block. +type NetherBrickFence struct { + transparent + sourceWaterDisplacer +} + +// BreakInfo ... +func (n NetherBrickFence) BreakInfo() BreakInfo { + return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(n)).withBlastResistance(30) +} + +// SideClosed ... +func (NetherBrickFence) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// Model ... +func (n NetherBrickFence) Model() world.BlockModel { + return model.Fence{} +} + +// EncodeItem ... +func (NetherBrickFence) EncodeItem() (name string, meta int16) { + return "minecraft:nether_brick_fence", 0 +} + +// EncodeBlock ... +func (NetherBrickFence) EncodeBlock() (string, map[string]any) { + return "minecraft:nether_brick_fence", nil +} diff --git a/server/block/nether_bricks.go b/server/block/nether_bricks.go new file mode 100644 index 0000000..3b12f64 --- /dev/null +++ b/server/block/nether_bricks.go @@ -0,0 +1,47 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// NetherBricks are blocks used to form nether fortresses in the Nether. +// Red Nether bricks, Cracked Nether bricks and Chiseled Nether bricks are decorative variants that do not naturally generate. +type NetherBricks struct { + solid + bassDrum + + // NetherBricksType is the type of nether bricks of the block. + Type NetherBricksType +} + +// BreakInfo ... +func (n NetherBricks) BreakInfo() BreakInfo { + return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(n)).withBlastResistance(30) +} + +// SmeltInfo ... +func (n NetherBricks) SmeltInfo() item.SmeltInfo { + if n.Type == NormalNetherBricks() { + return newSmeltInfo(item.NewStack(NetherBricks{Type: CrackedNetherBricks()}, 1), 0.1) + } + return item.SmeltInfo{} +} + +// EncodeItem ... +func (n NetherBricks) EncodeItem() (id string, meta int16) { + return "minecraft:" + n.Type.String(), 0 +} + +// EncodeBlock ... +func (n NetherBricks) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:" + n.Type.String(), nil +} + +// allNetherBricks returns a list of all nether bricks variants. +func allNetherBricks() (netherBricks []world.Block) { + for _, t := range NetherBricksTypes() { + netherBricks = append(netherBricks, NetherBricks{Type: t}) + } + return +} diff --git a/server/block/nether_bricks_type.go b/server/block/nether_bricks_type.go new file mode 100644 index 0000000..e0018e8 --- /dev/null +++ b/server/block/nether_bricks_type.go @@ -0,0 +1,68 @@ +package block + +// NetherBricksType represents a type of nether bricks. +type NetherBricksType struct { + netherBricks +} + +type netherBricks uint8 + +// NormalNetherBricks is the normal variant of the nether bricks. +func NormalNetherBricks() NetherBricksType { + return NetherBricksType{0} +} + +// RedNetherBricks is the red variant of the nether bricks. +func RedNetherBricks() NetherBricksType { + return NetherBricksType{1} +} + +// CrackedNetherBricks is the cracked variant of the nether bricks. +func CrackedNetherBricks() NetherBricksType { + return NetherBricksType{2} +} + +// ChiseledNetherBricks is the chiseled variant of the nether bricks. +func ChiseledNetherBricks() NetherBricksType { + return NetherBricksType{3} +} + +// Uint8 returns the nether bricks as a unit8. +func (n netherBricks) Uint8() uint8 { + return uint8(n) +} + +// Name ... +func (n netherBricks) Name() string { + switch n { + case 0: + return "Nether Bricks" + case 1: + return "Red Nether Bricks" + case 2: + return "Cracked Nether Bricks" + case 3: + return "Chiseled Nether Bricks" + } + panic("unknown nether brick type") +} + +// String ... +func (n netherBricks) String() string { + switch n { + case 0: + return "nether_brick" + case 1: + return "red_nether_brick" + case 2: + return "cracked_nether_bricks" + case 3: + return "chiseled_nether_bricks" + } + panic("unknown nether brick type") +} + +// NetherBricksTypes returns all nether bricks types. +func NetherBricksTypes() []NetherBricksType { + return []NetherBricksType{NormalNetherBricks(), RedNetherBricks(), CrackedNetherBricks(), ChiseledNetherBricks()} +} diff --git a/server/block/nether_gold_ore.go b/server/block/nether_gold_ore.go new file mode 100644 index 0000000..5725f69 --- /dev/null +++ b/server/block/nether_gold_ore.go @@ -0,0 +1,30 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" +) + +// NetherGoldOre is a variant of gold ore found exclusively in The Nether. +type NetherGoldOre struct { + solid +} + +// BreakInfo ... +func (n NetherGoldOre) BreakInfo() BreakInfo { + return newBreakInfo(3, pickaxeHarvestable, pickaxeEffective, multiOreDrops(item.GoldNugget{}, n, 2, 6)).withXPDropRange(0, 1) +} + +// SmeltInfo ... +func (NetherGoldOre) SmeltInfo() item.SmeltInfo { + return newOreSmeltInfo(item.NewStack(item.GoldIngot{}, 1), 1) +} + +// EncodeItem ... +func (NetherGoldOre) EncodeItem() (name string, meta int16) { + return "minecraft:nether_gold_ore", 0 +} + +// EncodeBlock ... +func (NetherGoldOre) EncodeBlock() (string, map[string]any) { + return "minecraft:nether_gold_ore", nil +} diff --git a/server/block/nether_sprouts.go b/server/block/nether_sprouts.go new file mode 100644 index 0000000..25ab662 --- /dev/null +++ b/server/block/nether_sprouts.go @@ -0,0 +1,68 @@ +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" +) + +// NetherSprouts are a non-solid plant block that generate in warped forests. +type NetherSprouts struct { + transparent + replaceable + empty +} + +// NeighbourUpdateTick ... +func (n NetherSprouts) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if !supportsVegetation(n, tx.Block(pos.Side(cube.FaceDown))) { + breakBlock(n, pos, tx) // TODO: Nylium & mycelium + } +} + +// UseOnBlock ... +func (n NetherSprouts) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, n) + if !used { + return false + } + if !supportsVegetation(n, tx.Block(pos.Side(cube.FaceDown))) { + return false // TODO: Nylium & mycelium + } + + place(tx, pos, n, user, ctx) + return placed(ctx) +} + +// HasLiquidDrops ... +func (n NetherSprouts) HasLiquidDrops() bool { + return false +} + +// FlammabilityInfo ... +func (n NetherSprouts) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(0, 0, true) +} + +// BreakInfo ... +func (n NetherSprouts) BreakInfo() BreakInfo { + return newBreakInfo(0, func(t item.Tool) bool { + return t.ToolType() == item.TypeShears + }, nothingEffective, oneOf(n)) +} + +// CompostChance ... +func (NetherSprouts) CompostChance() float64 { + return 0.5 +} + +// EncodeItem ... +func (n NetherSprouts) EncodeItem() (name string, meta int16) { + return "minecraft:nether_sprouts", 0 +} + +// EncodeBlock ... +func (n NetherSprouts) EncodeBlock() (string, map[string]any) { + return "minecraft:nether_sprouts", nil +} diff --git a/server/block/nether_wart.go b/server/block/nether_wart.go new file mode 100644 index 0000000..2f9fda2 --- /dev/null +++ b/server/block/nether_wart.go @@ -0,0 +1,86 @@ +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" +) + +// NetherWart is a fungus found in the Nether that is vital in the creation of potions. +type NetherWart struct { + transparent + empty + + // Age is the age of the nether wart block. 3 is fully grown. + Age int +} + +// HasLiquidDrops ... +func (n NetherWart) HasLiquidDrops() bool { + return true +} + +// RandomTick ... +func (n NetherWart) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + if n.Age < 3 && r.Float64() < 0.1 { + n.Age++ + tx.SetBlock(pos, n, nil) + } +} + +// UseOnBlock ... +func (n NetherWart) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, n) + if !used { + return false + } + if _, ok := tx.Block(pos.Side(cube.FaceDown)).(SoulSand); !ok { + return false + } + + place(tx, pos, n, user, ctx) + return placed(ctx) +} + +// NeighbourUpdateTick ... +func (n NetherWart) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if _, ok := tx.Block(pos.Side(cube.FaceDown)).(SoulSand); !ok { + breakBlock(n, pos, tx) + } +} + +// BreakInfo ... +func (n NetherWart) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, func(t item.Tool, enchantments []item.Enchantment) []item.Stack { + if n.Age < 3 { + return []item.Stack{item.NewStack(n, 1)} + } + return []item.Stack{item.NewStack(n, fortuneDiscreteCount(2, 4, 7, enchantments))} + }) +} + +// CompostChance ... +func (NetherWart) CompostChance() float64 { + return 0.65 +} + +// EncodeItem ... +func (NetherWart) EncodeItem() (name string, meta int16) { + return "minecraft:nether_wart", 0 +} + +// EncodeBlock ... +func (n NetherWart) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:nether_wart", map[string]any{"age": int32(n.Age)} +} + +// allNetherWart ... +func allNetherWart() (wart []world.Block) { + for i := 0; i < 4; i++ { + wart = append(wart, NetherWart{Age: i}) + } + return +} diff --git a/server/block/nether_wart_block.go b/server/block/nether_wart_block.go new file mode 100644 index 0000000..408d00a --- /dev/null +++ b/server/block/nether_wart_block.go @@ -0,0 +1,35 @@ +package block + +// NetherWartBlock is a decorative block found in crimson forests and crafted using Nether wart. +type NetherWartBlock struct { + solid + + // Warped is the turquoise variant found in warped forests, but cannot be crafted unlike Nether wart block. + Warped bool +} + +// BreakInfo ... +func (n NetherWartBlock) BreakInfo() BreakInfo { + return newBreakInfo(1, alwaysHarvestable, hoeEffective, oneOf(n)) +} + +// CompostChance ... +func (NetherWartBlock) CompostChance() float64 { + return 0.85 +} + +// EncodeItem ... +func (n NetherWartBlock) EncodeItem() (name string, meta int16) { + if n.Warped { + return "minecraft:warped_wart_block", 0 + } + return "minecraft:nether_wart_block", 0 +} + +// EncodeBlock ... +func (n NetherWartBlock) EncodeBlock() (name string, properties map[string]interface{}) { + if n.Warped { + return "minecraft:warped_wart_block", nil + } + return "minecraft:nether_wart_block", nil +} diff --git a/server/block/netherite.go b/server/block/netherite.go new file mode 100644 index 0000000..307bc22 --- /dev/null +++ b/server/block/netherite.go @@ -0,0 +1,33 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" +) + +// Netherite is a precious mineral block made from 9 netherite ingots. +type Netherite struct { + solid + bassDrum +} + +// BreakInfo ... +func (n Netherite) BreakInfo() BreakInfo { + return newBreakInfo(50, func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierDiamond.HarvestLevel + }, pickaxeEffective, oneOf(n)).withBlastResistance(6000) +} + +// PowersBeacon ... +func (Netherite) PowersBeacon() bool { + return true +} + +// EncodeItem ... +func (Netherite) EncodeItem() (name string, meta int16) { + return "minecraft:netherite_block", 0 +} + +// EncodeBlock ... +func (Netherite) EncodeBlock() (string, map[string]any) { + return "minecraft:netherite_block", nil +} diff --git a/server/block/netherrack.go b/server/block/netherrack.go new file mode 100644 index 0000000..10b1595 --- /dev/null +++ b/server/block/netherrack.go @@ -0,0 +1,38 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// Netherrack is a block found in The Nether. +type Netherrack struct { + solid + bassDrum +} + +// SoilFor ... +func (n Netherrack) SoilFor(block world.Block) bool { + flower, ok := block.(Flower) + return ok && flower.Type == WitherRose() +} + +// BreakInfo ... +func (n Netherrack) BreakInfo() BreakInfo { + return newBreakInfo(0.4, pickaxeHarvestable, pickaxeEffective, oneOf(n)) +} + +// SmeltInfo ... +func (Netherrack) SmeltInfo() item.SmeltInfo { + return newSmeltInfo(item.NewStack(item.NetherBrick{}, 1), 0.1) +} + +// EncodeItem ... +func (Netherrack) EncodeItem() (name string, meta int16) { + return "minecraft:netherrack", 0 +} + +// EncodeBlock ... +func (Netherrack) EncodeBlock() (string, map[string]any) { + return "minecraft:netherrack", nil +} diff --git a/server/block/note.go b/server/block/note.go new file mode 100644 index 0000000..719d9cd --- /dev/null +++ b/server/block/note.go @@ -0,0 +1,114 @@ +package block + +import ( + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "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/particle" + "github.com/df-mc/dragonfly/server/world/sound" +) + +// Note is a musical block that emits sounds when powered with redstone. +type Note struct { + solid + bass + + // Pitch is the current pitch the note block is set to. Value ranges from 0-24. + Pitch int + // Powered is whether the note block was powered during its last redstone update. + Powered bool +} + +// playNote ... +func (n Note) playNote(pos cube.Pos, tx *world.Tx) { + tx.PlaySound(pos.Vec3(), sound.Note{Instrument: n.instrument(pos, tx), Pitch: n.Pitch}) + tx.AddParticle(pos.Vec3(), particle.Note{Instrument: n.Instrument(), Pitch: n.Pitch}) +} + +// updateInstrument ... +func (n Note) instrument(pos cube.Pos, tx *world.Tx) sound.Instrument { + if instrumentBlock, ok := tx.Block(pos.Side(cube.FaceDown)).(interface { + Instrument() sound.Instrument + }); ok { + return instrumentBlock.Instrument() + } + return sound.Piano() +} + +// DecodeNBT ... +func (n Note) DecodeNBT(data map[string]any) any { + n.Pitch = int(nbtconv.Uint8(data, "note")) + n.Powered = nbtconv.Bool(data, "powered") + return n +} + +// EncodeNBT ... +func (n Note) EncodeNBT() map[string]any { + return map[string]any{"note": byte(n.Pitch), "powered": boolByte(n.Powered)} +} + +// Activate tunes and plays the note block when there is room above it. +func (n Note) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, _ item.User, _ *item.UseContext) bool { + if !n.canPlay(pos, tx) { + return false + } + n.Pitch = (n.Pitch + 1) % 25 + n.playNote(pos, tx) + tx.SetBlock(pos, n, &world.SetOpts{DisableBlockUpdates: true}) + return true +} + +// RedstoneUpdate updates the note block's powered state and plays the note when it first becomes powered. +func (n Note) RedstoneUpdate(pos cube.Pos, tx *world.Tx) { + poweredFaces := n.poweredFaces(pos, tx) + powered := len(poweredFaces) > 0 + if powered == n.Powered { + return + } + n.Powered = powered + if powered && n.canPlay(pos, tx) { + n.playNote(pos, tx) + } + tx.SetBlock(pos, n, &world.SetOpts{DisableBlockUpdates: true}) + updateAroundRedstone(pos, tx, poweredFaces...) +} + +// canPlay reports whether the block above the note block is air. +func (n Note) canPlay(pos cube.Pos, tx *world.Tx) bool { + _, ok := tx.Block(pos.Side(cube.FaceUp)).(Air) + return ok +} + +func (n Note) poweredFaces(pos cube.Pos, tx *world.Tx) []cube.Face { + var faces []cube.Face + for _, face := range cube.Faces() { + adjacentPos := pos.Side(face) + if power := tx.RedstonePower(adjacentPos, face, true); power > 0 { + faces = append(faces, face) + } + } + return faces +} + +// BreakInfo ... +func (n Note) BreakInfo() BreakInfo { + return newBreakInfo(0.8, alwaysHarvestable, axeEffective, oneOf(Note{})) +} + +// FuelInfo ... +func (Note) FuelInfo() item.FuelInfo { + return newFuelInfo(time.Second * 15) +} + +// EncodeItem ... +func (n Note) EncodeItem() (name string, meta int16) { + return "minecraft:noteblock", 0 +} + +// EncodeBlock ... +func (n Note) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:noteblock", nil +} diff --git a/server/block/obsidian.go b/server/block/obsidian.go new file mode 100644 index 0000000..5d99bd4 --- /dev/null +++ b/server/block/obsidian.go @@ -0,0 +1,45 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" +) + +// Obsidian is a dark purple block known for its high blast resistance and strength, most commonly found when +// water flows over lava. +type Obsidian struct { + solid + bassDrum + // Crying specifies if the block is a crying obsidian block. If true, the block is blue and emits light. + Crying bool +} + +// LightEmissionLevel ... +func (o Obsidian) LightEmissionLevel() uint8 { + if o.Crying { + return 10 + } + return 0 +} + +// EncodeItem ... +func (o Obsidian) EncodeItem() (name string, meta int16) { + if o.Crying { + return "minecraft:crying_obsidian", 0 + } + return "minecraft:obsidian", 0 +} + +// EncodeBlock ... +func (o Obsidian) EncodeBlock() (string, map[string]any) { + if o.Crying { + return "minecraft:crying_obsidian", nil + } + return "minecraft:obsidian", nil +} + +// BreakInfo ... +func (o Obsidian) BreakInfo() BreakInfo { + return newBreakInfo(35, func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierDiamond.HarvestLevel + }, pickaxeEffective, oneOf(o)).withBlastResistance(6000) +} diff --git a/server/block/ore_type.go b/server/block/ore_type.go new file mode 100644 index 0000000..ba55b49 --- /dev/null +++ b/server/block/ore_type.go @@ -0,0 +1,72 @@ +package block + +// OreType represents a variant of ore blocks. +type OreType struct { + ore +} + +// StoneOre returns the stone ore variant. +func StoneOre() OreType { + return OreType{0} +} + +// DeepslateOre returns the deepslate ore variant. +func DeepslateOre() OreType { + return OreType{1} +} + +// OreTypes returns a list of all ore types +func OreTypes() []OreType { + return []OreType{StoneOre(), DeepslateOre()} +} + +type ore uint8 + +// Uint8 returns the ore as a uint8. +func (o ore) Uint8() uint8 { + return uint8(o) +} + +// Name ... +func (o ore) Name() string { + switch o { + case 0: + return "Stone" + case 1: + return "Deepslate" + } + panic("unknown ore type") +} + +// String ... +func (o ore) String() string { + switch o { + case 0: + return "stone" + case 1: + return "deepslate" + } + panic("unknown ore type") +} + +// Prefix ... +func (o ore) Prefix() string { + switch o { + case 0: + return "" + case 1: + return "deepslate_" + } + panic("unknown ore type") +} + +// Hardness ... +func (o ore) Hardness() float64 { + switch o { + case 0: + return 3 + case 1: + return 4.5 + } + panic("unknown ore type") +} diff --git a/server/block/oxidation_type.go b/server/block/oxidation_type.go new file mode 100644 index 0000000..6f64f92 --- /dev/null +++ b/server/block/oxidation_type.go @@ -0,0 +1,86 @@ +package block + +// OxidationType represents a type of oxidation. +type OxidationType struct { + oxidation +} + +type oxidation uint8 + +// UnoxidisedOxidation is the normal variant of oxidation. +func UnoxidisedOxidation() OxidationType { + return OxidationType{0} +} + +// ExposedOxidation is the exposed variant of oxidation. +func ExposedOxidation() OxidationType { + return OxidationType{1} +} + +// WeatheredOxidation is the weathered variant of oxidation. +func WeatheredOxidation() OxidationType { + return OxidationType{2} +} + +// OxidisedOxidation is the oxidised variant of oxidation. +func OxidisedOxidation() OxidationType { + return OxidationType{3} +} + +// Uint8 returns the oxidation as a uint8. +func (s oxidation) Uint8() uint8 { + return uint8(s) +} + +// Name ... +func (s oxidation) Name() string { + switch s { + case 0: + return "" + case 1: + return "Exposed" + case 2: + return "Weathered" + case 3: + return "Oxidized" + } + panic("unknown oxidation type") +} + +// Decrease attempts to decrease the oxidation level by one. It returns the new oxidation level and if the +// decrease was successful. +func (s oxidation) Decrease() (OxidationType, bool) { + if s > 0 { + return OxidationType{s - 1}, true + } + return UnoxidisedOxidation(), false +} + +// Increase attempts to increase the oxidation level by one. It returns the new oxidation level and if the +// increase was successful. +func (s oxidation) Increase() (OxidationType, bool) { + if s < 3 { + return OxidationType{s + 1}, true + } + return OxidisedOxidation(), false +} + +// String ... +func (s oxidation) String() string { + switch s { + case 0: + return "" + case 1: + return "exposed" + case 2: + return "weathered" + case 3: + return "oxidized" + } + panic("unknown oxidation type") +} + +// OxidationTypes ... +func OxidationTypes() []OxidationType { + return []OxidationType{UnoxidisedOxidation(), ExposedOxidation(), WeatheredOxidation(), OxidisedOxidation()} +} diff --git a/server/block/oxidizable.go b/server/block/oxidizable.go new file mode 100644 index 0000000..d992974 --- /dev/null +++ b/server/block/oxidizable.go @@ -0,0 +1,80 @@ +package block + +import ( + "math/rand/v2" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Oxidisable is a block that can naturally oxidise over time, such as copper. +type Oxidisable interface { + world.Block + // CanOxidate returns whether the block can oxidate, i.e. if it's not waxed. + CanOxidate() bool + // OxidationLevel returns the currently level of oxidation of the block. + OxidationLevel() OxidationType + // WithOxidationLevel returns the oxidizable block with the oxidation level passed. + WithOxidationLevel(OxidationType) Oxidisable +} + +// attemptOxidation attempts to oxidise the block at the position passed. The details for this logic is +// described on the Minecraft Wiki: https://minecraft.wiki/w/Oxidation. +func attemptOxidation(pos cube.Pos, tx *world.Tx, r *rand.Rand, o Oxidisable) { + level := o.OxidationLevel() + if level == OxidisedOxidation() || !o.CanOxidate() { + return + } else if r.Float64() > 64.0/1125.0 { + return + } + + var all, higher int + for x := -4; x <= 4; x++ { + for y := -4; y <= 4; y++ { + for z := -4; z <= 4; z++ { + if x == 0 && y == 0 && z == 0 { + continue + } + nPos := pos.Add(cube.Pos{x, y, z}) + dist := abs(nPos.X()-pos.X()) + abs(nPos.Y()-pos.Y()) + abs(nPos.Z()-pos.Z()) + if dist > 4 { + continue + } + + b, ok := tx.Block(nPos).(Oxidisable) + if !ok || !b.CanOxidate() { + continue + } else if b.OxidationLevel().Uint8() < level.Uint8() { + return + } + all++ + if b.OxidationLevel().Uint8() > level.Uint8() { + higher++ + } + } + } + } + + chance := float64(higher+1) / float64(all+1) + if level == UnoxidisedOxidation() { + chance *= chance * 0.75 + } else { + chance *= chance + } + if r.Float64() < chance { + level, _ = level.Increase() + tx.SetBlock(pos, o.WithOxidationLevel(level), nil) + } +} + +// copperBlockName returns the name of a copper block with the given oxidation and waxed status. +func copperBlockName(blockName string, oxidation OxidationType, waxed bool) string { + name := blockName + if oxidation != UnoxidisedOxidation() { + name = oxidation.String() + "_" + name + } + if waxed { + name = "waxed_" + name + } + return "minecraft:" + name +} diff --git a/server/block/packed_ice.go b/server/block/packed_ice.go new file mode 100644 index 0000000..078c0e6 --- /dev/null +++ b/server/block/packed_ice.go @@ -0,0 +1,35 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/world/sound" +) + +// PackedIce is an opaque solid block variant of ice. Unlike regular ice, it does not melt near bright light sources. +type PackedIce struct { + solid +} + +// Instrument ... +func (PackedIce) Instrument() sound.Instrument { + return sound.Chimes() +} + +// BreakInfo ... +func (p PackedIce) BreakInfo() BreakInfo { + return newBreakInfo(0.5, alwaysHarvestable, pickaxeEffective, silkTouchOnlyDrop(p)) +} + +// Friction ... +func (p PackedIce) Friction() float64 { + return 0.98 +} + +// EncodeItem ... +func (PackedIce) EncodeItem() (name string, meta int16) { + return "minecraft:packed_ice", 0 +} + +// EncodeBlock ... +func (PackedIce) EncodeBlock() (string, map[string]any) { + return "minecraft:packed_ice", nil +} diff --git a/server/block/packed_mud.go b/server/block/packed_mud.go new file mode 100644 index 0000000..e3f3887 --- /dev/null +++ b/server/block/packed_mud.go @@ -0,0 +1,21 @@ +package block + +// PackedMud is a block crafted from mud and wheat. It is used to create mud bricks. +type PackedMud struct { + solid +} + +// BreakInfo ... +func (p PackedMud) BreakInfo() BreakInfo { + return newBreakInfo(1, alwaysHarvestable, nothingEffective, oneOf(p)).withBlastResistance(15) +} + +// EncodeItem ... +func (PackedMud) EncodeItem() (name string, meta int16) { + return "minecraft:packed_mud", 0 +} + +// EncodeBlock ... +func (PackedMud) EncodeBlock() (string, map[string]any) { + return "minecraft:packed_mud", nil +} diff --git a/server/block/pink_petals.go b/server/block/pink_petals.go new file mode 100644 index 0000000..3093836 --- /dev/null +++ b/server/block/pink_petals.go @@ -0,0 +1,104 @@ +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" +) + +// PinkPetals is a decorative block that generates in cherry grove biomes. +type PinkPetals struct { + empty + transparent + + // AdditionalCount is the amount of additional pink petals. This can range + // from 0-7, where only 0-3 can occur in-game. + AdditionalCount int + // Facing is the direction the pink petals are facing. This is opposite to + // the direction the player is facing when placing. + Facing cube.Direction +} + +// BoneMeal ... +func (p PinkPetals) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { + if p.AdditionalCount < 3 { + p.AdditionalCount++ + tx.SetBlock(pos, p, nil) + return item.BoneMealResultSmall + } + dropItem(tx, item.NewStack(p, 1), pos.Vec3Centre()) + return item.BoneMealResultSmall +} + +// UseOnBlock ... +func (p PinkPetals) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + if existing, ok := tx.Block(pos).(PinkPetals); ok { + if existing.AdditionalCount >= 3 { + return false + } + + existing.AdditionalCount++ + place(tx, pos, existing, user, ctx) + return placed(ctx) + } + + pos, _, used := firstReplaceable(tx, pos, face, p) + if !used { + return false + } + if !supportsVegetation(p, tx.Block(pos.Side(cube.FaceDown))) { + return false + } + + p.Facing = user.Rotation().Direction().Opposite() + place(tx, pos, p, user, ctx) + return placed(ctx) +} + +// NeighbourUpdateTick ... +func (p PinkPetals) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if !supportsVegetation(p, tx.Block(pos.Side(cube.FaceDown))) { + breakBlock(p, pos, tx) + } +} + +// HasLiquidDrops ... +func (PinkPetals) HasLiquidDrops() bool { + return true +} + +// BreakInfo ... +func (p PinkPetals) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, simpleDrops(item.NewStack(p, p.AdditionalCount+1))) +} + +// FlammabilityInfo ... +func (p PinkPetals) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(30, 100, true) +} + +// CompostChance ... +func (PinkPetals) CompostChance() float64 { + return 0.3 +} + +// EncodeItem ... +func (PinkPetals) EncodeItem() (name string, meta int16) { + return "minecraft:pink_petals", 0 +} + +// EncodeBlock ... +func (p PinkPetals) EncodeBlock() (string, map[string]any) { + return "minecraft:pink_petals", map[string]any{"growth": int32(p.AdditionalCount), "minecraft:cardinal_direction": p.Facing.String()} +} + +// allPinkPetals ... +func allPinkPetals() (b []world.Block) { + for i := 0; i <= 7; i++ { + for _, d := range cube.Directions() { + b = append(b, PinkPetals{AdditionalCount: i, Facing: d}) + } + } + return +} diff --git a/server/block/placer.go b/server/block/placer.go new file mode 100644 index 0000000..336741f --- /dev/null +++ b/server/block/placer.go @@ -0,0 +1,13 @@ +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" +) + +// Placer represents an entity that is able to place a block at a specific position in the world. +type Placer interface { + item.User + PlaceBlock(pos cube.Pos, b world.Block, ctx *item.UseContext) +} diff --git a/server/block/planks.go b/server/block/planks.go new file mode 100644 index 0000000..8e08284 --- /dev/null +++ b/server/block/planks.go @@ -0,0 +1,61 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "time" +) + +// Planks are common blocks used in crafting recipes. They are made by crafting logs into planks. +type Planks struct { + solid + bass + + // Wood is the type of wood of the planks. This field must have one of the values found in the material + // package. + Wood WoodType +} + +// FlammabilityInfo ... +func (p Planks) FlammabilityInfo() FlammabilityInfo { + if !p.Wood.Flammable() { + return newFlammabilityInfo(0, 0, false) + } + return newFlammabilityInfo(5, 20, true) +} + +// BreakInfo ... +func (p Planks) BreakInfo() BreakInfo { + return newBreakInfo(2, alwaysHarvestable, axeEffective, oneOf(p)).withBlastResistance(15) +} + +// RepairsWoodTools ... +func (p Planks) RepairsWoodTools() bool { + return true +} + +// FuelInfo ... +func (p Planks) FuelInfo() item.FuelInfo { + if !p.Wood.Flammable() { + return item.FuelInfo{} + } + return newFuelInfo(time.Second * 15) +} + +// EncodeItem ... +func (p Planks) EncodeItem() (name string, meta int16) { + return "minecraft:" + p.Wood.String() + "_planks", 0 +} + +// EncodeBlock ... +func (p Planks) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:" + p.Wood.String() + "_planks", nil +} + +// allPlanks returns all planks types. +func allPlanks() (planks []world.Block) { + for _, w := range WoodTypes() { + planks = append(planks, Planks{Wood: w}) + } + return +} diff --git a/server/block/podzol.go b/server/block/podzol.go new file mode 100644 index 0000000..93822b0 --- /dev/null +++ b/server/block/podzol.go @@ -0,0 +1,38 @@ +package block + +import "github.com/df-mc/dragonfly/server/world" + +// Podzol is a dirt-type block that naturally blankets the surface of the giant tree taiga and bamboo jungles, along +// with their respective variants. +type Podzol struct { + solid +} + +// SoilFor ... +func (p Podzol) SoilFor(block world.Block) bool { + switch block.(type) { + case ShortGrass, Fern, DoubleTallGrass, Flower, DoubleFlower, NetherSprouts, DeadBush, SugarCane: + return true + } + return false +} + +// Shovel ... +func (Podzol) Shovel() (world.Block, bool) { + return DirtPath{}, true +} + +// BreakInfo ... +func (p Podzol) BreakInfo() BreakInfo { + return newBreakInfo(0.5, alwaysHarvestable, shovelEffective, silkTouchOneOf(Dirt{}, p)) +} + +// EncodeItem ... +func (Podzol) EncodeItem() (name string, meta int16) { + return "minecraft:podzol", 0 +} + +// EncodeBlock ... +func (Podzol) EncodeBlock() (string, map[string]any) { + return "minecraft:podzol", nil +} diff --git a/server/block/polished_blackstone_brick.go b/server/block/polished_blackstone_brick.go new file mode 100644 index 0000000..d75e67e --- /dev/null +++ b/server/block/polished_blackstone_brick.go @@ -0,0 +1,43 @@ +package block + +import "github.com/df-mc/dragonfly/server/item" + +// PolishedBlackstoneBrick are a brick variant of polished blackstone and can spawn in bastion remnants and ruined portals. +type PolishedBlackstoneBrick struct { + solid + bassDrum + + // Cracked specifies if the polished blackstone bricks is its cracked variant. + Cracked bool +} + +// BreakInfo ... +func (b PolishedBlackstoneBrick) BreakInfo() BreakInfo { + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(b)).withBlastResistance(30) +} + +// SmeltInfo ... +func (b PolishedBlackstoneBrick) SmeltInfo() item.SmeltInfo { + if b.Cracked { + return item.SmeltInfo{} + } + return newSmeltInfo(item.NewStack(PolishedBlackstoneBrick{Cracked: true}, 1), 0.1) +} + +// EncodeItem ... +func (b PolishedBlackstoneBrick) EncodeItem() (name string, meta int16) { + name = "polished_blackstone_bricks" + if b.Cracked { + name = "cracked_" + name + } + return "minecraft:" + name, 0 +} + +// EncodeBlock ... +func (b PolishedBlackstoneBrick) EncodeBlock() (string, map[string]any) { + name := "polished_blackstone_bricks" + if b.Cracked { + name = "cracked_" + name + } + return "minecraft:" + name, nil +} diff --git a/server/block/polished_tuff.go b/server/block/polished_tuff.go new file mode 100644 index 0000000..cb0d07d --- /dev/null +++ b/server/block/polished_tuff.go @@ -0,0 +1,22 @@ +package block + +// PolishedTuff is a decorational variant of Tuff that can be crafted or found naturally in Trial Chambers. +type PolishedTuff struct { + solid + bassDrum +} + +// BreakInfo ... +func (t PolishedTuff) BreakInfo() BreakInfo { + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(t)).withBlastResistance(30) +} + +// EncodeItem ... +func (t PolishedTuff) EncodeItem() (name string, meta int16) { + return "minecraft:polished_tuff", 0 +} + +// EncodeBlock ... +func (t PolishedTuff) EncodeBlock() (string, map[string]any) { + return "minecraft:polished_tuff", nil +} diff --git a/server/block/potato.go b/server/block/potato.go new file mode 100644 index 0000000..266c36a --- /dev/null +++ b/server/block/potato.go @@ -0,0 +1,116 @@ +package block + +import ( + "math/rand/v2" + "time" + + "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" +) + +// Potato is a crop that can be consumed raw or cooked to make baked potatoes. +type Potato struct { + crop +} + +// SmeltInfo ... +func (p Potato) SmeltInfo() item.SmeltInfo { + return newFoodSmeltInfo(item.NewStack(item.BakedPotato{}, 1), 0.35) +} + +// SameCrop ... +func (Potato) SameCrop(c Crop) bool { + _, ok := c.(Potato) + return ok +} + +// AlwaysConsumable ... +func (p Potato) AlwaysConsumable() bool { + return false +} + +// ConsumeDuration ... +func (p Potato) ConsumeDuration() time.Duration { + return item.DefaultConsumeDuration +} + +// Consume ... +func (p Potato) Consume(_ *world.Tx, c item.Consumer) item.Stack { + c.Saturate(1, 0.6) + return item.Stack{} +} + +// BoneMeal ... +func (p Potato) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { + if p.Growth == 7 { + return item.BoneMealResultNone + } + p.Growth = min(p.Growth+rand.IntN(4)+2, 7) + tx.SetBlock(pos, p, nil) + return item.BoneMealResultSmall +} + +// UseOnBlock ... +func (p Potato) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, p) + if !used { + return false + } + + if _, ok := tx.Block(pos.Side(cube.FaceDown)).(Farmland); !ok { + return false + } + + place(tx, pos, p, user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (p Potato) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, func(t item.Tool, enchantments []item.Enchantment) []item.Stack { + if p.Growth < 7 { + return []item.Stack{item.NewStack(p, 1)} + } + fortune := fortuneLevel(enchantments) + count := rand.IntN(fortune+1) + 1 + fortuneBinomial(3+fortune) + if rand.Float64() < 0.02 { + return []item.Stack{item.NewStack(p, count), item.NewStack(item.PoisonousPotato{}, 1)} + } + return []item.Stack{item.NewStack(p, count)} + }) +} + +// CompostChance ... +func (Potato) CompostChance() float64 { + return 0.65 +} + +// EncodeItem ... +func (p Potato) EncodeItem() (name string, meta int16) { + return "minecraft:potato", 0 +} + +// RandomTick ... +func (p Potato) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + if tx.Light(pos) < 8 { + breakBlock(p, pos, tx) + } else if p.Growth < 7 && r.Float64() <= p.CalculateGrowthChance(pos, tx) { + p.Growth++ + tx.SetBlock(pos, p, nil) + } +} + +// EncodeBlock ... +func (p Potato) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:potatoes", map[string]any{"growth": int32(p.Growth)} +} + +// allPotato ... +func allPotato() (potato []world.Block) { + for i := 0; i <= 7; i++ { + potato = append(potato, Potato{crop{Growth: i}}) + } + return +} diff --git a/server/block/prismarine.go b/server/block/prismarine.go new file mode 100644 index 0000000..ea6da7a --- /dev/null +++ b/server/block/prismarine.go @@ -0,0 +1,37 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/world" +) + +// Prismarine is a type of stone that appears underwater in ruins and ocean monuments. +type Prismarine struct { + solid + bassDrum + + // Type is the type of prismarine of the block. + Type PrismarineType +} + +// BreakInfo ... +func (p Prismarine) BreakInfo() BreakInfo { + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(p)).withBlastResistance(30) +} + +// EncodeItem ... +func (p Prismarine) EncodeItem() (id string, meta int16) { + return "minecraft:" + p.Type.String(), 0 +} + +// EncodeBlock ... +func (p Prismarine) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:" + p.Type.String(), nil +} + +// allPrismarine returns a list of all prismarine block variants. +func allPrismarine() (c []world.Block) { + for _, t := range PrismarineTypes() { + c = append(c, Prismarine{Type: t}) + } + return +} diff --git a/server/block/prismarine_type.go b/server/block/prismarine_type.go new file mode 100644 index 0000000..b2ff68d --- /dev/null +++ b/server/block/prismarine_type.go @@ -0,0 +1,59 @@ +package block + +// PrismarineType represents a type of prismarine. +type PrismarineType struct { + prismarine +} + +type prismarine uint8 + +// NormalPrismarine is the normal variant of prismarine. +func NormalPrismarine() PrismarineType { + return PrismarineType{0} +} + +// DarkPrismarine is the dark variant of prismarine. +func DarkPrismarine() PrismarineType { + return PrismarineType{1} +} + +// BrickPrismarine is the brick variant of prismarine. +func BrickPrismarine() PrismarineType { + return PrismarineType{2} +} + +// Uint8 returns the prismarine as a uint8. +func (s prismarine) Uint8() uint8 { + return uint8(s) +} + +// Name ... +func (s prismarine) Name() string { + switch s { + case 0: + return "Prismarine" + case 1: + return "Dark Prismarine" + case 2: + return "Prismarine Bricks" + } + panic("unknown prismarine type") +} + +// String ... +func (s prismarine) String() string { + switch s { + case 0: + return "prismarine" + case 1: + return "dark_prismarine" + case 2: + return "prismarine_bricks" + } + panic("unknown prismarine type") +} + +// PrismarineTypes ... +func PrismarineTypes() []PrismarineType { + return []PrismarineType{NormalPrismarine(), DarkPrismarine(), BrickPrismarine()} +} diff --git a/server/block/pumpkin.go b/server/block/pumpkin.go new file mode 100644 index 0000000..51777ef --- /dev/null +++ b/server/block/pumpkin.go @@ -0,0 +1,98 @@ +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/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" +) + +// Pumpkin is a crop block. Interacting with shears results in the carved variant. +type Pumpkin struct { + solid + + // Carved is whether the pumpkin is carved. + Carved bool + // Facing is the direction the pumpkin is facing. + Facing cube.Direction +} + +// Instrument ... +func (p Pumpkin) Instrument() sound.Instrument { + if !p.Carved { + return sound.Didgeridoo() + } + return sound.Piano() +} + +// UseOnBlock ... +func (p Pumpkin) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, p) + if !used { + return + } + p.Facing = user.Rotation().Direction().Opposite() + + place(tx, pos, p, user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (p Pumpkin) BreakInfo() BreakInfo { + return newBreakInfo(1, alwaysHarvestable, axeEffective, oneOf(p)) +} + +// CompostChance ... +func (Pumpkin) CompostChance() float64 { + return 0.65 +} + +// Carve ... +func (p Pumpkin) Carve(f cube.Face) (world.Block, bool) { + return Pumpkin{Facing: f.Direction(), Carved: true}, !p.Carved +} + +// Helmet ... +func (p Pumpkin) Helmet() bool { + return p.Carved +} + +// DefencePoints ... +func (p Pumpkin) DefencePoints() float64 { + return 0 +} + +// Toughness ... +func (p Pumpkin) Toughness() float64 { + return 0 +} + +// KnockBackResistance ... +func (p Pumpkin) KnockBackResistance() float64 { + return 0 +} + +// EncodeItem ... +func (p Pumpkin) EncodeItem() (name string, meta int16) { + if p.Carved { + return "minecraft:carved_pumpkin", 0 + } + return "minecraft:pumpkin", 0 +} + +// EncodeBlock ... +func (p Pumpkin) EncodeBlock() (name string, properties map[string]any) { + if p.Carved { + return "minecraft:carved_pumpkin", map[string]any{"minecraft:cardinal_direction": p.Facing.String()} + } + return "minecraft:pumpkin", map[string]any{"minecraft:cardinal_direction": p.Facing.String()} +} + +func allPumpkins() (pumpkins []world.Block) { + for i := cube.Direction(0); i <= 3; i++ { + pumpkins = append(pumpkins, Pumpkin{Facing: i}) + pumpkins = append(pumpkins, Pumpkin{Facing: i, Carved: true}) + } + return +} diff --git a/server/block/pumpkin_seeds.go b/server/block/pumpkin_seeds.go new file mode 100644 index 0000000..e14be3a --- /dev/null +++ b/server/block/pumpkin_seeds.go @@ -0,0 +1,118 @@ +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" +) + +// PumpkinSeeds grow pumpkin blocks. +type PumpkinSeeds struct { + crop + + // Direction is the direction from the stem to the pumpkin. + Direction cube.Face +} + +// SameCrop ... +func (PumpkinSeeds) SameCrop(c Crop) bool { + _, ok := c.(PumpkinSeeds) + return ok +} + +// NeighbourUpdateTick ... +func (p PumpkinSeeds) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if _, ok := tx.Block(pos.Side(cube.FaceDown)).(Farmland); !ok { + breakBlock(p, pos, tx) + } else if p.Direction != cube.FaceDown { + if pumpkin, ok := tx.Block(pos.Side(p.Direction)).(Pumpkin); !ok || pumpkin.Carved { + p.Direction = cube.FaceDown + tx.SetBlock(pos, p, nil) + } + } +} + +// RandomTick ... +func (p PumpkinSeeds) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + if r.Float64() <= p.CalculateGrowthChance(pos, tx) && tx.Light(pos) >= 8 { + if p.Growth < 7 { + p.Growth++ + tx.SetBlock(pos, p, nil) + } else { + directions := []cube.Direction{cube.North, cube.South, cube.West, cube.East} + for _, i := range directions { + if _, ok := tx.Block(pos.Side(i.Face())).(Pumpkin); ok { + return + } + } + direction := directions[r.IntN(len(directions))].Face() + stemPos := pos.Side(direction) + if _, ok := tx.Block(stemPos).(Air); ok { + switch tx.Block(stemPos.Side(cube.FaceDown)).(type) { + case Farmland, Dirt, Grass: + p.Direction = direction + tx.SetBlock(pos, p, nil) + tx.SetBlock(stemPos, Pumpkin{}, nil) + } + } + } + } +} + +// BoneMeal ... +func (p PumpkinSeeds) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { + if p.Growth == 7 { + return item.BoneMealResultNone + } + p.Growth = min(p.Growth+rand.IntN(4)+2, 7) + tx.SetBlock(pos, p, nil) + return item.BoneMealResultSmall +} + +// UseOnBlock ... +func (p PumpkinSeeds) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, p) + if !used { + return false + } + + if _, ok := tx.Block(pos.Side(cube.FaceDown)).(Farmland); !ok { + return false + } + + place(tx, pos, p, user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (p PumpkinSeeds) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(p)) +} + +// CompostChance ... +func (PumpkinSeeds) CompostChance() float64 { + return 0.3 +} + +// EncodeItem ... +func (p PumpkinSeeds) EncodeItem() (name string, meta int16) { + return "minecraft:pumpkin_seeds", 0 +} + +// EncodeBlock ... +func (p PumpkinSeeds) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:pumpkin_stem", map[string]any{"facing_direction": int32(p.Direction), "growth": int32(p.Growth)} +} + +// allPumpkinStems +func allPumpkinStems() (stems []world.Block) { + for i := 0; i <= 7; i++ { + for j := cube.Face(0); j <= 5; j++ { + stems = append(stems, PumpkinSeeds{Direction: j, crop: crop{Growth: i}}) + } + } + return +} diff --git a/server/block/purpur.go b/server/block/purpur.go new file mode 100644 index 0000000..0d1dafe --- /dev/null +++ b/server/block/purpur.go @@ -0,0 +1,75 @@ +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" +) + +type ( + // Purpur is a decorative block that is naturally generated in End cities and End ships. + Purpur struct { + solid + bassDrum + } + // PurpurPillar is a variant of Purpur that can be rotated. + PurpurPillar struct { + solid + bassDrum + + // Axis is the axis which the purpur pillar block faces. + Axis cube.Axis + } +) + +// BreakInfo ... +func (p Purpur) BreakInfo() BreakInfo { + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(p)).withBlastResistance(30) +} + +// EncodeItem ... +func (p Purpur) EncodeItem() (name string, meta int16) { + return "minecraft:purpur_block", 0 +} + +// EncodeBlock ... +func (p Purpur) EncodeBlock() (name string, properties map[string]interface{}) { + return "minecraft:purpur_block", map[string]interface{}{"pillar_axis": "y"} +} + +// UseOnBlock ... +func (p PurpurPillar) 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, p) + if !used { + return + } + p.Axis = face.Axis() + + place(tx, pos, p, user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (p PurpurPillar) BreakInfo() BreakInfo { + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(p)).withBlastResistance(30) +} + +// EncodeItem ... +func (p PurpurPillar) EncodeItem() (name string, meta int16) { + return "minecraft:purpur_pillar", 0 +} + +// EncodeBlock ... +func (p PurpurPillar) EncodeBlock() (name string, properties map[string]interface{}) { + return "minecraft:purpur_pillar", map[string]interface{}{"pillar_axis": p.Axis.String()} +} + +// allPurpurs ... +func allPurpurs() (purpur []world.Block) { + purpur = append(purpur, Purpur{}) + for _, axis := range cube.Axes() { + purpur = append(purpur, PurpurPillar{Axis: axis}) + } + return +} diff --git a/server/block/quartz.go b/server/block/quartz.go new file mode 100644 index 0000000..639db14 --- /dev/null +++ b/server/block/quartz.go @@ -0,0 +1,116 @@ +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" +) + +type ( + // Quartz is a mineral block used only for decoration. + Quartz struct { + solid + bassDrum + // Smooth specifies if the quartz block is smooth or not. + Smooth bool + } + + // ChiseledQuartz is a mineral block used only for decoration. + ChiseledQuartz struct { + solid + bassDrum + } + // QuartzPillar is a mineral block used only for decoration. + QuartzPillar struct { + solid + bassDrum + // Axis is the axis which the quartz pillar block faces. + Axis cube.Axis + } +) + +// UseOnBlock handles the rotational placing of quartz pillar blocks. +func (q QuartzPillar) 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, q) + if !used { + return + } + q.Axis = face.Axis() + + place(tx, pos, q, user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (q Quartz) BreakInfo() BreakInfo { + if q.Smooth { + return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(q)).withBlastResistance(30) + } + return newBreakInfo(0.8, pickaxeHarvestable, pickaxeEffective, oneOf(q)) +} + +// BreakInfo ... +func (c ChiseledQuartz) BreakInfo() BreakInfo { + return newBreakInfo(0.8, pickaxeHarvestable, pickaxeEffective, simpleDrops(item.NewStack(c, 1))) +} + +// BreakInfo ... +func (q QuartzPillar) BreakInfo() BreakInfo { + return newBreakInfo(0.8, pickaxeHarvestable, pickaxeEffective, simpleDrops(item.NewStack(q, 1))) +} + +// SmeltInfo ... +func (q Quartz) SmeltInfo() item.SmeltInfo { + if q.Smooth { + return item.SmeltInfo{} + } + return newSmeltInfo(item.NewStack(Quartz{Smooth: true}, 1), 0.1) +} + +// EncodeItem ... +func (q Quartz) EncodeItem() (name string, meta int16) { + if q.Smooth { + return "minecraft:smooth_quartz", 0 + } + return "minecraft:quartz_block", 0 +} + +// EncodeItem ... +func (c ChiseledQuartz) EncodeItem() (name string, meta int16) { + return "minecraft:chiseled_quartz_block", 0 +} + +// EncodeItem ... +func (q QuartzPillar) EncodeItem() (name string, meta int16) { + return "minecraft:quartz_pillar", 0 +} + +// EncodeBlock ... +func (q Quartz) EncodeBlock() (name string, properties map[string]any) { + if q.Smooth { + return "minecraft:smooth_quartz", map[string]any{"pillar_axis": "y"} + } + return "minecraft:quartz_block", map[string]any{"pillar_axis": "y"} +} + +// EncodeBlock ... +func (ChiseledQuartz) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:chiseled_quartz_block", map[string]any{"pillar_axis": "y"} +} + +// EncodeBlock ... +func (q QuartzPillar) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:quartz_pillar", map[string]any{"pillar_axis": q.Axis.String()} +} + +// allQuartz ... +func allQuartz() (quartz []world.Block) { + quartz = append(quartz, Quartz{}) + quartz = append(quartz, Quartz{Smooth: true}) + quartz = append(quartz, ChiseledQuartz{}) + for _, a := range cube.Axes() { + quartz = append(quartz, QuartzPillar{Axis: a}) + } + return +} diff --git a/server/block/quartz_bricks.go b/server/block/quartz_bricks.go new file mode 100644 index 0000000..47f324f --- /dev/null +++ b/server/block/quartz_bricks.go @@ -0,0 +1,22 @@ +package block + +// QuartzBricks is a mineral block used only for decoration. +type QuartzBricks struct { + solid + bassDrum +} + +// BreakInfo ... +func (q QuartzBricks) BreakInfo() BreakInfo { + return newBreakInfo(0.8, pickaxeHarvestable, pickaxeEffective, oneOf(q)) +} + +// EncodeItem ... +func (QuartzBricks) EncodeItem() (name string, meta int16) { + return "minecraft:quartz_bricks", 0 +} + +// EncodeBlock ... +func (QuartzBricks) EncodeBlock() (string, map[string]any) { + return "minecraft:quartz_bricks", nil +} diff --git a/server/block/quartz_ore.go b/server/block/quartz_ore.go new file mode 100644 index 0000000..20ef317 --- /dev/null +++ b/server/block/quartz_ore.go @@ -0,0 +1,29 @@ +package block + +import "github.com/df-mc/dragonfly/server/item" + +// NetherQuartzOre is ore found in the Nether. +type NetherQuartzOre struct { + solid + bassDrum +} + +// BreakInfo ... +func (q NetherQuartzOre) BreakInfo() BreakInfo { + return newBreakInfo(3, pickaxeHarvestable, pickaxeEffective, oreDrops(item.NetherQuartz{}, q)).withXPDropRange(0, 3) +} + +// SmeltInfo ... +func (NetherQuartzOre) SmeltInfo() item.SmeltInfo { + return newOreSmeltInfo(item.NewStack(item.NetherQuartz{}, 1), 0.2) +} + +// EncodeItem ... +func (NetherQuartzOre) EncodeItem() (name string, meta int16) { + return "minecraft:quartz_ore", 0 +} + +// EncodeBlock ... +func (NetherQuartzOre) EncodeBlock() (string, map[string]any) { + return "minecraft:quartz_ore", nil +} diff --git a/server/block/raw_copper.go b/server/block/raw_copper.go new file mode 100644 index 0000000..97fdb1c --- /dev/null +++ b/server/block/raw_copper.go @@ -0,0 +1,28 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" +) + +// RawCopper is a raw metal block equivalent to nine raw copper. +type RawCopper struct { + solid + bassDrum +} + +// BreakInfo ... +func (r RawCopper) BreakInfo() BreakInfo { + return newBreakInfo(5, func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierStone.HarvestLevel + }, pickaxeEffective, oneOf(r)).withBlastResistance(30) +} + +// EncodeItem ... +func (RawCopper) EncodeItem() (name string, meta int16) { + return "minecraft:raw_copper_block", 0 +} + +// EncodeBlock ... +func (RawCopper) EncodeBlock() (string, map[string]any) { + return "minecraft:raw_copper_block", nil +} diff --git a/server/block/raw_gold.go b/server/block/raw_gold.go new file mode 100644 index 0000000..c47082b --- /dev/null +++ b/server/block/raw_gold.go @@ -0,0 +1,28 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" +) + +// RawGold is a raw metal block equivalent to nine raw gold. +type RawGold struct { + solid + bassDrum +} + +// BreakInfo ... +func (g RawGold) BreakInfo() BreakInfo { + return newBreakInfo(5, func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierIron.HarvestLevel + }, pickaxeEffective, oneOf(g)).withBlastResistance(30) +} + +// EncodeItem ... +func (RawGold) EncodeItem() (name string, meta int16) { + return "minecraft:raw_gold_block", 0 +} + +// EncodeBlock ... +func (RawGold) EncodeBlock() (string, map[string]any) { + return "minecraft:raw_gold_block", nil +} diff --git a/server/block/raw_iron.go b/server/block/raw_iron.go new file mode 100644 index 0000000..100919e --- /dev/null +++ b/server/block/raw_iron.go @@ -0,0 +1,28 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" +) + +// RawIron is a raw metal block equivalent to nine raw iron. +type RawIron struct { + solid + bassDrum +} + +// BreakInfo ... +func (r RawIron) BreakInfo() BreakInfo { + return newBreakInfo(5, func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierStone.HarvestLevel + }, pickaxeEffective, oneOf(r)).withBlastResistance(30) +} + +// EncodeItem ... +func (RawIron) EncodeItem() (name string, meta int16) { + return "minecraft:raw_iron_block", 0 +} + +// EncodeBlock ... +func (RawIron) EncodeBlock() (string, map[string]any) { + return "minecraft:raw_iron_block", nil +} diff --git a/server/block/redstone.go b/server/block/redstone.go new file mode 100644 index 0000000..4ba84dd --- /dev/null +++ b/server/block/redstone.go @@ -0,0 +1,380 @@ +package block + +import ( + "slices" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/block/model" + "github.com/df-mc/dragonfly/server/event" + "github.com/df-mc/dragonfly/server/world" +) + +// wireNetwork implements a minimally-invasive bolt-on accelerator that performs a breadth-first search through redstone +// wires in order to more efficiently and compute new redstone wire power levels and determine the order in which other +// blocks should be updated. This implementation is heavily based off of RedstoneWireTurbo and MCHPRS. +type wireNetwork struct { + nodes []*wireNode + nodeCache map[cube.Pos]*wireNode + updateQueue [3][]*wireNode + currentWalkLayer uint32 +} + +// wireNode is a data structure to keep track of redstone wires and neighbours that will receive updates. +type wireNode struct { + visited bool + + pos cube.Pos + block world.Block + source cube.Pos + + neighbours []*wireNode + oriented bool + + xBias int32 + zBias int32 + + layer uint32 +} + +const ( + wireHeadingNorth = 0 + wireHeadingEast = 1 + wireHeadingSouth = 2 + wireHeadingWest = 3 +) + +// updateStrongRedstone sets off the breadth-first walk through all redstone wires connected to the initial position +// triggered. This is the main entry point for the redstone update algorithm. +func updateStrongRedstone(pos cube.Pos, tx *world.Tx) { + n := &wireNetwork{ + nodeCache: make(map[cube.Pos]*wireNode), + } + + root := &wireNode{ + block: tx.Block(pos), + pos: pos, + visited: true, + } + n.nodeCache[pos] = root + n.nodes = append(n.nodes, root) + + n.propagateChanges(tx, root, 0) + n.breadthFirstWalk(tx) +} + +// updateAroundRedstone updates redstone components around the given center position. It will also ignore any faces +// provided within the ignoredFaces parameter. This implementation is based off of RedstoneCircuit and Java 1.19. +func updateAroundRedstone(centre cube.Pos, tx *world.Tx, ignoredFaces ...cube.Face) { + // Order matches Java 1.19's RedstoneCircuit traversal. + for _, face := range []cube.Face{ + cube.FaceWest, + cube.FaceEast, + cube.FaceDown, + cube.FaceUp, + cube.FaceNorth, + cube.FaceSouth, + } { + if slices.Contains(ignoredFaces, face) { + continue + } + pos := centre.Side(face) + updateRedstoneFrom(pos, centre, tx) + updateRedstoneFrom(pos.Side(cube.FaceUp), centre, tx) + updateRedstoneFrom(pos.Side(cube.FaceDown), centre, tx) + updateReceiversAroundPoweredBlock(pos, tx, face.Opposite()) + } +} + +// updateReceiversAroundPoweredBlock updates redstone receivers directly adjacent to an indirectly powered solid block. +// This keeps mechanisms behind the powered block in sync without walking corner positions around the original update. +// For example, this covers the following vertical path: torch -> stone -> note block. +func updateReceiversAroundPoweredBlock(pos cube.Pos, tx *world.Tx, ignoredFaces ...cube.Face) { + if _, ok := tx.Block(pos).Model().(model.Solid); !ok { + return + } + for _, face := range cube.Faces() { + if slices.Contains(ignoredFaces, face) || tx.RedstonePower(pos, face, true) == 0 { + continue + } + updateRedstoneFrom(pos.Side(face), pos, tx) + } +} + +// updateRedstone dispatches a cancellable redstone update to the block at pos, if it handles redstone updates. +// Prefer updateRedstone over calling RedstoneUpdate directly so HandleRedstoneUpdate gets a chance to observe +// and optionally cancel the update. Direct RedstoneUpdate calls are reserved for internal state initialisation +// (e.g. seeding a freshly placed block) where handler cancellation isn't appropriate. +func updateRedstone(pos cube.Pos, tx *world.Tx) { + updateRedstoneFrom(pos, pos, tx) +} + +// updateRedstoneFrom dispatches a cancellable redstone update and records the position that caused it. +func updateRedstoneFrom(pos, source cube.Pos, tx *world.Tx) { + r, ok := tx.Block(pos).(world.RedstoneUpdater) + if !ok { + return + } + if redstoneUpdateCancelled(pos, tx) { + return + } + tx.Redstone().WithUpdateSource(source, func() { + r.RedstoneUpdate(pos, tx) + }) +} + +// redstoneUpdateCancelled checks if the redstone update has been cancelled by the HandleRedstoneUpdate handler. +func redstoneUpdateCancelled(pos cube.Pos, tx *world.Tx) bool { + ctx := event.C(tx) + tx.World().Handler().HandleRedstoneUpdate(ctx, pos) + return ctx.Cancelled() +} + +// updateDirectionalRedstone updates redstone components through the given face. This implementation is based off of +// RedstoneCircuit and Java 1.19. +func updateDirectionalRedstone(pos cube.Pos, tx *world.Tx, face cube.Face) { + updateAroundRedstone(pos, tx) + updateAroundRedstone(pos.Side(face), tx, face.Opposite()) +} + +// identifyNeighbours identifies the neighbouring positions of a given node, determines their types, and links them into +// the graph. After that, based on what nodes in the graph have been visited, the neighbours are reordered left-to-right +// relative to the direction of information flow. +func (n *wireNetwork) identifyNeighbours(tx *world.Tx, node *wireNode) { + var neighboursVisited [24]bool + var neighbourNodes [24]*wireNode + for i, offset := range redstoneNeighbourOffsets { + neighbourPos := node.pos.Add(offset) + neighbour, ok := n.nodeCache[neighbourPos] + if !ok { + neighbour = &wireNode{ + pos: neighbourPos, + block: tx.Block(neighbourPos), + } + n.nodeCache[neighbourPos] = neighbour + n.nodes = append(n.nodes, neighbour) + } + neighbourNodes[i] = neighbour + neighboursVisited[i] = neighbour.visited + } + + fromWest := neighboursVisited[0] || neighboursVisited[7] || neighboursVisited[8] + fromEast := neighboursVisited[1] || neighboursVisited[12] || neighboursVisited[13] + fromNorth := neighboursVisited[4] || neighboursVisited[17] || neighboursVisited[20] + fromSouth := neighboursVisited[5] || neighboursVisited[18] || neighboursVisited[21] + + var cX, cZ int32 + if fromWest { + cX++ + } + if fromEast { + cX-- + } + if fromNorth { + cZ++ + } + if fromSouth { + cZ-- + } + + var heading uint32 + if cX == 0 && cZ == 0 { + heading = computeRedstoneHeading(node.xBias, node.zBias) + for _, neighbourNode := range neighbourNodes { + neighbourNode.xBias = node.xBias + neighbourNode.zBias = node.zBias + } + } else { + if cX != 0 && cZ != 0 { + if node.xBias != 0 { + cZ = 0 + } + if node.zBias != 0 { + cX = 0 + } + } + heading = computeRedstoneHeading(cX, cZ) + for _, neighbourNode := range neighbourNodes { + neighbourNode.xBias = cX + neighbourNode.zBias = cZ + } + } + + n.orientNeighbours(neighbourNodes, node, heading) +} + +// redstoneNeighbourOffsets lists the 24 positions visited around a redstone wire node: the 6 immediate neighbours +// followed by the unique neighbours-of-neighbours, in the order west, east, down, up, north, south. The fixed +// indices here are referenced directly by identifyNeighbours and the redstoneReordering tables below. +var redstoneNeighbourOffsets = [...]cube.Pos{ + // Immediate neighbours, in the order of west, east, down, up, north, and south. + {-1, 0, 0}, + {1, 0, 0}, + {0, -1, 0}, + {0, 1, 0}, + {0, 0, -1}, + {0, 0, 1}, + + // Neighbours of neighbours, in the same order, except that duplicates are omitted. + {-2, 0, 0}, + {-1, -1, 0}, + {-1, 1, 0}, + {-1, 0, -1}, + {-1, 0, 1}, + + {2, 0, 0}, + {1, -1, 0}, + {1, 1, 0}, + {1, 0, -1}, + {1, 0, 1}, + + {0, -2, 0}, + {0, -1, -1}, + {0, -1, 1}, + + {0, 2, 0}, + {0, 1, -1}, + {0, 1, 1}, + + {0, 0, -2}, + {0, 0, 2}, +} + +// redstoneReordering contains lookup tables that completely remap neighbour positions into a left-to-right ordering, +// based on the cardinal direction that is determined to be forward. +var redstoneReordering = [...][24]uint32{ + {2, 3, 16, 19, 0, 4, 1, 5, 7, 8, 17, 20, 12, 13, 18, 21, 6, 9, 22, 14, 11, 10, 23, 15}, + {2, 3, 16, 19, 4, 1, 5, 0, 17, 20, 12, 13, 18, 21, 7, 8, 22, 14, 11, 15, 23, 9, 6, 10}, + {2, 3, 16, 19, 1, 5, 0, 4, 12, 13, 18, 21, 7, 8, 17, 20, 11, 15, 23, 10, 6, 14, 22, 9}, + {2, 3, 16, 19, 5, 0, 4, 1, 18, 21, 7, 8, 17, 20, 12, 13, 23, 10, 6, 9, 22, 15, 11, 14}, +} + +// orientNeighbours reorders the neighbours of a node based on the direction that is determined to be forward. +func (n *wireNetwork) orientNeighbours(src [24]*wireNode, dst *wireNode, heading uint32) { + dst.oriented = true + dst.neighbours = make([]*wireNode, 0, 24) + for _, i := range redstoneReordering[heading] { + dst.neighbours = append(dst.neighbours, src[i]) + } +} + +// propagateChanges propagates changes for any redstone wire in layer N, informing the neighbours to recompute their +// states in layers N + 1 and N + 2. +func (n *wireNetwork) propagateChanges(tx *world.Tx, node *wireNode, layer uint32) { + if !node.oriented { + n.identifyNeighbours(tx, node) + } + + layerOne := layer + 1 + for _, neighbour := range node.neighbours { + if layerOne > neighbour.layer { + neighbour.layer = layerOne + neighbour.source = node.pos + n.updateQueue[1] = append(n.updateQueue[1], neighbour) + } + } + + layerTwo := layer + 2 + for _, neighbour := range node.neighbours[:4] { + if layerTwo > neighbour.layer { + neighbour.layer = layerTwo + neighbour.source = node.pos + n.updateQueue[2] = append(n.updateQueue[2], neighbour) + } + } +} + +// breadthFirstWalk performs a breadth-first (layer by layer) traversal through redstone wires, propagating value +// changes to neighbours in the order that they are visited. +func (n *wireNetwork) breadthFirstWalk(tx *world.Tx) { + n.shiftQueue() + n.currentWalkLayer = 1 + + for len(n.updateQueue[0]) > 0 || len(n.updateQueue[1]) > 0 { + for _, node := range n.updateQueue[0] { + if _, ok := node.block.(RedstoneWire); ok { + n.updateNode(tx, node, n.currentWalkLayer) + continue + } + updateRedstoneFrom(node.pos, node.source, tx) + } + + n.shiftQueue() + n.currentWalkLayer++ + } + + n.currentWalkLayer = 0 +} + +// shiftQueue shifts the update queue, moving all nodes from the current layer to the next layer. The last queue is then +// simply invalidated. +func (n *wireNetwork) shiftQueue() { + n.updateQueue[0] = n.updateQueue[1] + n.updateQueue[1] = n.updateQueue[2] + n.updateQueue[2] = nil +} + +// updateNode processes a node which has had neighbouring redstone wires that have experienced value changes. +func (n *wireNetwork) updateNode(tx *world.Tx, node *wireNode, layer uint32) { + node.visited = true + if redstoneUpdateCancelled(node.pos, tx) { + return + } + + newWire, changed := n.calculateCurrentChanges(tx, node) + if !changed { + return + } + node.block = newWire + n.propagateChanges(tx, node, layer) +} + +// calculateCurrentChanges computes redstone wire power levels from neighboring blocks. Modifications cut the number of +// power level changes by about 45% from vanilla, and also synergies well with the breadth-first search implementation. +// It returns the new redstone wire block and a boolean indicating whether the power level changed. +func (n *wireNetwork) calculateCurrentChanges(tx *world.Tx, node *wireNode) (RedstoneWire, bool) { + wire := node.block.(RedstoneWire) + i := wire.Power + + if !node.oriented { + n.identifyNeighbours(tx, node) + } + + j := calculateRedstoneWirePower(node.pos, tx, func(pos cube.Pos) world.Block { + if cached, ok := n.nodeCache[pos]; ok { + return cached.block + } + return tx.Block(pos) + }) + + if i == j { + return wire, false + } + wire.Power = j + tx.SetBlock(node.pos, wire, &world.SetOpts{DisableBlockUpdates: true}) + return wire, true +} + +// maxRedstoneWirePower returns the greater of strength and the power level of b if it is redstone wire. +func maxRedstoneWirePower(b world.Block, strength int) int { + if wire, ok := b.(RedstoneWire); ok { + return max(wire.Power, strength) + } + return strength +} + +// computeRedstoneHeading computes the cardinal direction that is "forward" given which redstone wires have been visited +// and which have not around the position currently being processed. +func computeRedstoneHeading(rX, rZ int32) uint32 { + code := (rX + 1) + 3*(rZ+1) + switch code { + case 0, 1: + return wireHeadingNorth + case 2, 5: + return wireHeadingEast + case 3, 4: + return wireHeadingWest + case 6, 7, 8: + return wireHeadingSouth + } + panic("should never happen") +} diff --git a/server/block/redstone_block.go b/server/block/redstone_block.go new file mode 100644 index 0000000..2841228 --- /dev/null +++ b/server/block/redstone_block.go @@ -0,0 +1,60 @@ +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" +) + +// RedstoneBlock is a mineral block equivalent to nine redstone dust. +// It acts as a permanently powered redstone power source that can be pushed by pistons. +type RedstoneBlock struct { + solid +} + +// BreakInfo ... +func (r RedstoneBlock) BreakInfo() BreakInfo { + return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(r)).withBlastResistance(30).withBreakHandler(func(pos cube.Pos, tx *world.Tx, _ item.User) { + updateAroundRedstone(pos, tx) + }) +} + +// EncodeItem ... +func (r RedstoneBlock) EncodeItem() (name string, meta int16) { + return "minecraft:redstone_block", 0 +} + +// EncodeBlock ... +func (r RedstoneBlock) EncodeBlock() (string, map[string]any) { + return "minecraft:redstone_block", nil +} + +// UseOnBlock ... +func (r RedstoneBlock) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, r) + if !used { + return false + } + place(tx, pos, r, user, ctx) + if placed(ctx) { + updateAroundRedstone(pos, tx) + return true + } + return false +} + +// RedstoneSource ... +func (r RedstoneBlock) RedstoneSource() bool { + return true +} + +// WeakPower ... +func (r RedstoneBlock) WeakPower(_ cube.Pos, _ cube.Face, _ *world.Tx, _ bool) int { + return 15 +} + +// StrongPower ... +func (r RedstoneBlock) StrongPower(_ cube.Pos, _ cube.Face, _ *world.Tx, _ bool) int { + return 0 +} diff --git a/server/block/redstone_ore.go b/server/block/redstone_ore.go new file mode 100644 index 0000000..d027ae5 --- /dev/null +++ b/server/block/redstone_ore.go @@ -0,0 +1,134 @@ +package block + +import ( + "image/color" + "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/df-mc/dragonfly/server/world/particle" + "github.com/go-gl/mathgl/mgl64" +) + +var redstoneOreParticleColour = color.RGBA{R: 255, A: 255} + +// RedstoneOre is an ore block that lights up when disturbed. +type RedstoneOre struct { + solid + bassDrum + + // Type is the type of redstone ore. + Type OreType + // Lit is whether the redstone ore is glowing. + Lit bool +} + +// Activate lights the redstone ore unless the user is sneaking. +// It returns false so the held item may still be used. +func (r RedstoneOre) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + if sneaking, ok := u.(interface{ Sneaking() bool }); ok && sneaking.Sneaking() { + return false + } + r.light(pos, tx) + return false +} + +// Punch lights the redstone ore when mining starts. +func (r RedstoneOre) Punch(pos cube.Pos, _ cube.Face, tx *world.Tx, _ item.User) { + r.light(pos, tx) +} + +// EntityStepOn lights the redstone ore when a non-sneaking entity steps on it. +func (r RedstoneOre) EntityStepOn(pos cube.Pos, tx *world.Tx, e world.Entity) { + if sneaking, ok := e.(interface{ Sneaking() bool }); ok && sneaking.Sneaking() { + return + } + r.light(pos, tx) +} + +// RandomTick turns lit redstone ore off again. +func (r RedstoneOre) RandomTick(pos cube.Pos, tx *world.Tx, _ *rand.Rand) { + r.fade(pos, tx) +} + +// light emits particles and switches the ore to its lit state if it is not already lit. +func (r RedstoneOre) light(pos cube.Pos, tx *world.Tx) { + r.emitParticles(pos, tx) + if r.Lit { + return + } + r.Lit = true + tx.SetBlock(pos, r, nil) +} + +// emitParticles emits the red particles produced when redstone ore is disturbed. +func (RedstoneOre) emitParticles(pos cube.Pos, tx *world.Tx) { + for range 5 { + tx.AddParticle(pos.Vec3().Add(redstoneOreParticleOffset()), particle.Dust{Colour: redstoneOreParticleColour}) + } +} + +// redstoneOreParticleOffset returns a random offset on or just outside one face of the block. +func redstoneOreParticleOffset() mgl64.Vec3 { + offset := mgl64.Vec3{rand.Float64(), rand.Float64(), rand.Float64()} + const edge = 0.05 + switch rand.IntN(6) { + case 0: + offset[0] = -edge + case 1: + offset[0] = 1 + edge + case 2: + offset[1] = -edge + case 3: + offset[1] = 1 + edge + case 4: + offset[2] = -edge + case 5: + offset[2] = 1 + edge + } + return offset +} + +// fade returns lit redstone ore to its unlit state. +func (r RedstoneOre) fade(pos cube.Pos, tx *world.Tx) { + if !r.Lit { + return + } + r.Lit = false + tx.SetBlock(pos, r, nil) +} + +// LightEmissionLevel ... +func (r RedstoneOre) LightEmissionLevel() uint8 { + if r.Lit { + return 9 + } + return 0 +} + +// BreakInfo ... +func (r RedstoneOre) BreakInfo() BreakInfo { + return newBreakInfo(r.Type.Hardness(), func(t item.Tool) bool { + return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierIron.HarvestLevel + }, pickaxeEffective, discreteDrops(RedstoneWire{}, RedstoneOre{Type: r.Type}, 4, 5, 8)).withXPDropRange(1, 5).withBlastResistance(15) +} + +// SmeltInfo ... +func (RedstoneOre) SmeltInfo() item.SmeltInfo { + return newOreSmeltInfo(item.NewStack(RedstoneWire{}, 1), 0.7) +} + +// EncodeItem ... +func (r RedstoneOre) EncodeItem() (name string, meta int16) { + return "minecraft:" + r.Type.Prefix() + "redstone_ore", 0 +} + +// EncodeBlock ... +func (r RedstoneOre) EncodeBlock() (string, map[string]any) { + lit := "" + if r.Lit { + lit = "lit_" + } + return "minecraft:" + lit + r.Type.Prefix() + "redstone_ore", nil +} diff --git a/server/block/redstone_torch.go b/server/block/redstone_torch.go new file mode 100644 index 0000000..6aef001 --- /dev/null +++ b/server/block/redstone_torch.go @@ -0,0 +1,294 @@ +package block + +import ( + "math/rand/v2" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "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" +) + +// RedstoneTorch is a non-solid block that emits light and provides a full-strength redstone signal when lit. +type RedstoneTorch struct { + transparent + empty + + // Facing is the direction from the torch to the block it is attached to. + Facing cube.Face + // Lit indicates whether the redstone torch is currently lit and emitting power. + Lit bool +} + +// HasLiquidDrops returns whether the redstone torch drops its item when flowing liquid breaks it. +func (RedstoneTorch) HasLiquidDrops() bool { + return true +} + +// LightEmissionLevel returns the light level emitted by the redstone torch (7 when lit, 0 when unlit). +func (t RedstoneTorch) LightEmissionLevel() uint8 { + if t.Lit { + return 7 + } + return 0 +} + +// BreakInfo returns information about breaking the redstone torch. +func (t RedstoneTorch) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(t)).withBreakHandler(func(pos cube.Pos, tx *world.Tx, _ item.User) { + tx.Redstone().ClearTorchBurnout(pos) + updateTorchRedstone(pos, tx) + }) +} + +// UseOnBlock handles the placement of a redstone torch on a block surface. +func (t RedstoneTorch) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, face, used := firstReplaceable(tx, pos, face, t) + if !used { + return false + } + if face == cube.FaceDown { + return false + } + if _, ok := tx.Block(pos).(world.Liquid); ok { + return false + } + if !tx.Block(pos.Side(face.Opposite())).Model().FaceSolid(pos.Side(face.Opposite()), face, tx) { + fallbackFace, ok := findTorchPlacementFace(pos, tx) + if !ok { + return false + } + face = fallbackFace + } + t.Facing = face.Opposite() + t.Lit = true + + place(tx, pos, t, user, ctx) + if placed(ctx) { + // Initialise the freshly placed torch state before propagating its output. + t.RedstoneUpdate(pos, tx) + updateTorchRedstone(pos, tx) + return true + } + return false +} + +// NeighbourUpdateTick is called when a neighbouring block is updated. +func (t RedstoneTorch) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if !tx.Block(pos.Side(t.Facing)).Model().FaceSolid(pos.Side(t.Facing), t.Facing.Opposite(), tx) { + tx.Redstone().ClearTorchBurnout(pos) + breakBlock(t, pos, tx) + return + } + if t.recoverFromBurnout(pos, tx) { + return + } + updateRedstone(pos, tx) +} + +// RedstoneUpdate is called when the redstone power state changes nearby. This method ignores burned-out torches and +// schedules state changes for active torches. +func (t RedstoneTorch) RedstoneUpdate(pos cube.Pos, tx *world.Tx) { + currentTick := tx.CurrentTick() + if burnedOut, _ := tx.Redstone().TorchBurnoutStatus(pos, currentTick); burnedOut { + if t.updateSourceTouchesInput(pos, tx) { + t.recoverFromBurnout(pos, tx) + } + return + } + + shouldBeLit := t.inputStrength(pos, tx) == 0 + if shouldBeLit == t.Lit { + return + } + tx.Redstone().MarkTorchSelfTriggeredIfActive(pos) + tx.ScheduleBlockUpdate(pos, t, time.Millisecond*100) +} + +// recoverFromBurnout relights a burned-out torch after a real neighbouring block update once its rapid-toggle history +// has expired. Redstone propagation alone may visit calculation-only positions, so it must not recover burned-out +// torches that did not receive an actual neighbour update. +func (RedstoneTorch) recoverFromBurnout(pos cube.Pos, tx *world.Tx) bool { + torch, ok := redstoneTorchAt(pos, tx) + if !ok { + return false + } + + currentTick := tx.CurrentTick() + burnedOut, recoverable := tx.Redstone().TorchBurnoutStatus(pos, currentTick) + if !burnedOut { + return false + } + if !recoverable { + return true + } + tx.Redstone().ClearTorchBurnout(pos) + + torch.Lit = torch.inputStrength(pos, tx) == 0 + tx.SetBlock(pos, torch, nil) + updateTorchRedstone(pos, tx) + return true +} + +// updateSourceTouchesInput reports whether the current redstone update came from the block the torch is attached to, +// or from a block directly beside it. Dust on top of the attached block can legitimately recover a burnout loop, while +// disconnected dust visited by the broad wire walk should not. +func (t RedstoneTorch) updateSourceTouchesInput(pos cube.Pos, tx *world.Tx) bool { + source, ok := tx.Redstone().UpdateSource() + if !ok { + return false + } + inputPos := pos.Side(t.Facing) + if source == inputPos { + return true + } + for _, face := range cube.Faces() { + if source == inputPos.Side(face) { + return true + } + } + return false +} + +// ScheduledTick is called when a scheduled block update occurs. +// This method handles state changes and checks for burnout conditions. +func (RedstoneTorch) ScheduledTick(pos cube.Pos, tx *world.Tx, _ *rand.Rand) { + torch, ok := redstoneTorchAt(pos, tx) + if !ok { + return + } + + currentTick := tx.CurrentTick() + if burnedOut, _ := tx.Redstone().TorchBurnoutStatus(pos, currentTick); burnedOut { + return + } + + shouldBeLit := torch.inputStrength(pos, tx) == 0 + if shouldBeLit == torch.Lit { + tx.Redstone().PruneTorchBurnout(pos, currentTick) + return + } + + if tx.Redstone().RecordTorchToggle(pos, currentTick) { + torch.burnOut(pos, tx) + return + } + + torch.Lit = !torch.Lit + tx.SetBlock(pos, torch, nil) + updateTorchRedstone(pos, tx) +} + +// burnOut puts the redstone torch into burnout state, turning it off and playing effects. +func (RedstoneTorch) burnOut(pos cube.Pos, tx *world.Tx) { + torch, ok := redstoneTorchAt(pos, tx) + if !ok { + return + } + + tx.Redstone().BurnOutTorch(pos) + torch.Lit = false + tx.PlaySound(pos.Vec3Centre(), sound.Fizz{}) + tx.SetBlock(pos, torch, nil) + updateTorchRedstone(pos, tx) +} + +// redstoneTorchAt returns the current torch at pos. Scheduled redstone updates carry an old block value, so mutation +// paths must reload the live world block before writing torch state back. +func redstoneTorchAt(pos cube.Pos, tx *world.Tx) (RedstoneTorch, bool) { + t, ok := tx.Block(pos).(RedstoneTorch) + if !ok { + tx.Redstone().ClearTorchBurnout(pos) + } + return t, ok +} + +// updateTorchRedstone updates receivers around the torch and behind the block it strongly powers above. +func updateTorchRedstone(pos cube.Pos, tx *world.Tx) { + tx.Redstone().WithActiveTorchUpdate(pos, func() { + updateDirectionalRedstone(pos, tx, cube.FaceUp) + }) +} + +// EncodeItem encodes the redstone torch as an item. +func (RedstoneTorch) EncodeItem() (name string, meta int16) { + return "minecraft:redstone_torch", 0 +} + +// EncodeBlock encodes the redstone torch as a block for network transmission. +func (t RedstoneTorch) EncodeBlock() (name string, properties map[string]any) { + face := "unknown" + if t.Facing != unknownFace { + face = t.Facing.String() + if t.Facing == cube.FaceDown { + face = "top" + } + } + if t.Lit { + return "minecraft:redstone_torch", map[string]any{"torch_facing_direction": face} + } + return "minecraft:unlit_redstone_torch", map[string]any{"torch_facing_direction": face} +} + +// RedstoneSource ... +func (t RedstoneTorch) RedstoneSource() bool { + return true +} + +// WeakPower returns the weak redstone power level provided to adjacent blocks. +func (t RedstoneTorch) WeakPower(_ cube.Pos, face cube.Face, _ *world.Tx, _ bool) int { + if !t.Lit { + return 0 + } + if face.Opposite() == t.Facing { + return 0 + } + return 15 +} + +// StrongPower returns the strong redstone power level provided to the block above the torch. +func (t RedstoneTorch) StrongPower(_ cube.Pos, face cube.Face, _ *world.Tx, _ bool) int { + if t.Lit && face == cube.FaceDown { + return 15 + } + return 0 +} + +// inputStrength returns the redstone power level received by the block the torch is attached to. +func (t RedstoneTorch) inputStrength(pos cube.Pos, tx *world.Tx) int { + return tx.RedstonePower(pos.Side(t.Facing), t.Facing, true) +} + +// redstoneTorchFallbackSides lists the faces to check for placing a redstone torch on a non-solid block. +var redstoneTorchFallbackSides = [...]cube.Face{ + cube.FaceSouth, + cube.FaceWest, + cube.FaceNorth, + cube.FaceEast, + cube.FaceDown, +} + +// findTorchPlacementFace finds a valid face for placing a redstone torch on a non-solid block. +// It returns the face the torch should be placed on and whether it was found. +func findTorchPlacementFace(pos cube.Pos, tx *world.Tx) (cube.Face, bool) { + for _, side := range redstoneTorchFallbackSides { + if tx.Block(pos.Side(side)).Model().FaceSolid(pos.Side(side), side.Opposite(), tx) { + return side.Opposite(), true + } + } + return 0, false +} + +// allRedstoneTorches returns all possible redstone torch block states. +func allRedstoneTorches() (all []world.Block) { + for _, f := range append(cube.Faces(), unknownFace) { + if f == cube.FaceUp { + continue + } + all = append(all, RedstoneTorch{Facing: f, Lit: true}) + all = append(all, RedstoneTorch{Facing: f}) + } + return +} diff --git a/server/block/redstone_wire.go b/server/block/redstone_wire.go new file mode 100644 index 0000000..3c110dc --- /dev/null +++ b/server/block/redstone_wire.go @@ -0,0 +1,273 @@ +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/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// RedstoneWire is a block that is used to transfer a charge between objects. Charged objects can be used to open doors +// or activate certain items. This block is the placed form of redstone which can be found by mining redstone ore with +// an iron pickaxe or better. Deactivated redstone wire will appear dark red, but activated redstone wire will appear +// bright red with a sparkling particle effect. +type RedstoneWire struct { + empty + transparent + + // Power is the current power level of the redstone wire. It ranges from 0 to 15. + Power int +} + +// HasLiquidDrops ... +func (RedstoneWire) HasLiquidDrops() bool { + return true +} + +// BreakInfo ... +func (r RedstoneWire) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(RedstoneWire{})).withBreakHandler(func(pos cube.Pos, tx *world.Tx, _ item.User) { + updateStrongRedstone(pos, tx) + }) +} + +// EncodeBlock ... +func (r RedstoneWire) EncodeBlock() (string, map[string]any) { + return "minecraft:redstone_wire", map[string]any{ + "redstone_signal": int32(r.Power), + } +} + +// EncodeItem ... +func (RedstoneWire) EncodeItem() (name string, meta int16) { + return "minecraft:redstone", 0 +} + +// UseOnBlock ... +func (r RedstoneWire) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, r) + if !used { + return false + } + belowPos := pos.Side(cube.FaceDown) + if !tx.Block(belowPos).Model().FaceSolid(belowPos, cube.FaceUp, tx) { + return false + } + r.Power = r.calculatePower(pos, tx) + place(tx, pos, r, user, ctx) + if placed(ctx) { + updateStrongRedstone(pos, tx) + return true + } + return false +} + +// NeighbourUpdateTick ... +func (r RedstoneWire) NeighbourUpdateTick(pos, neighbour cube.Pos, tx *world.Tx) { + if pos == neighbour { + // Ignore the self-update sent after this wire's block state changes. + return + } + below := pos.Side(cube.FaceDown) + if !tx.Block(below).Model().FaceSolid(below, cube.FaceUp, tx) { + breakBlock(r, pos, tx) + return + } + if changed, ok := r.updateFromNeighbour(pos, tx); ok && !changed { + updateStrongRedstone(pos, tx) + } +} + +// RedstoneUpdate ... +func (r RedstoneWire) RedstoneUpdate(pos cube.Pos, tx *world.Tx) { + r.updatePower(pos, tx) +} + +// updateFromNeighbour updates the wire after a neighbour change. changed reports whether the wire's power changed, +// and ok reports whether the update was allowed by the redstone update handler. +func (r RedstoneWire) updateFromNeighbour(pos cube.Pos, tx *world.Tx) (changed bool, ok bool) { + if redstoneUpdateCancelled(pos, tx) { + return false, false + } + return r.updatePower(pos, tx), true +} + +// updatePower recalculates the wire's power and propagates the network when the power changes. +func (r RedstoneWire) updatePower(pos cube.Pos, tx *world.Tx) bool { + if power := r.calculatePower(pos, tx); r.Power != power { + r.Power = power + tx.SetBlock(pos, r, &world.SetOpts{DisableBlockUpdates: true}) + updateStrongRedstone(pos, tx) + return true + } + return false +} + +// RedstoneSource ... +func (RedstoneWire) RedstoneSource() bool { + return false +} + +// WeaklyPowersBlocks returns true because powered redstone dust weakly powers conductive blocks it points into or rests on top of. +func (RedstoneWire) WeaklyPowersBlocks() bool { + return true +} + +// WeakPower returns the power emitted by the wire toward a neighbouring receiver. Dust powers upward, never powers +// downward, and only powers horizontal receivers in connected directions. A powered wire with no horizontal +// connections behaves as an unconnected cross and powers every horizontal side. +func (r RedstoneWire) WeakPower(pos cube.Pos, face cube.Face, tx *world.Tx, accountForDust bool) int { + if !accountForDust { + return 0 + } + if face == cube.FaceUp { + return r.Power + } + if face == cube.FaceDown { + return 0 + } + if !r.hasHorizontalRedstoneConnection(pos, tx) { + return r.Power + } + if r.connection(pos, face.Opposite(), tx) { + return r.Power + } + if r.connection(pos, face, tx) && !r.connection(pos, face.RotateLeft(), tx) && !r.connection(pos, face.RotateRight(), tx) { + return r.Power + } + return 0 +} + +// StrongPower returns 0 because redstone dust weakly powers conductive blocks rather than strongly powering them. +func (RedstoneWire) StrongPower(cube.Pos, cube.Face, *world.Tx, bool) int { + return 0 +} + +// calculatePower returns the highest level of received redstone power at the provided position. +func (r RedstoneWire) calculatePower(pos cube.Pos, tx *world.Tx) int { + return calculateRedstoneWirePower(pos, tx, tx.Block) +} + +// calculateRedstoneWirePower returns the highest level of received redstone power at the provided position. blockAt is +// injected so direct updates and the BFS wire network share the same rules while the BFS path may still read cached +// node state. +func calculateRedstoneWirePower(pos cube.Pos, tx *world.Tx, blockAt func(cube.Pos) world.Block) int { + aboveBlocksVerticalTravel := blocksRedstoneWireVerticalTravel(blockAt(pos.Side(cube.FaceUp))) + var blockPower, wirePower int + for _, side := range cube.Faces() { + neighbourPos := pos.Side(side) + neighbour := blockAt(neighbourPos) + + wirePower = maxRedstoneWirePower(neighbour, wirePower) + blockPower = max(blockPower, tx.RedstonePower(neighbourPos, side, false)) + + if side.Axis() == cube.Y { + // Only check horizontal neighbours from here on. + continue + } + + if canRedstoneWireStepDown(pos, neighbourPos, neighbour, tx) && !aboveBlocksVerticalTravel { + wirePower = maxRedstoneWirePower(blockAt(neighbourPos.Side(cube.FaceUp)), wirePower) + } + if canRedstoneWireStepDown(neighbourPos.Side(cube.FaceDown), neighbourPos, neighbour, tx) && !blocksRedstoneWireVerticalTravel(neighbour) { + wirePower = maxRedstoneWirePower(blockAt(neighbourPos.Side(cube.FaceDown)), wirePower) + } + + if _, neighbourSolid := neighbour.Model().(model.Solid); !neighbourSolid { + wirePower = maxRedstoneWirePower(blockAt(neighbourPos.Side(cube.FaceDown)), wirePower) + } + } + return max(blockPower, wirePower-1) +} + +// hasHorizontalRedstoneConnection checks if the dust connects horizontally to redstone wire or a redstone source. It +// does not include passive receivers such as doors, trapdoors, or note blocks. +func (r RedstoneWire) hasHorizontalRedstoneConnection(pos cube.Pos, tx *world.Tx) bool { + for _, face := range cube.HorizontalFaces() { + if r.connection(pos, face, tx) { + return true + } + } + return false +} + +// connection returns true if the dust shape connects through the given face to another wire or a redstone source. It +// also accounts for valid one-block vertical wire connections. +func (r RedstoneWire) connection(pos cube.Pos, face cube.Face, tx *world.Tx) bool { + sidePos := pos.Side(face) + sideBlock := tx.Block(sidePos) + if r.connectsAbove(pos, sidePos, sideBlock, tx) || r.connectsTo(sideBlock, true) { + return true + } + return r.connectsBelow(sidePos, sideBlock, tx) +} + +// connectsAbove checks if the redstone wire can connect to the block above it. +func (r RedstoneWire) connectsAbove(pos, sidePos cube.Pos, sideBlock world.Block, tx *world.Tx) bool { + if blocksRedstoneWireVerticalTravel(tx.Block(pos.Side(cube.FaceUp))) || !r.canRunOnTop(tx, sidePos, sideBlock) { + return false + } + return r.connectsTo(tx.Block(sidePos.Side(cube.FaceUp)), false) +} + +// connectsBelow checks if the redstone wire can connect to the block below it. +func (r RedstoneWire) connectsBelow(sidePos cube.Pos, sideBlock world.Block, tx *world.Tx) bool { + _, sideSolid := sideBlock.Model().(model.Solid) + return !sideSolid && r.connectsTo(tx.Block(sidePos.Side(cube.FaceDown)), false) +} + +// connectsTo reports whether a block is part of the redstone wire connection graph. Passive redstone receivers are not +// connections; direct source conductors count only when allowDirectSources is true. +func (RedstoneWire) connectsTo(block world.Block, allowDirectSources bool) bool { + if _, ok := block.(RedstoneWire); ok { + return true + } + c, ok := block.(world.Conductor) + return ok && allowDirectSources && c.RedstoneSource() +} + +// canRunOnTop checks whether redstone dust can be placed on top of the block. +func (RedstoneWire) canRunOnTop(tx *world.Tx, pos cube.Pos, block world.Block) bool { + return block.Model().FaceSolid(pos, cube.FaceUp, tx) +} + +// blocksRedstoneWireVerticalTravel checks if the block above redstone wire blocks vertical wire travel. +func blocksRedstoneWireVerticalTravel(block world.Block) bool { + if _, ok := block.Model().(model.Solid); !ok { + return false + } + diffuser, ok := block.(LightDiffuser) + return !ok || diffuser.LightDiffusionLevel() != 0 +} + +// canRedstoneWireStepDown checks if redstone dust can provide power while travelling down around the side block. +func canRedstoneWireStepDown(from, side cube.Pos, block world.Block, tx *world.Tx) bool { + if stepDowner, ok := block.(RedstoneWireStepDowner); ok { + return stepDowner.CanRedstoneWireStepDown(side, from, tx) + } + for _, face := range cube.Faces() { + if !block.Model().FaceSolid(side, face, tx) { + return false + } + } + return true +} + +// TrimMaterial delegates to item.RedstoneWire so the block form stays valid for smithing trim decoding too. +func (RedstoneWire) TrimMaterial() string { + return item.RedstoneWire{}.TrimMaterial() +} + +// MaterialColour delegates to item.RedstoneWire to keep trim metadata defined in one place. +func (RedstoneWire) MaterialColour() string { + return item.RedstoneWire{}.MaterialColour() +} + +// allRedstoneWires returns a list of all redstone dust states. +func allRedstoneWires() (all []world.Block) { + for i := range 16 { + all = append(all, RedstoneWire{Power: i}) + } + return +} diff --git a/server/block/register.go b/server/block/register.go new file mode 100644 index 0000000..2dc5eff --- /dev/null +++ b/server/block/register.go @@ -0,0 +1,544 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +//go:generate go run ../../cmd/blockhash -o hash.go . + +// init registers all blocks implemented by Dragonfly. +func init() { + world.RegisterBlock(Air{}) + world.RegisterBlock(Amethyst{}) + 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}) + world.RegisterBlock(Bedrock{}) + world.RegisterBlock(BlueIce{}) + world.RegisterBlock(Bookshelf{}) + world.RegisterBlock(Bricks{}) + world.RegisterBlock(Calcite{}) + world.RegisterBlock(Clay{}) + world.RegisterBlock(Coal{}) + world.RegisterBlock(Cobblestone{Mossy: true}) + world.RegisterBlock(Cobblestone{}) + world.RegisterBlock(Cobweb{}) + world.RegisterBlock(CraftingTable{}) + world.RegisterBlock(DeadBush{}) + world.RegisterBlock(DeepslateBricks{Cracked: true}) + world.RegisterBlock(DeepslateBricks{}) + world.RegisterBlock(DeepslateTiles{Cracked: true}) + world.RegisterBlock(DeepslateTiles{}) + world.RegisterBlock(Diamond{}) + world.RegisterBlock(Diorite{Polished: true}) + world.RegisterBlock(Diorite{}) + world.RegisterBlock(DirtPath{}) + world.RegisterBlock(Dirt{Coarse: true}) + world.RegisterBlock(Dirt{}) + world.RegisterBlock(DragonEgg{}) + world.RegisterBlock(DriedKelp{}) + world.RegisterBlock(Dripstone{}) + world.RegisterBlock(Emerald{}) + world.RegisterBlock(EnchantingTable{}) + world.RegisterBlock(EndBricks{}) + world.RegisterBlock(EndStone{}) + world.RegisterBlock(FletchingTable{}) + world.RegisterBlock(GlassPane{}) + world.RegisterBlock(Glass{}) + world.RegisterBlock(Glowstone{}) + world.RegisterBlock(Gold{}) + world.RegisterBlock(Granite{Polished: true}) + world.RegisterBlock(Granite{}) + world.RegisterBlock(Grass{}) + world.RegisterBlock(Gravel{}) + world.RegisterBlock(Honeycomb{}) + world.RegisterBlock(InfestedStone{}) + world.RegisterBlock(InfestedCobblestone{}) + for _, b := range allInfestedStoneBricks() { + world.RegisterBlock(b) + } + for _, b := range allInfestedDeepslate() { + world.RegisterBlock(b) + } + world.RegisterBlock(InvisibleBedrock{}) + world.RegisterBlock(IronBars{}) + world.RegisterBlock(Iron{}) + world.RegisterBlock(Jukebox{}) + world.RegisterBlock(Lapis{}) + world.RegisterBlock(LilyPad{}) + world.RegisterBlock(Magma{}) + world.RegisterBlock(Melon{}) + world.RegisterBlock(MossCarpet{}) + world.RegisterBlock(MudBricks{}) + world.RegisterBlock(Mud{}) + world.RegisterBlock(NetherBrickFence{}) + world.RegisterBlock(NetherGoldOre{}) + world.RegisterBlock(NetherQuartzOre{}) + world.RegisterBlock(NetherSprouts{}) + world.RegisterBlock(NetherWartBlock{Warped: true}) + world.RegisterBlock(NetherWartBlock{}) + world.RegisterBlock(Netherite{}) + world.RegisterBlock(Netherrack{}) + world.RegisterBlock(Note{}) + world.RegisterBlock(Obsidian{Crying: true}) + world.RegisterBlock(Obsidian{}) + world.RegisterBlock(PackedIce{}) + world.RegisterBlock(PackedMud{}) + world.RegisterBlock(Podzol{}) + world.RegisterBlock(PolishedBlackstoneBrick{Cracked: true}) + world.RegisterBlock(PolishedBlackstoneBrick{}) + world.RegisterBlock(QuartzBricks{}) + world.RegisterBlock(RawCopper{}) + world.RegisterBlock(RawGold{}) + world.RegisterBlock(RawIron{}) + world.RegisterBlock(RedstoneBlock{}) + world.RegisterBlock(ReinforcedDeepslate{}) + world.RegisterBlock(ResinBricks{Chiseled: true}) + world.RegisterBlock(ResinBricks{}) + world.RegisterBlock(Resin{}) + world.RegisterBlock(Sand{Red: true}) + world.RegisterBlock(Sand{}) + world.RegisterBlock(SeaLantern{}) + world.RegisterBlock(Shroomlight{}) + world.RegisterBlock(Slime{}) + world.RegisterBlock(SmithingTable{}) + world.RegisterBlock(SmoothBasalt{}) + world.RegisterBlock(Snow{}) + world.RegisterBlock(SoulSand{}) + world.RegisterBlock(SoulSoil{}) + world.RegisterBlock(Sponge{Wet: true}) + world.RegisterBlock(Sponge{}) + world.RegisterBlock(SporeBlossom{}) + world.RegisterBlock(Stone{Smooth: true}) + world.RegisterBlock(Stone{}) + world.RegisterBlock(TNT{}) + world.RegisterBlock(Terracotta{}) + world.RegisterBlock(Tuff{}) + world.RegisterBlock(Tuff{Chiseled: true}) + world.RegisterBlock(TuffBricks{}) + world.RegisterBlock(TuffBricks{Chiseled: true}) + world.RegisterBlock(PolishedTuff{}) + world.RegisterBlock(ShortGrass{}) + world.RegisterBlock(Fern{}) + + for _, ore := range OreTypes() { + world.RegisterBlock(CoalOre{Type: ore}) + world.RegisterBlock(CopperOre{Type: ore}) + world.RegisterBlock(DiamondOre{Type: ore}) + world.RegisterBlock(EmeraldOre{Type: ore}) + world.RegisterBlock(GoldOre{Type: ore}) + world.RegisterBlock(IronOre{Type: ore}) + world.RegisterBlock(LapisOre{Type: ore}) + world.RegisterBlock(RedstoneOre{Type: ore}) + world.RegisterBlock(RedstoneOre{Type: ore, Lit: true}) + } + + registerAll(allAnvils()) + registerAll(allBambooBlocks()) + registerAll(allBanners()) + registerAll(allBarrels()) + registerAll(allBasalt()) + registerAll(allBeds()) + registerAll(allBeetroot()) + registerAll(allBlackstone()) + registerAll(allBlastFurnaces()) + registerAll(allBoneBlock()) + registerAll(allBrewingStands()) + registerAll(allCactus()) + registerAll(allCake()) + registerAll(allCampfires()) + registerAll(allCarpet()) + registerAll(allCarrots()) + registerAll(allIronChains()) + registerAll(allChests()) + registerAll(allCocoaBeans()) + registerAll(allComposters()) + registerAll(allConcrete()) + registerAll(allConcretePowder()) + registerAll(allCoral()) + registerAll(allCoralBlocks()) + registerAll(allDeepslate()) + registerAll(allDoors()) + registerAll(allDoubleFlowers()) + registerAll(allDoubleTallGrass()) + registerAll(allEndRods()) + registerAll(allEnderChests()) + registerAll(allFarmland()) + registerAll(allFence()) + registerAll(allFenceGates()) + registerAll(allFire()) + registerAll(allFlowers()) + registerAll(allFroglight()) + registerAll(allFurnaces()) + registerAll(allGlazedTerracotta()) + registerAll(allGrindstones()) + registerAll(allHayBales()) + registerAll(allHoppers()) + registerAll(allItemFrames()) + registerAll(allKelp()) + registerAll(allLadders()) + registerAll(allLanterns()) + registerAll(allLava()) + registerAll(allLeaves()) + registerAll(allLecterns()) + registerAll(allLevers()) + registerAll(allLight()) + registerAll(allLitPumpkins()) + registerAll(allLogs()) + registerAll(allLooms()) + registerAll(allMelonStems()) + registerAll(allMuddyMangroveRoots()) + registerAll(allNetherBricks()) + registerAll(allNetherWart()) + registerAll(allPinkPetals()) + registerAll(allPlanks()) + registerAll(allPotato()) + registerAll(allPrismarine()) + registerAll(allPumpkinStems()) + registerAll(allPumpkins()) + registerAll(allPurpurs()) + registerAll(allQuartz()) + registerAll(allRedstoneTorches()) + registerAll(allRedstoneWires()) + registerAll(allSandstones()) + registerAll(allSeaPickles()) + registerAll(allSigns()) + registerAll(allSkulls()) + registerAll(allSlabs()) + registerAll(allSmokers()) + registerAll(allStainedGlass()) + registerAll(allStainedGlassPane()) + registerAll(allStainedTerracotta()) + registerAll(allStairs()) + registerAll(allStoneBricks()) + registerAll(allStonecutters()) + registerAll(allString()) + registerAll(allSugarCane()) + registerAll(allTorches()) + registerAll(allTrapdoors()) + registerAll(allVines()) + registerAll(allWalls()) + registerAll(allWater()) + registerAll(allWheat()) + registerAll(allWood()) + registerAll(allWool()) + registerAll(allDecoratedPots()) + registerAll(allCopper()) + registerAll(allCopperBars()) + registerAll(allCopperChains()) + registerAll(allCopperDoors()) + registerAll(allCopperGolemStatues()) + registerAll(allCopperGrates()) + registerAll(allCopperLanterns()) + registerAll(allCopperTorches()) + registerAll(allCopperTrapdoors()) +} + +func init() { + world.RegisterItem(Air{}) + world.RegisterItem(Amethyst{}) + 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}) + world.RegisterItem(Basalt{}) + world.RegisterItem(Beacon{}) + world.RegisterItem(Bedrock{}) + world.RegisterItem(BeetrootSeeds{}) + world.RegisterItem(BlastFurnace{}) + world.RegisterItem(BlueIce{}) + world.RegisterItem(Bone{}) + world.RegisterItem(Bookshelf{}) + world.RegisterItem(BrewingStand{}) + world.RegisterItem(Bricks{}) + world.RegisterItem(Cactus{}) + world.RegisterItem(Cake{}) + world.RegisterItem(Calcite{}) + world.RegisterItem(Carrot{}) + world.RegisterItem(IronChain{}) + world.RegisterItem(Chest{}) + world.RegisterItem(ChiseledQuartz{}) + world.RegisterItem(Clay{}) + world.RegisterItem(Coal{}) + world.RegisterItem(Cobblestone{Mossy: true}) + world.RegisterItem(Cobblestone{}) + world.RegisterItem(Cobweb{}) + world.RegisterItem(CocoaBean{}) + world.RegisterItem(Composter{}) + world.RegisterItem(CopperTorch{}) + world.RegisterItem(CraftingTable{}) + world.RegisterItem(DeadBush{}) + world.RegisterItem(DeepslateBricks{Cracked: true}) + world.RegisterItem(DeepslateBricks{}) + world.RegisterItem(DeepslateTiles{Cracked: true}) + world.RegisterItem(DeepslateTiles{}) + world.RegisterItem(Diamond{}) + world.RegisterItem(Diorite{Polished: true}) + world.RegisterItem(Diorite{}) + world.RegisterItem(DirtPath{}) + world.RegisterItem(Dirt{Coarse: true}) + world.RegisterItem(Dirt{}) + world.RegisterItem(DragonEgg{}) + world.RegisterItem(DriedKelp{}) + world.RegisterItem(Dripstone{}) + world.RegisterItem(Emerald{}) + world.RegisterItem(EnchantingTable{}) + world.RegisterItem(EndBricks{}) + world.RegisterItem(EndRod{}) + world.RegisterItem(EndStone{}) + world.RegisterItem(EnderChest{}) + world.RegisterItem(Farmland{}) + world.RegisterItem(FletchingTable{}) + world.RegisterItem(Furnace{}) + world.RegisterItem(GlassPane{}) + world.RegisterItem(Glass{}) + world.RegisterItem(Glowstone{}) + world.RegisterItem(Gold{}) + world.RegisterItem(Granite{Polished: true}) + world.RegisterItem(Granite{}) + world.RegisterItem(Grass{}) + world.RegisterItem(Gravel{}) + world.RegisterItem(Grindstone{}) + world.RegisterItem(HayBale{}) + world.RegisterItem(Honeycomb{}) + world.RegisterItem(Hopper{}) + world.RegisterItem(InfestedStone{}) + world.RegisterItem(InfestedCobblestone{}) + for _, t := range StoneBricksTypes() { + world.RegisterItem(InfestedStoneBricks{Type: t}) + } + world.RegisterItem(InfestedDeepslate{}) + world.RegisterItem(InvisibleBedrock{}) + world.RegisterItem(IronBars{}) + world.RegisterItem(Iron{}) + world.RegisterItem(ItemFrame{Glowing: true}) + world.RegisterItem(ItemFrame{}) + world.RegisterItem(Jukebox{}) + world.RegisterItem(Kelp{}) + world.RegisterItem(Ladder{}) + world.RegisterItem(Lapis{}) + world.RegisterItem(Lectern{}) + world.RegisterItem(Lever{}) + world.RegisterItem(LilyPad{}) + world.RegisterItem(Magma{}) + world.RegisterItem(LitPumpkin{}) + world.RegisterItem(Loom{}) + world.RegisterItem(MelonSeeds{}) + world.RegisterItem(Melon{}) + world.RegisterItem(MossCarpet{}) + world.RegisterItem(MudBricks{}) + world.RegisterItem(MuddyMangroveRoots{}) + world.RegisterItem(Mud{}) + world.RegisterItem(NetherBrickFence{}) + world.RegisterItem(NetherGoldOre{}) + world.RegisterItem(NetherQuartzOre{}) + world.RegisterItem(NetherSprouts{}) + world.RegisterItem(NetherWartBlock{Warped: true}) + world.RegisterItem(NetherWartBlock{}) + world.RegisterItem(NetherWart{}) + world.RegisterItem(Netherite{}) + world.RegisterItem(Netherrack{}) + world.RegisterItem(Note{Pitch: 24}) + world.RegisterItem(Obsidian{Crying: true}) + world.RegisterItem(Obsidian{}) + world.RegisterItem(PackedIce{}) + world.RegisterItem(PackedMud{}) + world.RegisterItem(PinkPetals{}) + world.RegisterItem(Podzol{}) + world.RegisterItem(PolishedBlackstoneBrick{Cracked: true}) + world.RegisterItem(PolishedBlackstoneBrick{}) + world.RegisterItem(Potato{}) + world.RegisterItem(PumpkinSeeds{}) + world.RegisterItem(Pumpkin{Carved: true}) + world.RegisterItem(Pumpkin{}) + world.RegisterItem(PurpurPillar{}) + world.RegisterItem(Purpur{}) + world.RegisterItem(QuartzBricks{}) + world.RegisterItem(QuartzPillar{}) + world.RegisterItem(Quartz{Smooth: true}) + world.RegisterItem(Quartz{}) + world.RegisterItem(RawCopper{}) + world.RegisterItem(RawGold{}) + world.RegisterItem(RawIron{}) + world.RegisterItem(RedstoneBlock{}) + world.RegisterItem(RedstoneTorch{}) + world.RegisterItem(RedstoneWire{}) + world.RegisterItem(ReinforcedDeepslate{}) + world.RegisterItem(ResinBricks{Chiseled: true}) + world.RegisterItem(ResinBricks{}) + world.RegisterItem(Resin{}) + world.RegisterItem(Sand{Red: true}) + world.RegisterItem(Sand{}) + world.RegisterItem(SeaLantern{}) + world.RegisterItem(SeaPickle{}) + world.RegisterItem(Shroomlight{}) + world.RegisterItem(Slime{}) + world.RegisterItem(SmithingTable{}) + world.RegisterItem(Smoker{}) + world.RegisterItem(SmoothBasalt{}) + world.RegisterItem(Snow{}) + world.RegisterItem(SoulSand{}) + world.RegisterItem(SoulSoil{}) + world.RegisterItem(Sponge{Wet: true}) + world.RegisterItem(Sponge{}) + world.RegisterItem(SporeBlossom{}) + world.RegisterItem(Stonecutter{}) + world.RegisterItem(Stone{Smooth: true}) + world.RegisterItem(Stone{}) + world.RegisterItem(String{}) + world.RegisterItem(SugarCane{}) + world.RegisterItem(TNT{}) + world.RegisterItem(Terracotta{}) + world.RegisterItem(Tuff{}) + world.RegisterItem(Tuff{Chiseled: true}) + world.RegisterItem(TuffBricks{}) + world.RegisterItem(TuffBricks{Chiseled: true}) + world.RegisterItem(PolishedTuff{}) + world.RegisterItem(Vines{}) + world.RegisterItem(WheatSeeds{}) + world.RegisterItem(DecoratedPot{}) + world.RegisterItem(ShortGrass{}) + world.RegisterItem(Fern{}) + world.RegisterItem(item.Bucket{Content: item.LiquidBucketContent(Lava{})}) + world.RegisterItem(item.Bucket{Content: item.LiquidBucketContent(Water{})}) + world.RegisterItem(item.Bucket{Content: item.MilkBucketContent()}) + + for _, b := range allLight() { + world.RegisterItem(b.(world.Item)) + } + for _, c := range allCoral() { + world.RegisterItem(c.(world.Item)) + } + for _, c := range allCoralBlocks() { + world.RegisterItem(c.(world.Item)) + } + for _, t := range SandstoneTypes() { + world.RegisterItem(Sandstone{Type: t, Red: true}) + world.RegisterItem(Sandstone{Type: t}) + } + for _, s := range allStoneBricks() { + world.RegisterItem(s.(world.Item)) + } + for _, t := range AnvilTypes() { + world.RegisterItem(Anvil{Type: t}) + } + for _, c := range item.Colours() { + world.RegisterItem(Banner{Colour: c}) + world.RegisterItem(Bed{Colour: c}) + world.RegisterItem(Carpet{Colour: c}) + world.RegisterItem(ConcretePowder{Colour: c}) + world.RegisterItem(Concrete{Colour: c}) + world.RegisterItem(GlazedTerracotta{Colour: c}) + world.RegisterItem(StainedGlassPane{Colour: c}) + world.RegisterItem(StainedGlass{Colour: c}) + world.RegisterItem(StainedTerracotta{Colour: c}) + world.RegisterItem(Wool{Colour: c}) + } + for _, w := range WoodTypes() { + if t, ok := w.Leaves(); ok { + world.RegisterItem(Leaves{Type: t, Persistent: true}) + } + 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(Leaves{Type: AzaleaLeaves(), Persistent: true}) + world.RegisterItem(Leaves{Type: FloweringAzaleaLeaves(), Persistent: true}) + for _, ore := range OreTypes() { + world.RegisterItem(CoalOre{Type: ore}) + world.RegisterItem(CopperOre{Type: ore}) + world.RegisterItem(DiamondOre{Type: ore}) + world.RegisterItem(EmeraldOre{Type: ore}) + world.RegisterItem(GoldOre{Type: ore}) + world.RegisterItem(IronOre{Type: ore}) + world.RegisterItem(LapisOre{Type: ore}) + world.RegisterItem(RedstoneOre{Type: ore}) + } + 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}) + } + for _, f := range DoubleFlowerTypes() { + world.RegisterItem(DoubleFlower{Type: f}) + } + for _, g := range DoubleTallGrassTypes() { + world.RegisterItem(DoubleTallGrass{Type: g}) + } + for _, p := range PrismarineTypes() { + world.RegisterItem(Prismarine{Type: p}) + } + for _, t := range NetherBricksTypes() { + world.RegisterItem(NetherBricks{Type: t}) + } + for _, t := range FroglightTypes() { + world.RegisterItem(Froglight{Type: t}) + } + for _, s := range SkullTypes() { + world.RegisterItem(Skull{Type: s}) + } + for _, t := range SlabBlocks() { + world.RegisterItem(Slab{Block: t}) + } + for _, t := range WallBlocks() { + world.RegisterItem(Wall{Block: t}) + } + for _, s := range StairsBlocks() { + world.RegisterItem(Stairs{Block: s}) + } + for _, t := range BlackstoneTypes() { + world.RegisterItem(Blackstone{Type: t}) + } + for _, t := range DeepslateTypes() { + world.RegisterItem(Deepslate{Type: t}) + } + for _, o := range OxidationTypes() { + world.RegisterItem(CopperBars{Oxidation: o}) + world.RegisterItem(CopperBars{Oxidation: o, Waxed: true}) + world.RegisterItem(CopperChain{Oxidation: o}) + world.RegisterItem(CopperChain{Oxidation: o, Waxed: true}) + world.RegisterItem(CopperDoor{Oxidation: o}) + world.RegisterItem(CopperDoor{Oxidation: o, Waxed: true}) + world.RegisterItem(CopperGolemStatue{Oxidation: o}) + world.RegisterItem(CopperGolemStatue{Oxidation: o, Waxed: true}) + world.RegisterItem(CopperGrate{Oxidation: o}) + world.RegisterItem(CopperGrate{Oxidation: o, Waxed: true}) + world.RegisterItem(CopperLantern{Oxidation: o}) + world.RegisterItem(CopperLantern{Oxidation: o, Waxed: true}) + world.RegisterItem(CopperTrapdoor{Oxidation: o}) + world.RegisterItem(CopperTrapdoor{Oxidation: o, Waxed: true}) + + for _, c := range CopperTypes() { + world.RegisterItem(Copper{Type: c, Oxidation: o}) + world.RegisterItem(Copper{Type: c, Oxidation: o, Waxed: true}) + } + } +} + +func registerAll(blocks []world.Block) { + for _, b := range blocks { + world.RegisterBlock(b) + } +} diff --git a/server/block/reinforced_deepslate.go b/server/block/reinforced_deepslate.go new file mode 100644 index 0000000..149a132 --- /dev/null +++ b/server/block/reinforced_deepslate.go @@ -0,0 +1,22 @@ +package block + +// ReinforcedDeepslate is a tough decorative block that spawns in ancient cities. +type ReinforcedDeepslate struct { + solid + bassDrum +} + +// BreakInfo ... +func (r ReinforcedDeepslate) BreakInfo() BreakInfo { + return newBreakInfo(55, alwaysHarvestable, nothingEffective, oneOf(r)).withBlastResistance(6000) +} + +// EncodeItem ... +func (ReinforcedDeepslate) EncodeItem() (name string, meta int16) { + return "minecraft:reinforced_deepslate", 0 +} + +// EncodeBlock ... +func (ReinforcedDeepslate) EncodeBlock() (string, map[string]interface{}) { + return "minecraft:reinforced_deepslate", nil +} diff --git a/server/block/resin.go b/server/block/resin.go new file mode 100644 index 0000000..7bd6c81 --- /dev/null +++ b/server/block/resin.go @@ -0,0 +1,21 @@ +package block + +// Resin is a block equivalent to nine resin clumps. +type Resin struct { + solid +} + +// BreakInfo ... +func (r Resin) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(r)) +} + +// EncodeItem ... +func (Resin) EncodeItem() (name string, meta int16) { + return "minecraft:resin_block", 0 +} + +// EncodeBlock ... +func (Resin) EncodeBlock() (string, map[string]any) { + return "minecraft:resin_block", nil +} diff --git a/server/block/resin_bricks.go b/server/block/resin_bricks.go new file mode 100644 index 0000000..384dbac --- /dev/null +++ b/server/block/resin_bricks.go @@ -0,0 +1,31 @@ +package block + +// ResinBricks is a block crafted from resin brick. +type ResinBricks struct { + solid + bassDrum + + // Chiseled specifies if the resin bricks is its chiseled variant. + Chiseled bool +} + +// BreakInfo ... +func (r ResinBricks) BreakInfo() BreakInfo { + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(r)).withBlastResistance(30) +} + +// EncodeItem ... +func (r ResinBricks) EncodeItem() (name string, meta int16) { + if r.Chiseled { + return "minecraft:chiseled_resin_bricks", 0 + } + return "minecraft:resin_bricks", 0 +} + +// EncodeBlock ... +func (r ResinBricks) EncodeBlock() (string, map[string]any) { + if r.Chiseled { + return "minecraft:chiseled_resin_bricks", nil + } + return "minecraft:resin_bricks", nil +} diff --git a/server/block/sand.go b/server/block/sand.go new file mode 100644 index 0000000..f7ad54c --- /dev/null +++ b/server/block/sand.go @@ -0,0 +1,57 @@ +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" +) + +// Sand is a block affected by gravity. It can come in a red variant. +type Sand struct { + gravityAffected + solid + snare + + // Red toggles the red sand variant. + Red bool +} + +// SoilFor ... +func (s Sand) SoilFor(block world.Block) bool { + switch block.(type) { + case Cactus, DeadBush, SugarCane: + return true + } + return false +} + +// NeighbourUpdateTick ... +func (s Sand) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + s.fall(s, pos, tx) +} + +// BreakInfo ... +func (s Sand) BreakInfo() BreakInfo { + return newBreakInfo(0.5, alwaysHarvestable, shovelEffective, oneOf(s)) +} + +// SmeltInfo ... +func (Sand) SmeltInfo() item.SmeltInfo { + return newSmeltInfo(item.NewStack(Glass{}, 1), 0.1) +} + +// EncodeItem ... +func (s Sand) EncodeItem() (name string, meta int16) { + if s.Red { + return "minecraft:red_sand", 0 + } + return "minecraft:sand", 0 +} + +// EncodeBlock ... +func (s Sand) EncodeBlock() (string, map[string]any) { + if s.Red { + return "minecraft:red_sand", nil + } + return "minecraft:sand", nil +} diff --git a/server/block/sandstone.go b/server/block/sandstone.go new file mode 100644 index 0000000..466cf25 --- /dev/null +++ b/server/block/sandstone.go @@ -0,0 +1,71 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// Sandstone is a solid block commonly found in deserts and beaches underneath sand. +type Sandstone struct { + solid + bassDrum + + // Type is the type of sandstone of the block. + Type SandstoneType + + // Red specifies if the sandstone type is red or not. When set to true, the sandstone type will represent its + // red variant, for example red sandstone. + Red bool +} + +// BreakInfo ... +func (s Sandstone) BreakInfo() BreakInfo { + if s.Type == SmoothSandstone() { + return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(s)).withBlastResistance(30) + } + return newBreakInfo(0.8, pickaxeHarvestable, pickaxeEffective, oneOf(s)) +} + +// EncodeItem ... +func (s Sandstone) EncodeItem() (name string, meta int16) { + var prefix string + if s.Type != NormalSandstone() { + prefix = s.Type.String() + "_" + } + if s.Red { + return "minecraft:" + prefix + "red_sandstone", 0 + } + return "minecraft:" + prefix + "sandstone", 0 +} + +// EncodeBlock ... +func (s Sandstone) EncodeBlock() (string, map[string]any) { + var prefix string + if s.Type != NormalSandstone() { + prefix = s.Type.String() + "_" + } + if s.Red { + return "minecraft:" + prefix + "red_sandstone", nil + } + return "minecraft:" + prefix + "sandstone", nil +} + +// SmeltInfo ... +func (s Sandstone) SmeltInfo() item.SmeltInfo { + if s.Type == NormalSandstone() { + return newSmeltInfo(item.NewStack(Sandstone{Red: s.Red, Type: SmoothSandstone()}, 1), 0.1) + } + return item.SmeltInfo{} +} + +// allSandstones returns a list of all sandstone block variants. +func allSandstones() (c []world.Block) { + f := func(red bool) { + for _, t := range SandstoneTypes() { + c = append(c, Sandstone{Type: t, Red: red}) + } + } + f(true) + f(false) + return +} diff --git a/server/block/sandstone_type.go b/server/block/sandstone_type.go new file mode 100644 index 0000000..dff52cd --- /dev/null +++ b/server/block/sandstone_type.go @@ -0,0 +1,68 @@ +package block + +// SandstoneType represents a type of sandstone. +type SandstoneType struct { + sandstone +} + +type sandstone uint8 + +// NormalSandstone is the normal variant of sandstone. +func NormalSandstone() SandstoneType { + return SandstoneType{0} +} + +// CutSandstone is the cut variant of sandstone. +func CutSandstone() SandstoneType { + return SandstoneType{1} +} + +// ChiseledSandstone is the chiseled variant of sandstone. +func ChiseledSandstone() SandstoneType { + return SandstoneType{2} +} + +// SmoothSandstone is the smooth variant of sandstone. +func SmoothSandstone() SandstoneType { + return SandstoneType{3} +} + +// Uint8 returns the sandstone as a uint8. +func (s sandstone) Uint8() uint8 { + return uint8(s) +} + +// Name ... +func (s sandstone) Name() string { + switch s { + case 0: + return "Sandstone" + case 1: + return "Cut Sandstone" + case 2: + return "Chiseled Sandstone" + case 3: + return "Smooth Sandstone" + } + panic("unknown sandstone type") +} + +// String ... +func (s sandstone) String() string { + switch s { + case 0: + return "default" + case 1: + return "cut" + case 2: + return "chiseled" + case 3: + return "smooth" + } + panic("unknown sandstone type") +} + +// SandstoneTypes ... +func SandstoneTypes() []SandstoneType { + return []SandstoneType{NormalSandstone(), CutSandstone(), ChiseledSandstone(), SmoothSandstone()} +} diff --git a/server/block/sea_lantern.go b/server/block/sea_lantern.go new file mode 100644 index 0000000..69829ed --- /dev/null +++ b/server/block/sea_lantern.go @@ -0,0 +1,33 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" + "math/rand/v2" +) + +// SeaLantern is an underwater light sources that appear in ocean monuments and underwater ruins. +type SeaLantern struct { + transparent + solid + clicksAndSticks +} + +// LightEmissionLevel ... +func (SeaLantern) LightEmissionLevel() uint8 { + return 15 +} + +// BreakInfo ... +func (s SeaLantern) BreakInfo() BreakInfo { + return newBreakInfo(0.3, alwaysHarvestable, nothingEffective, silkTouchDrop(item.NewStack(item.PrismarineCrystals{}, rand.IntN(2)+2), item.NewStack(s, 1))) +} + +// EncodeItem ... +func (SeaLantern) EncodeItem() (name string, meta int16) { + return "minecraft:sea_lantern", 0 +} + +// EncodeBlock ... +func (SeaLantern) EncodeBlock() (string, map[string]any) { + return "minecraft:sea_lantern", nil +} diff --git a/server/block/sea_pickle.go b/server/block/sea_pickle.go new file mode 100644 index 0000000..96df4d8 --- /dev/null +++ b/server/block/sea_pickle.go @@ -0,0 +1,183 @@ +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" +) + +// SeaPickle is a small stationary underwater block that emits light, and is typically found in colonies of up to +// four sea pickles. +type SeaPickle struct { + empty + transparent + sourceWaterDisplacer + + // AdditionalCount is the amount of additional sea pickles clustered together. + AdditionalCount int + // Dead is whether the sea pickles are not alive. Sea pickles are only considered alive when inside of water. While + // alive, sea pickles emit light & can be grown with bone meal. + Dead bool +} + +// canSurvive ... +func (SeaPickle) canSurvive(pos cube.Pos, tx *world.Tx) bool { + below := tx.Block(pos.Side(cube.FaceDown)) + if !below.Model().FaceSolid(pos.Side(cube.FaceDown), cube.FaceUp, tx) { + return false + } + if liquid, ok := tx.Liquid(pos); ok { + if _, ok = liquid.(Water); !ok || liquid.LiquidDepth() != 8 { + return false + } + } + if emitter, ok := below.(LightDiffuser); ok && emitter.LightDiffusionLevel() != 15 { + return false + } + return true +} + +// BoneMeal ... +func (s SeaPickle) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { + if s.Dead { + return item.BoneMealResultNone + } + if coral, ok := tx.Block(pos.Side(cube.FaceDown)).(CoralBlock); !ok || coral.Dead { + return item.BoneMealResultNone + } + + if s.AdditionalCount != 3 { + s.AdditionalCount = 3 + tx.SetBlock(pos, s, nil) + } + + for x := -2; x <= 2; x++ { + distance := -int(math.Abs(float64(x))) + 2 + for z := -distance; z <= distance; z++ { + for y := -1; y < 1; y++ { + if (x == 0 && y == 0 && z == 0) || rand.IntN(6) != 0 { + continue + } + newPos := pos.Add(cube.Pos{x, y, z}) + + if _, ok := tx.Block(newPos).(Water); !ok { + continue + } + if coral, ok := tx.Block(newPos.Side(cube.FaceDown)).(CoralBlock); !ok || coral.Dead { + continue + } + tx.SetBlock(newPos, SeaPickle{AdditionalCount: rand.IntN(3) + 1}, nil) + } + } + } + + return item.BoneMealResultSmall +} + +// UseOnBlock ... +func (s SeaPickle) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + if existing, ok := tx.Block(pos).(SeaPickle); ok { + if existing.AdditionalCount >= 3 { + return false + } + + existing.AdditionalCount++ + place(tx, pos, existing, user, ctx) + return placed(ctx) + } + + pos, _, used := firstReplaceable(tx, pos, face, s) + if !used { + return false + } + if !s.canSurvive(pos, tx) { + return false + } + + s.Dead = true + if liquid, ok := tx.Liquid(pos); ok { + _, ok = liquid.(Water) + s.Dead = !ok + } + + place(tx, pos, s, user, ctx) + return placed(ctx) +} + +// NeighbourUpdateTick ... +func (s SeaPickle) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if !s.canSurvive(pos, tx) { + breakBlock(s, pos, tx) + return + } + + alive := false + if liquid, ok := tx.Liquid(pos); ok { + _, alive = liquid.(Water) + } + if s.Dead == alive { + s.Dead = !alive + tx.SetBlock(pos, s, nil) + } +} + +// HasLiquidDrops ... +func (SeaPickle) HasLiquidDrops() bool { + return true +} + +// SideClosed ... +func (SeaPickle) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// LightEmissionLevel ... +func (s SeaPickle) LightEmissionLevel() uint8 { + if s.Dead { + return 0 + } + return uint8(6 + s.AdditionalCount*3) +} + +// BreakInfo ... +func (s SeaPickle) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, simpleDrops(item.NewStack(s, s.AdditionalCount+1))) +} + +// FlammabilityInfo ... +func (SeaPickle) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(15, 100, true) +} + +// SmeltInfo ... +func (SeaPickle) SmeltInfo() item.SmeltInfo { + return newSmeltInfo(item.NewStack(item.Dye{Colour: item.ColourLime()}, 1), 0.1) +} + +// CompostChance ... +func (SeaPickle) CompostChance() float64 { + return 0.65 +} + +// EncodeItem ... +func (SeaPickle) EncodeItem() (name string, meta int16) { + return "minecraft:sea_pickle", 0 +} + +// EncodeBlock ... +func (s SeaPickle) EncodeBlock() (string, map[string]any) { + return "minecraft:sea_pickle", map[string]any{"cluster_count": int32(s.AdditionalCount), "dead_bit": s.Dead} +} + +// allSeaPickles ... +func allSeaPickles() (b []world.Block) { + for i := 0; i <= 3; i++ { + b = append(b, SeaPickle{AdditionalCount: i}) + b = append(b, SeaPickle{AdditionalCount: i, Dead: true}) + } + return +} diff --git a/server/block/short_grass.go b/server/block/short_grass.go new file mode 100644 index 0000000..edf6f3f --- /dev/null +++ b/server/block/short_grass.go @@ -0,0 +1,76 @@ +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" +) + +// ShortGrass is a transparent plant block which can be used to obtain seeds and as decoration. +type ShortGrass struct { + replaceable + transparent + empty + + Double bool +} + +// FlammabilityInfo ... +func (g ShortGrass) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(60, 100, false) +} + +// BreakInfo ... +func (g ShortGrass) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, grassDrops(g)) +} + +// BoneMeal attempts to affect the block using a bone meal item. +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 item.BoneMealResultSmall + } + return item.BoneMealResultNone +} + +// CompostChance ... +func (g ShortGrass) CompostChance() float64 { + return 0.65 +} + +// NeighbourUpdateTick ... +func (g ShortGrass) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if !supportsVegetation(g, tx.Block(pos.Side(cube.FaceDown))) { + breakBlock(g, pos, tx) + } +} + +// HasLiquidDrops ... +func (g ShortGrass) HasLiquidDrops() bool { + return true +} + +// UseOnBlock ... +func (g ShortGrass) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, g) + if !used || !supportsVegetation(g, tx.Block(pos.Side(cube.FaceDown))) { + return false + } + + place(tx, pos, g, user, ctx) + return placed(ctx) +} + +// EncodeItem ... +func (g ShortGrass) EncodeItem() (name string, meta int16) { + return "minecraft:short_grass", 0 +} + +// EncodeBlock ... +func (g ShortGrass) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:short_grass", nil +} diff --git a/server/block/shroomlight.go b/server/block/shroomlight.go new file mode 100644 index 0000000..393e15f --- /dev/null +++ b/server/block/shroomlight.go @@ -0,0 +1,31 @@ +package block + +// Shroomlight are light-emitting blocks that generate in huge fungi. +type Shroomlight struct { + solid +} + +// LightEmissionLevel ... +func (Shroomlight) LightEmissionLevel() uint8 { + return 15 +} + +// BreakInfo ... +func (s Shroomlight) BreakInfo() BreakInfo { + return newBreakInfo(1, alwaysHarvestable, hoeEffective, oneOf(s)) +} + +// CompostChance ... +func (Shroomlight) CompostChance() float64 { + return 0.65 +} + +// EncodeItem ... +func (Shroomlight) EncodeItem() (name string, meta int16) { + return "minecraft:shroomlight", 0 +} + +// EncodeBlock ... +func (Shroomlight) EncodeBlock() (string, map[string]any) { + return "minecraft:shroomlight", nil +} diff --git a/server/block/sign.go b/server/block/sign.go new file mode 100644 index 0000000..3c54ba2 --- /dev/null +++ b/server/block/sign.go @@ -0,0 +1,251 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "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" + "image/color" + "time" +) + +// Sign is a non-solid block that can display text on the front and back of the block. +type Sign struct { + transparent + empty + bass + sourceWaterDisplacer + + // Wood is the type of wood of the sign. This field must have one of the values found in the material + // package. + Wood WoodType + // Attach is the attachment of the Sign. It is either of the type WallAttachment or StandingAttachment. + Attach Attachment + // Waxed specifies if the Sign has been waxed by a player. If set to true, the Sign can no longer be edited by + // anyone and must be destroyed if the text needs to be changed. + Waxed bool + // Front is the text of the front side of the sign. Anyone can edit this unless the sign is Waxed. + Front SignText + // Back is the text of the back side of the sign. Anyone can edit this unless the sign is Waxed. + Back SignText +} + +// SignText represents the data for a single side of a sign. The sign can be edited on the front and back side. +type SignText struct { + // Text is the text displayed on this side of the sign. The text is automatically wrapped if it does not fit on a line. + Text string + // BaseColour is the base colour of the text on this side of the sign, changed when using a dye on the sign. The default + // colour is black. + BaseColour color.RGBA + // Glowing specifies if the Sign has glowing text on the current side. If set to true, the text will be visible even + // in the dark, and it will have an outline to improve visibility. + Glowing bool + // Owner holds the XUID of the player that most recently edited this side of the sign. + Owner string +} + +// SideClosed ... +func (s Sign) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// MaxCount ... +func (s Sign) MaxCount() int { + return 16 +} + +// FlammabilityInfo ... +func (s Sign) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(0, 0, true) +} + +// FuelInfo ... +func (s Sign) FuelInfo() item.FuelInfo { + if !s.Wood.Flammable() { + return item.FuelInfo{} + } + return newFuelInfo(time.Second * 10) +} + +// EncodeItem ... +func (s Sign) EncodeItem() (name string, meta int16) { + return "minecraft:" + s.Wood.String() + "_sign", 0 +} + +// BreakInfo ... +func (s Sign) BreakInfo() BreakInfo { + return newBreakInfo(1, alwaysHarvestable, axeEffective, oneOf(Sign{Wood: s.Wood})) +} + +// Dye dyes the Sign, changing its base colour to that of the colour passed. +func (s Sign) Dye(pos cube.Pos, userPos mgl64.Vec3, c item.Colour) (world.Block, bool) { + if s.EditingFrontSide(pos, userPos) { + if s.Front.BaseColour == c.SignRGBA() { + return s, false + } + s.Front.BaseColour = c.SignRGBA() + } else { + if s.Back.BaseColour == c.SignRGBA() { + return s, false + } + s.Back.BaseColour = c.SignRGBA() + } + return s, true +} + +// Ink inks the sign either glowing or non-glowing. +func (s Sign) Ink(pos cube.Pos, userPos mgl64.Vec3, glowing bool) (world.Block, bool) { + if s.EditingFrontSide(pos, userPos) { + if s.Front.Glowing == glowing { + return s, false + } + s.Front.Glowing = glowing + } else { + if s.Back.Glowing == glowing { + return s, false + } + s.Back.Glowing = glowing + } + return s, true +} + +// Wax waxes a sign to prevent it from further editing. +func (s Sign) Wax(cube.Pos, mgl64.Vec3) (world.Block, bool) { + if s.Waxed { + return s, false + } + s.Waxed = true + return s, true +} + +// Activate ... +func (s Sign) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + if editor, ok := u.(SignEditor); ok && !s.Waxed { + editor.OpenSign(pos, s.EditingFrontSide(pos, u.Position())) + } else if s.Waxed { + tx.PlaySound(pos.Vec3(), sound.WaxedSignFailedInteraction{}) + } + return true +} + +// EditingFrontSide returns if the user is editing the front side of the sign based on their position relative to the +// position and direction of the sign. +func (s Sign) EditingFrontSide(pos cube.Pos, userPos mgl64.Vec3) bool { + return userPos.Sub(pos.Vec3Centre()).Dot(s.Attach.Rotation().Vec3()) > 0 +} + +// SignEditor represents something that can edit a sign, typically players. +type SignEditor interface { + OpenSign(pos cube.Pos, frontSide bool) +} + +// UseOnBlock ... +func (s Sign) 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, s) + if !used || face == cube.FaceDown { + return false + } + + if face == cube.FaceUp { + s.Attach = StandingAttachment(user.Rotation().Orientation().Opposite()) + } else { + s.Attach = WallAttachment(face.Direction()) + } + place(tx, pos, s, user, ctx) + if editor, ok := user.(SignEditor); ok { + editor.OpenSign(pos, true) + } + return placed(ctx) +} + +// NeighbourUpdateTick ... +func (s Sign) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if s.Attach.hanging { + if _, ok := tx.Block(pos.Side(s.Attach.facing.Opposite().Face())).(Air); ok { + breakBlock(s, pos, tx) + } + } else if _, ok := tx.Block(pos.Side(cube.FaceDown)).(Air); ok { + breakBlock(s, pos, tx) + } +} + +// EncodeBlock ... +func (s Sign) EncodeBlock() (name string, properties map[string]any) { + woodType := s.Wood.String() + "_" + switch s.Wood { + case OakWood(): + woodType = "" + case DarkOakWood(): + woodType = "darkoak_" + } + if s.Attach.hanging { + return "minecraft:" + woodType + "wall_sign", map[string]any{"facing_direction": int32(s.Attach.facing + 2)} + } + return "minecraft:" + woodType + "standing_sign", map[string]any{"ground_sign_direction": int32(s.Attach.o)} +} + +// DecodeNBT ... +func (s Sign) DecodeNBT(data map[string]any) any { + if nbtconv.String(data, "Text") != "" { + // The NBT format changed in 1.19.80 to have separate data for each side of the sign. The old format must still + // be supported for backwards compatibility. + s.Front.Text = nbtconv.String(data, "Text") + s.Front.BaseColour = nbtconv.RGBAFromInt32(nbtconv.Int32(data, "SignTextColor")) + s.Front.Glowing = nbtconv.Bool(data, "IgnoreLighting") && nbtconv.Bool(data, "TextIgnoreLegacyBugResolved") + return s + } + + front, ok := data["FrontText"].(map[string]any) + if ok { + s.Front.BaseColour = nbtconv.RGBAFromInt32(nbtconv.Int32(front, "Color")) + s.Front.Glowing = nbtconv.Bool(front, "GlowingText") + s.Front.Text = nbtconv.String(front, "Text") + s.Front.Owner = nbtconv.String(front, "Owner") + } + + back, ok := data["BackText"].(map[string]any) + if ok { + s.Back.BaseColour = nbtconv.RGBAFromInt32(nbtconv.Int32(back, "Color")) + s.Back.Glowing = nbtconv.Bool(back, "GlowingText") + s.Back.Text = nbtconv.String(back, "Text") + s.Back.Owner = nbtconv.String(back, "Owner") + } + + return s +} + +// EncodeNBT ... +func (s Sign) EncodeNBT() map[string]any { + m := map[string]any{ + "id": "Sign", + "IsWaxed": boolByte(s.Waxed), + "FrontText": map[string]any{ + "SignTextColor": nbtconv.Int32FromRGBA(s.Front.BaseColour), + "IgnoreLighting": boolByte(s.Front.Glowing), + "Text": s.Front.Text, + "TextOwner": s.Front.Owner, + }, + "BackText": map[string]any{ + "SignTextColor": nbtconv.Int32FromRGBA(s.Back.BaseColour), + "IgnoreLighting": boolByte(s.Back.Glowing), + "Text": s.Back.Text, + "TextOwner": s.Back.Owner, + }, + } + return m +} + +// allSigns ... +func allSigns() (signs []world.Block) { + for _, w := range WoodTypes() { + for _, d := range cube.Directions() { + signs = append(signs, Sign{Wood: w, Attach: WallAttachment(d)}) + } + for o := cube.Orientation(0); o <= 15; o++ { + signs = append(signs, Sign{Wood: w, Attach: StandingAttachment(o)}) + } + } + return +} diff --git a/server/block/skull.go b/server/block/skull.go new file mode 100644 index 0000000..0f78caa --- /dev/null +++ b/server/block/skull.go @@ -0,0 +1,125 @@ +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/go-gl/mathgl/mgl64" +) + +// TODO: Dragon Heads can be powered by redstone + +// Skull is a decorative block. There are seven types of skulls: player, zombie, skeleton, wither skeleton, creeper, +// dragon, and piglin. +type Skull struct { + transparent + sourceWaterDisplacer + + // Type is the type of the skull. + Type SkullType + + // Attach is the attachment of the Skull. It is either of the type WallAttachment or StandingAttachment. + //blockhash:facing_only + Attach Attachment +} + +// Helmet ... +func (Skull) Helmet() bool { + return true +} + +// DefencePoints ... +func (Skull) DefencePoints() float64 { + return 0 +} + +// Toughness ... +func (Skull) Toughness() float64 { + return 0 +} + +// KnockBackResistance ... +func (Skull) KnockBackResistance() float64 { + return 0 +} + +// Model ... +func (s Skull) Model() world.BlockModel { + return model.Skull{Direction: s.Attach.facing.Face(), Hanging: s.Attach.hanging} +} + +// UseOnBlock ... +func (s Skull) 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, s) + if !used || face == cube.FaceDown { + return false + } + + if face == cube.FaceUp { + s.Attach = StandingAttachment(user.Rotation().Orientation()) + } else { + s.Attach = WallAttachment(face.Direction()) + } + place(tx, pos, s, user, ctx) + return placed(ctx) +} + +// SideClosed ... +func (Skull) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// HasLiquidDrops ... +func (Skull) HasLiquidDrops() bool { + return true +} + +// BreakInfo ... +func (s Skull) BreakInfo() BreakInfo { + return newBreakInfo(1, alwaysHarvestable, nothingEffective, oneOf(Skull{Type: s.Type})) +} + +// EncodeItem ... +func (s Skull) EncodeItem() (name string, meta int16) { + return "minecraft:" + s.Type.String(), 0 +} + +// DecodeNBT ... +func (s Skull) DecodeNBT(data map[string]interface{}) interface{} { + if t := skull(nbtconv.Uint8(data, "SkullType")); t != 255 { + // Used to upgrade pre-1.21.40 skulls after their flattening. Any skull placed since will set + // SkullType to 255. + s.Type = SkullType{t} + } + s.Attach.o = cube.OrientationFromYaw(float64(nbtconv.Float32(data, "Rotation"))) + return s +} + +// EncodeNBT ... +func (s Skull) EncodeNBT() map[string]interface{} { + return map[string]interface{}{"id": "Skull", "SkullType": uint8(255), "Rotation": float32(s.Attach.o.Yaw())} +} + +// EncodeBlock ... +func (s Skull) EncodeBlock() (string, map[string]interface{}) { + if s.Attach.hanging { + if s.Attach.facing == unknownDirection { + return "minecraft:" + s.Type.String(), map[string]interface{}{"facing_direction": int32(0)} + } + return "minecraft:" + s.Type.String(), map[string]interface{}{"facing_direction": int32(s.Attach.facing) + 2} + } + return "minecraft:" + s.Type.String(), map[string]interface{}{"facing_direction": int32(1)} +} + +// allSkulls ... +func allSkulls() (skulls []world.Block) { + for _, t := range SkullTypes() { + for _, d := range append(cube.Directions(), unknownDirection) { + skulls = append(skulls, Skull{Type: t, Attach: WallAttachment(d)}) + } + skulls = append(skulls, Skull{Type: t, Attach: StandingAttachment(0)}) + } + return +} diff --git a/server/block/skull_type.go b/server/block/skull_type.go new file mode 100644 index 0000000..7b035ce --- /dev/null +++ b/server/block/skull_type.go @@ -0,0 +1,95 @@ +package block + +// SkullType represents a mob variant of a skull. +type SkullType struct { + skull +} + +// SkeletonSkull returns the skull variant for skeletons. +func SkeletonSkull() SkullType { + return SkullType{0} +} + +// WitherSkeletonSkull returns the skull variant for wither skeletons. +func WitherSkeletonSkull() SkullType { + return SkullType{1} +} + +// ZombieHead returns the skull variant for zombies. +func ZombieHead() SkullType { + return SkullType{2} +} + +// PlayerHead returns the skull variant for players. +func PlayerHead() SkullType { + return SkullType{3} +} + +// CreeperHead returns the skull variant for creepers. +func CreeperHead() SkullType { + return SkullType{4} +} + +// DragonHead returns the skull variant for ender dragons. +func DragonHead() SkullType { + return SkullType{5} +} + +// PiglinHead returns the skull variant for piglins. +func PiglinHead() SkullType { + return SkullType{6} +} + +// SkullTypes returns all variants of skulls. +func SkullTypes() []SkullType { + return []SkullType{SkeletonSkull(), WitherSkeletonSkull(), ZombieHead(), PlayerHead(), CreeperHead(), DragonHead(), PiglinHead()} +} + +type skull uint8 + +// Uint8 ... +func (s skull) Uint8() uint8 { + return uint8(s) +} + +// Name ... +func (s skull) Name() string { + switch s { + case 0: + return "Skeleton Skull" + case 1: + return "Wither Skeleton Skull" + case 2: + return "Zombie Head" + case 3: + return "Player Head" + case 4: + return "Creeper Head" + case 5: + return "Dragon Head" + case 6: + return "Piglin Head" + } + panic("unknown skull type") +} + +// String ... +func (s skull) String() string { + switch s { + case 0: + return "skeleton_skull" + case 1: + return "wither_skeleton_skull" + case 2: + return "zombie_head" + case 3: + return "player_head" + case 4: + return "creeper_head" + case 5: + return "dragon_head" + case 6: + return "piglin_head" + } + panic("unknown skull type") +} diff --git a/server/block/slab.go b/server/block/slab.go new file mode 100644 index 0000000..90c578f --- /dev/null +++ b/server/block/slab.go @@ -0,0 +1,166 @@ +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/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" +) + +// Slab is a half block that allows entities to walk up blocks without jumping. +type Slab struct { + // Block is the block to use for the type of slab. + Block world.Block + // Top specifies if the slab is in the top part of the block. + Top bool + // Double specifies if the slab is a double slab. These double slabs can be made by placing another slab + // on an existing slab. + Double bool +} + +// UseOnBlock handles the placement of slabs with relation to them being upside down or not and handles slabs +// being turned into double slabs. +func (s Slab) UseOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + id, meta := s.EncodeItem() + clickedBlock := tx.Block(pos) + if clickedSlab, ok := clickedBlock.(Slab); ok && !s.Double { + clickedId, clickedMeta := clickedSlab.EncodeItem() + if !clickedSlab.Double && id == clickedId && meta == clickedMeta && ((face == cube.FaceUp && !clickedSlab.Top) || (face == cube.FaceDown && clickedSlab.Top)) { + // A half slab of the same type was clicked at the top, so we can make it full. + clickedSlab.Double = true + + place(tx, pos, clickedSlab, user, ctx) + return placed(ctx) + } + } + if sideSlab, ok := tx.Block(pos.Side(face)).(Slab); ok && !replaceableWith(tx, pos, s) && !s.Double { + sideId, sideMeta := sideSlab.EncodeItem() + // The block on the side of the one clicked was a slab and the block clicked was not replaceableWith, so + // the slab on the side must've been half and may now be filled if the wood types are the same. + if !sideSlab.Double && id == sideId && meta == sideMeta { + sideSlab.Double = true + + place(tx, pos.Side(face), sideSlab, user, ctx) + return placed(ctx) + } + } + pos, face, used = firstReplaceable(tx, pos, face, s) + if !used { + return + } + if face == cube.FaceDown || (clickPos[1] > 0.5 && face != cube.FaceUp) { + s.Top = true + } + + place(tx, pos, s, user, ctx) + return placed(ctx) +} + +// Instrument ... +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() +} + +// FlammabilityInfo ... +func (s Slab) FlammabilityInfo() FlammabilityInfo { + if flammable, ok := s.Block.(Flammable); ok { + return flammable.FlammabilityInfo() + } + return newFlammabilityInfo(0, 0, false) +} + +// FuelInfo ... +func (s Slab) FuelInfo() item.FuelInfo { + if fuel, ok := s.Block.(item.Fuel); ok { + return fuel.FuelInfo() + } + return item.FuelInfo{} +} + +// CanDisplace ... +func (s Slab) CanDisplace(b world.Liquid) bool { + water, ok := b.(Water) + return !s.Double && ok && water.Depth == 8 +} + +// SideClosed ... +func (s Slab) SideClosed(pos, side cube.Pos, _ *world.Tx) bool { + // Only returns true if the side is below the slab and if the slab is not upside down. + return !s.Top && side[1] == pos[1]-1 +} + +// LightDiffusionLevel returns 0 if the slab is a half slab, or 15 if it is double. +func (s Slab) LightDiffusionLevel() uint8 { + if s.Double { + return 15 + } + return 0 +} + +// CanRedstoneWireStepDown ... +func (s Slab) CanRedstoneWireStepDown(cube.Pos, cube.Pos, *world.Tx) bool { + return s.Double +} + +// BreakInfo ... +func (s Slab) BreakInfo() BreakInfo { + hardness, blastResistance, harvestable, effective := 2.0, 30.0, pickaxeHarvestable, pickaxeEffective + + switch block := s.Block.(type) { + case Stone, Sandstone, Quartz, Purpur, Blackstone, PolishedBlackstoneBrick: + // These slab types do not match their block's hardness or blast resistance + case StoneBricks: + if block.Type == MossyStoneBricks() { + hardness = 1.5 + } + case Breakable: + breakInfo := block.BreakInfo() + hardness, blastResistance, harvestable, effective = breakInfo.Hardness, breakInfo.BlastResistance, breakInfo.Harvestable, breakInfo.Effective + } + return newBreakInfo(hardness, harvestable, effective, func(tool item.Tool, enchantments []item.Enchantment) []item.Stack { + if s.Double { + return []item.Stack{item.NewStack(s, 2)} + } + return []item.Stack{item.NewStack(s, 1)} + }).withBlastResistance(blastResistance) +} + +// Model ... +func (s Slab) Model() world.BlockModel { + return model.Slab{Double: s.Double, Top: s.Top} +} + +// EncodeItem ... +func (s Slab) EncodeItem() (string, int16) { + name, suffix := encodeSlabBlock(s.Block, false) + return "minecraft:" + name + suffix, 0 +} + +// EncodeBlock ... +func (s Slab) EncodeBlock() (string, map[string]any) { + side := "bottom" + if s.Top { + side = "top" + } + name, suffix := encodeSlabBlock(s.Block, s.Double) + return "minecraft:" + name + suffix, map[string]any{"minecraft:vertical_half": side} +} + +// allSlabs ... +func allSlabs() (b []world.Block) { + for _, s := range SlabBlocks() { + b = append(b, Slab{Block: s, Double: true}) + b = append(b, Slab{Block: s, Top: true, Double: true}) + b = append(b, Slab{Block: s, Top: true}) + b = append(b, Slab{Block: s}) + } + return +} diff --git a/server/block/slab_type.go b/server/block/slab_type.go new file mode 100644 index 0000000..476bd64 --- /dev/null +++ b/server/block/slab_type.go @@ -0,0 +1,206 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/world" +) + +// encodeSlabBlock encodes the provided block in to an identifier and meta value that can be used to encode the slab. +// halfFlattened is a temporary hack for a stone_block_slab which has been flattened but double_stone_block_slab +// has not. This can be removed in 1.21.10 where they have flattened all slab types. +func encodeSlabBlock(block world.Block, double bool) (id string, suffix string) { + suffix = "_slab" + if double { + suffix = "_double_slab" + } + + switch block := block.(type) { + case Andesite: + if block.Polished { + return "polished_andesite", suffix + } + return "andesite", suffix + case Blackstone: + if block.Type == NormalBlackstone() { + return "blackstone", suffix + } else if block.Type == PolishedBlackstone() { + return "polished_blackstone", suffix + } + case BambooMosaic: + return "bamboo_mosaic", suffix + case Bricks: + return "brick", suffix + case Cobblestone: + if block.Mossy { + return "mossy_cobblestone", suffix + } + return "cobblestone", suffix + case Copper: + if block.Type == CutCopper() { + suffix = "cut_copper_slab" + if double { + suffix = "double_" + suffix + } + var name string + if block.Oxidation != UnoxidisedOxidation() { + name = block.Oxidation.String() + "_" + } + if block.Waxed { + name = "waxed_" + name + } + return name, suffix + } + case Deepslate: + if block.Type == CobbledDeepslate() { + return "cobbled_deepslate", suffix + } else if block.Type == PolishedDeepslate() { + return "polished_deepslate", suffix + } + case DeepslateBricks: + if !block.Cracked { + return "deepslate_brick", suffix + } + case DeepslateTiles: + if !block.Cracked { + return "deepslate_tile", suffix + } + case Diorite: + if block.Polished { + return "polished_diorite", suffix + } + return "diorite", suffix + case EndBricks: + return "end_stone_brick", suffix + case Granite: + if block.Polished { + return "polished_granite", suffix + } + return "granite", suffix + case MudBricks: + return "mud_brick", suffix + case NetherBricks: + if block.Type == RedNetherBricks() { + return "nether_brick", suffix + } + return "red_nether_brick", suffix + case Planks: + return block.Wood.String(), suffix + case PolishedBlackstoneBrick: + if !block.Cracked { + return "polished_blackstone_brick", suffix + } + case PolishedTuff: + return "polished_tuff", suffix + case Prismarine: + switch block.Type { + case NormalPrismarine(): + return "prismarine", suffix + case DarkPrismarine(): + return "dark_prismarine", suffix + case BrickPrismarine(): + return "prismarine_brick", suffix + } + panic("invalid prismarine type") + case Purpur: + return "purpur", suffix + case Quartz: + if block.Smooth { + return "smooth_quartz", suffix + } + return "quartz", suffix + case ResinBricks: + return "resin_brick", suffix + case Sandstone: + switch block.Type { + case NormalSandstone(): + if block.Red { + return "red_sandstone", suffix + } + return "sandstone", suffix + case CutSandstone(): + if block.Red { + return "cut_red_sandstone", suffix + } + return "cut_sandstone", suffix + case SmoothSandstone(): + if block.Red { + return "smooth_red_sandstone", suffix + } + return "smooth_sandstone", suffix + } + panic("invalid sandstone type") + case Stone: + if block.Smooth { + return "smooth_stone", suffix + } + return "normal_stone", suffix + case StoneBricks: + if block.Type == MossyStoneBricks() { + return "mossy_stone_brick", suffix + } + return "stone_brick", suffix + case Tuff: + if !block.Chiseled { + return "tuff", suffix + } + case TuffBricks: + if !block.Chiseled { + return "tuff_brick", suffix + } + } + panic("invalid block used for slab") +} + +// SlabBlocks returns a list of all possible blocks for a slab. +func SlabBlocks() []world.Block { + b := []world.Block{ + Andesite{Polished: true}, + Andesite{}, + BambooMosaic{}, + Blackstone{Type: PolishedBlackstone()}, + Blackstone{}, + Bricks{}, + Cobblestone{Mossy: true}, + Cobblestone{}, + DeepslateBricks{}, + DeepslateTiles{}, + Deepslate{Type: CobbledDeepslate()}, + Deepslate{Type: PolishedDeepslate()}, + Diorite{Polished: true}, + Diorite{}, + EndBricks{}, + Granite{Polished: true}, + Granite{}, + MudBricks{}, + NetherBricks{Type: RedNetherBricks()}, + NetherBricks{}, + PolishedBlackstoneBrick{}, + PolishedTuff{}, + Purpur{}, + Quartz{Smooth: true}, + Quartz{}, + ResinBricks{}, + StoneBricks{Type: MossyStoneBricks()}, + StoneBricks{}, + Stone{Smooth: true}, + Stone{}, + Tuff{}, + TuffBricks{}, + } + for _, p := range PrismarineTypes() { + b = append(b, Prismarine{Type: p}) + } + for _, s := range SandstoneTypes() { + if s != ChiseledSandstone() { + b = append(b, Sandstone{Type: s}) + b = append(b, Sandstone{Type: s, Red: true}) + } + } + for _, w := range WoodTypes() { + b = append(b, Planks{Wood: w}) + } + for _, o := range OxidationTypes() { + b = append(b, Copper{Type: CutCopper(), Oxidation: o}) + b = append(b, Copper{Type: CutCopper(), Oxidation: o, Waxed: true}) + } + return b +} diff --git a/server/block/slime.go b/server/block/slime.go new file mode 100644 index 0000000..452f62c --- /dev/null +++ b/server/block/slime.go @@ -0,0 +1,50 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Slime is a storage block equivalent to nine slimeballs. It has both sticky and bouncy properties, +// making it useful in conjunction with pistons to move both blocks and entities. +type Slime struct { + solid + transparent +} + +// EntityLand ... +func (Slime) EntityLand(_ cube.Pos, _ *world.Tx, e world.Entity, distance *float64) { + if _, ok := e.(fallDistanceEntity); ok { + *distance = 0 + } + if s, ok := e.(interface{ Sneaking() bool }); ok && s.Sneaking() { + return + } + if v, ok := e.(velocityEntity); ok { + vel := v.Velocity() + if vel[1] < 0 { + vel[1] = -vel[1] + v.SetVelocity(vel) + } + } +} + +// Friction ... +func (Slime) Friction() float64 { + return 0.8 +} + +// BreakInfo ... +func (s Slime) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(s)) +} + +// EncodeItem ... +func (Slime) EncodeItem() (name string, meta int16) { + return "minecraft:slime", 0 +} + +// EncodeBlock ... +func (Slime) EncodeBlock() (string, map[string]any) { + return "minecraft:slime", nil +} diff --git a/server/block/smelter.go b/server/block/smelter.go new file mode 100644 index 0000000..684ca47 --- /dev/null +++ b/server/block/smelter.go @@ -0,0 +1,272 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/inventory" + "github.com/df-mc/dragonfly/server/world" + "math" + "math/rand/v2" + "sync" + "time" +) + +// smelter is a struct that may be embedded by blocks that can smelt blocks and items, such as blast furnaces, furnaces, +// and smokers. +type smelter struct { + mu sync.Mutex + + viewers map[ContainerViewer]struct{} + inventory *inventory.Inventory + + remainingDuration time.Duration + cookDuration time.Duration + maxDuration time.Duration + experience int +} + +// newSmelter initialises a new smelter with the given remaining, maximum, and cook durations and XP, and returns it. +func newSmelter() *smelter { + s := &smelter{viewers: make(map[ContainerViewer]struct{})} + s.inventory = inventory.New(3, func(slot int, _, item item.Stack) { + s.mu.Lock() + defer s.mu.Unlock() + for viewer := range s.viewers { + viewer.ViewSlotChange(slot, item) + } + }) + return s +} + +// InsertItem ... +func (s *smelter) InsertItem(h Hopper, pos cube.Pos, tx *world.Tx) bool { + for sourceSlot, sourceStack := range h.inventory.Slots() { + var slot int + + if sourceStack.Empty() { + continue + } + + if h.Facing != cube.FaceDown { + slot = 1 + } else { + slot = 0 + } + + stack := sourceStack.Grow(-sourceStack.Count() + 1) + it, _ := s.Inventory(tx, pos).Item(slot) + if slot == 1 { + if fuel, ok := sourceStack.Item().(item.Fuel); !ok || fuel.FuelInfo().Duration == 0 { + // The item is not fuel. + continue + } + } + if !sourceStack.Comparable(it) { + // The items are not the same. + continue + } + if it.Count() == it.MaxCount() { + // The item has the maximum count that the stack is able to hold. + continue + } + if !it.Empty() { + stack = it.Grow(1) + } + + _ = s.Inventory(tx, pos).SetItem(slot, stack) + _ = h.inventory.SetItem(sourceSlot, sourceStack.Grow(-1)) + return true + } + + return false +} + +// ExtractItem ... +func (s *smelter) ExtractItem(h Hopper, pos cube.Pos, tx *world.Tx) bool { + for sourceSlot, sourceStack := range s.inventory.Slots() { + if sourceStack.Empty() { + continue + } + + if sourceSlot == 0 { + continue + } + + if sourceSlot == 1 { + fuel, ok := sourceStack.Item().(item.Fuel) + if ok && fuel.FuelInfo().Duration.Seconds() != 0 { + continue + } + } + + _, err := h.inventory.AddItem(sourceStack.Grow(-sourceStack.Count() + 1)) + if err != nil { + // The hopper is full. + continue + } + + _ = s.Inventory(tx, pos).SetItem(sourceSlot, sourceStack.Grow(-1)) + return true + } + + return false +} + +// Durations returns the remaining, maximum, and cook durations of the smelter. +func (s *smelter) Durations() (remaining time.Duration, max time.Duration, cook time.Duration) { + s.mu.Lock() + defer s.mu.Unlock() + return s.remainingDuration, s.maxDuration, s.cookDuration +} + +// Experience returns the collected experience of the smelter. +func (s *smelter) Experience() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.experience +} + +// ResetExperience resets the collected experience of the smelter, and returns the amount of experience that was reset. +func (s *smelter) ResetExperience() int { + s.mu.Lock() + defer s.mu.Unlock() + xp := s.experience + s.experience = 0 + return xp +} + +// Inventory returns the inventory of the furnace. +func (s *smelter) Inventory(*world.Tx, cube.Pos) *inventory.Inventory { + return s.inventory +} + +// AddViewer adds a viewer to the furnace, so that it is updated whenever the inventory of the furnace is changed. +func (s *smelter) AddViewer(v ContainerViewer, _ *world.Tx, _ cube.Pos) { + s.mu.Lock() + defer s.mu.Unlock() + s.viewers[v] = struct{}{} +} + +// RemoveViewer removes a viewer from the furnace, so that slot updates in the inventory are no longer sent to +// it. +func (s *smelter) RemoveViewer(v ContainerViewer, _ *world.Tx, _ cube.Pos) { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.viewers) == 0 { + // No viewers. + return + } + delete(s.viewers, v) +} + +// setExperience sets the collected experience of the smelter to the given value. +func (s *smelter) setExperience(xp int) { + s.mu.Lock() + defer s.mu.Unlock() + s.experience = xp +} + +// setDurations sets the remaining, maximum, and cook durations of the smelter to the given values. +func (s *smelter) setDurations(remaining, max, cook time.Duration) { + s.mu.Lock() + defer s.mu.Unlock() + s.remainingDuration, s.maxDuration, s.cookDuration = remaining, max, cook +} + +// tickSmelting ticks the smelter, ensuring the necessary items exist in the furnace, and then processing all inputted +// items for the necessary duration. +func (s *smelter) tickSmelting(requirement, decrement time.Duration, lit bool, supported func(item.SmeltInfo) bool) bool { + s.mu.Lock() + + // First keep track of our past durations, since if any of them change, we need to be able to tell they did and then + // update the viewers on the change. + prevCookDuration := s.cookDuration + prevRemainingDuration := s.remainingDuration + prevMaxDuration := s.maxDuration + + // Now get each item in the smelter. We don't need to validate errors here since we know the bounds of the smelter. + input, _ := s.inventory.Item(0) + fuel, _ := s.inventory.Item(1) + product, _ := s.inventory.Item(2) + + // Initialise some default smelt info, and update it if we can smelt the item. + var inputInfo item.SmeltInfo + if i, ok := input.Item().(item.Smeltable); ok && supported(i.SmeltInfo()) { + inputInfo = i.SmeltInfo() + } + + // Initialise some default fuel info, and update it if it can be used as fuel. + var fuelInfo item.FuelInfo + if f, ok := fuel.Item().(item.Fuel); ok { + fuelInfo = f.FuelInfo() + if fuelInfo.Residue.Empty() { + // If we don't have a custom residue set, then we just decrement the fuel by one. + fuelInfo.Residue = fuel.Grow(-1) + } + } + + // Now we need to ensure that we can actually smelt the item. We need to ensure that we have at least one input, + // the input's product is compatible with the product already in the product slot, the product slot is not full, + // and that we have enough fuel to smelt the item. If all of these conditions are met, then we update the remaining + // duration and cook duration and create residue. + canSmelt := input.Count() > 0 && (inputInfo.Product.Comparable(product)) && !inputInfo.Product.Empty() && product.Count() < product.MaxCount() + if s.remainingDuration <= 0 && canSmelt && fuelInfo.Duration > 0 && fuel.Count() > 0 { + s.remainingDuration, s.maxDuration, lit = fuelInfo.Duration, fuelInfo.Duration, true + defer s.inventory.SetItem(1, fuelInfo.Residue) + } + + // Now we need to process a single stage of fuel loss. First, ensure that we have enough remaining duration. + if s.remainingDuration > 0 { + // Decrement a tick from the remaining fuel duration. + s.remainingDuration -= time.Millisecond * 50 + + // If we have a valid smeltable item, process a single stage of smelting. + switch { + case canSmelt: + // Increase the cook duration by a tick. + s.cookDuration += time.Millisecond * 50 + + // Check if we've cooked enough to match the requirement. + if s.cookDuration >= requirement { + // We can now create the product and reduce the input by one. + defer s.inventory.SetItem(0, input.Grow(-1)) + defer s.inventory.SetItem(2, item.NewStack(inputInfo.Product.Item(), product.Count()+inputInfo.Product.Count())) + + // Calculate the amount of experience to grant. Round the experience down to the nearest integer. + // The remaining XP is a chance to be granted an additional experience point. + xp := inputInfo.Experience * float64(inputInfo.Product.Count()) + earned := math.Floor(inputInfo.Experience) + if chance := xp - earned; chance > 0 && rand.Float64() < chance { + earned++ + } + + // Decrease the cook duration by the requirement, and update the smelter's stored experience. + s.cookDuration -= requirement + s.experience += int(earned) + } + case s.remainingDuration == 0: + // We've run out of fuel, so we need to reset the max duration too. + s.maxDuration = 0 + default: + // We still have some remaining fuel, but the input isn't smeltable, so we reset the cook duration. + s.cookDuration = 0 + } + } else { + // We don't have any more remaining duration, so we need to reset the max duration and put out the furnace. + s.maxDuration, lit = 0, false + } + + // We've run out of fuel, but we have some remaining cook duration, so instead of stopping entirely, we reduce the + // cook duration by the decrement. + if s.cookDuration > 0 && !lit { + s.cookDuration -= decrement + } + + // Update the viewers on the new durations. + for v := range s.viewers { + v.ViewFurnaceUpdate(prevCookDuration, s.cookDuration, prevRemainingDuration, s.remainingDuration, prevMaxDuration, s.maxDuration) + } + + s.mu.Unlock() + return lit +} diff --git a/server/block/smithing_table.go b/server/block/smithing_table.go new file mode 100644 index 0000000..3d190d5 --- /dev/null +++ b/server/block/smithing_table.go @@ -0,0 +1,38 @@ +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" +) + +// SmithingTable is a toolsmith's job site block that generates in villages. It can be used to upgrade diamond gear into +// netherite gear. +type SmithingTable struct { + bass + solid +} + +// EncodeItem ... +func (SmithingTable) EncodeItem() (name string, meta int16) { + return "minecraft:smithing_table", 0 +} + +// EncodeBlock ... +func (SmithingTable) EncodeBlock() (name string, properties map[string]interface{}) { + return "minecraft:smithing_table", nil +} + +// BreakInfo ... +func (s SmithingTable) BreakInfo() BreakInfo { + return newBreakInfo(2.5, alwaysHarvestable, axeEffective, oneOf(s)) +} + +// Activate ... +func (SmithingTable) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + if opener, ok := u.(ContainerOpener); ok { + opener.OpenBlockContainer(pos, tx) + return true + } + return false +} diff --git a/server/block/smoker.go b/server/block/smoker.go new file mode 100644 index 0000000..a461468 --- /dev/null +++ b/server/block/smoker.go @@ -0,0 +1,142 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "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/v2" + "time" +) + +// Smoker is a type of furnace that cooks food items, similar to a furnace, but twice as fast. It also serves as a +// butcher's job site block. +// The empty value of Smoker is not valid. It must be created using block.NewSmoker(cube.Face). +type Smoker struct { + solid + bassDrum + *smelter + + // Facing is the direction the smoker is facing. + Facing cube.Direction + // Lit is true if the smoker is lit. + Lit bool +} + +// NewSmoker creates a new initialised smoker. The smelter is properly initialised. +func NewSmoker(face cube.Direction) Smoker { + return Smoker{ + Facing: face, + smelter: newSmelter(), + } +} + +// Tick is called to check if the smoker should update and start or stop smelting. +func (s Smoker) Tick(_ int64, pos cube.Pos, tx *world.Tx) { + if s.Lit && rand.Float64() <= 0.016 { // Every three or so seconds. + tx.PlaySound(pos.Vec3Centre(), sound.SmokerCrackle{}) + } + if lit := s.tickSmelting(time.Second*5, time.Millisecond*200, s.Lit, func(i item.SmeltInfo) bool { + return i.Food + }); s.Lit != lit { + s.Lit = lit + tx.SetBlock(pos, s, nil) + } +} + +// LightEmissionLevel ... +func (s Smoker) LightEmissionLevel() uint8 { + if s.Lit { + return 13 + } + return 0 +} + +// EncodeItem ... +func (s Smoker) EncodeItem() (name string, meta int16) { + return "minecraft:smoker", 0 +} + +// EncodeBlock ... +func (s Smoker) EncodeBlock() (name string, properties map[string]interface{}) { + if s.Lit { + return "minecraft:lit_smoker", map[string]interface{}{"minecraft:cardinal_direction": s.Facing.String()} + } + return "minecraft:smoker", map[string]interface{}{"minecraft:cardinal_direction": s.Facing.String()} +} + +// UseOnBlock ... +func (s Smoker) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, s) + if !used { + return false + } + + place(tx, pos, NewSmoker(user.Rotation().Direction().Opposite()), user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (s Smoker) BreakInfo() BreakInfo { + xp := s.Experience() + return newBreakInfo(3.5, alwaysHarvestable, pickaxeEffective, oneOf(Smoker{})).withXPDropRange(xp, xp).withBreakHandler(func(pos cube.Pos, tx *world.Tx, u item.User) { + for _, i := range s.Inventory(tx, pos).Clear() { + dropItem(tx, i, pos.Vec3()) + } + }) +} + +// Activate ... +func (s Smoker) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + if opener, ok := u.(ContainerOpener); ok { + opener.OpenBlockContainer(pos, tx) + return true + } + return false +} + +// EncodeNBT ... +func (s Smoker) EncodeNBT() map[string]interface{} { + if s.smelter == nil { + //noinspection GoAssignmentToReceiver + s = NewSmoker(s.Facing) + } + remaining, maximum, cook := s.Durations() + return map[string]interface{}{ + "BurnTime": int16(remaining.Milliseconds() / 50), + "CookTime": int16(cook.Milliseconds() / 50), + "BurnDuration": int16(maximum.Milliseconds() / 50), + "StoredXPInt": int16(s.Experience()), + "Items": nbtconv.InvToNBT(s.inventory), + "id": "Smoker", + } +} + +// DecodeNBT ... +func (s Smoker) DecodeNBT(data map[string]interface{}) interface{} { + remaining := nbtconv.TickDuration[int16](data, "BurnTime") + maximum := nbtconv.TickDuration[int16](data, "BurnDuration") + cook := nbtconv.TickDuration[int16](data, "CookTime") + + xp := int(nbtconv.Int16(data, "StoredXPInt")) + lit := s.Lit + + //noinspection GoAssignmentToReceiver + s = NewSmoker(s.Facing) + s.Lit = lit + s.setExperience(xp) + s.setDurations(remaining, maximum, cook) + nbtconv.InvFromNBT(s.inventory, nbtconv.Slice(data, "Items")) + return s +} + +// allSmokers ... +func allSmokers() (smokers []world.Block) { + for _, face := range cube.Directions() { + smokers = append(smokers, Smoker{Facing: face}) + smokers = append(smokers, Smoker{Facing: face, Lit: true}) + } + return +} diff --git a/server/block/smooth_basalt.go b/server/block/smooth_basalt.go new file mode 100644 index 0000000..6f39d40 --- /dev/null +++ b/server/block/smooth_basalt.go @@ -0,0 +1,22 @@ +package block + +// SmoothBasalt is a decorative solid block obtained by smelting basalt. +type SmoothBasalt struct { + solid + bassDrum +} + +// EncodeBlock ... +func (SmoothBasalt) EncodeBlock() (string, map[string]any) { + return "minecraft:smooth_basalt", nil +} + +// EncodeItem ... +func (SmoothBasalt) EncodeItem() (name string, meta int16) { + return "minecraft:smooth_basalt", 0 +} + +// BreakInfo ... +func (s SmoothBasalt) BreakInfo() BreakInfo { + return newBreakInfo(1.25, pickaxeHarvestable, pickaxeEffective, oneOf(s)).withBlastResistance(21) +} diff --git a/server/block/snow.go b/server/block/snow.go new file mode 100644 index 0000000..af588b5 --- /dev/null +++ b/server/block/snow.go @@ -0,0 +1,23 @@ +package block + +import "github.com/df-mc/dragonfly/server/item" + +// Snow is a full-sized block of snow. +type Snow struct { + solid +} + +// BreakInfo ... +func (s Snow) BreakInfo() BreakInfo { + return newBreakInfo(0.2, alwaysHarvestable, shovelEffective, silkTouchDrop(item.NewStack(item.Snowball{}, 4), item.NewStack(s, 1))) +} + +// EncodeItem ... +func (Snow) EncodeItem() (name string, meta int16) { + return "minecraft:snow", 0 +} + +// EncodeBlock ... +func (Snow) EncodeBlock() (string, map[string]any) { + return "minecraft:snow", nil +} diff --git a/server/block/soul_sand.go b/server/block/soul_sand.go new file mode 100644 index 0000000..2d4e509 --- /dev/null +++ b/server/block/soul_sand.go @@ -0,0 +1,39 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" +) + +// SoulSand is a block found naturally only in the Nether. SoulSand slows movement of mobs & players. +type SoulSand struct { + solid +} + +// TODO: Implement bubble columns. + +// SoilFor ... +func (s SoulSand) SoilFor(block world.Block) bool { + flower, ok := block.(Flower) + return ok && flower.Type == WitherRose() +} + +// Instrument ... +func (s SoulSand) Instrument() sound.Instrument { + return sound.CowBell() +} + +// BreakInfo ... +func (s SoulSand) BreakInfo() BreakInfo { + return newBreakInfo(0.5, alwaysHarvestable, shovelEffective, oneOf(s)) +} + +// EncodeItem ... +func (SoulSand) EncodeItem() (name string, meta int16) { + return "minecraft:soul_sand", 0 +} + +// EncodeBlock ... +func (SoulSand) EncodeBlock() (string, map[string]any) { + return "minecraft:soul_sand", nil +} diff --git a/server/block/soul_soil.go b/server/block/soul_soil.go new file mode 100644 index 0000000..d87f7c9 --- /dev/null +++ b/server/block/soul_soil.go @@ -0,0 +1,29 @@ +package block + +import "github.com/df-mc/dragonfly/server/world" + +// SoulSoil is a block naturally found only in the soul sand valley. +type SoulSoil struct { + solid +} + +// SoilFor ... +func (s SoulSoil) SoilFor(block world.Block) bool { + _, ok := block.(NetherSprouts) + return ok +} + +// BreakInfo ... +func (s SoulSoil) BreakInfo() BreakInfo { + return newBreakInfo(0.5, alwaysHarvestable, shovelEffective, oneOf(s)) +} + +// EncodeItem ... +func (SoulSoil) EncodeItem() (name string, meta int16) { + return "minecraft:soul_soil", 0 +} + +// EncodeBlock ... +func (SoulSoil) EncodeBlock() (string, map[string]any) { + return "minecraft:soul_soil", nil +} diff --git a/server/block/sponge.go b/server/block/sponge.go new file mode 100644 index 0000000..491c363 --- /dev/null +++ b/server/block/sponge.go @@ -0,0 +1,130 @@ +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/df-mc/dragonfly/server/world/particle" + "github.com/go-gl/mathgl/mgl64" +) + +// Sponge is a block that can be used to remove water around itself when placed, turning into a wet sponge in the +// process. +type Sponge struct { + solid + + // Wet specifies whether the dry or the wet variant of the block is used. + Wet bool +} + +// BreakInfo ... +func (s Sponge) BreakInfo() BreakInfo { + return newBreakInfo(0.6, alwaysHarvestable, nothingEffective, oneOf(s)) +} + +// SmeltInfo ... +func (s Sponge) SmeltInfo() item.SmeltInfo { + if s.Wet { + return newSmeltInfo(item.NewStack(Sponge{}, 1), 0.15) + } + return item.SmeltInfo{} +} + +// EncodeItem ... +func (s Sponge) EncodeItem() (name string, meta int16) { + if s.Wet { + return "minecraft:wet_sponge", 0 + } + return "minecraft:sponge", 0 +} + +// EncodeBlock ... +func (s Sponge) EncodeBlock() (string, map[string]any) { + if s.Wet { + return "minecraft:wet_sponge", nil + } + return "minecraft:sponge", nil +} + +// UseOnBlock places the sponge, absorbs nearby water if it's still dry and flags it as wet if any water has been +// absorbed. +func (s Sponge) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + var particles = false + pos, _, used = firstReplaceable(tx, pos, face, s) + if !used { + return + } + + // Check if the Sponge is placed in the Nether and if so, turn it into a normal Sponge instantly. + if tx.World().Dimension().WaterEvaporates() && s.Wet { + s.Wet = false + particles = true + } + + place(tx, pos, s, user, ctx) + if particles && placed(ctx) { + tx.AddParticle(pos.Side(cube.FaceUp).Vec3(), particle.Evaporate{}) + } + return placed(ctx) +} + +// NeighbourUpdateTick checks for nearby water flow. If water could be found and the sponge is dry, it will absorb the +// water and be flagged as wet. +func (s Sponge) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + // The sponge is dry, so it can absorb nearby water. + if !s.Wet { + if s.absorbWater(pos, tx) > 0 { + // Water has been absorbed, so we flag the sponge as wet. + s.setWet(pos, tx) + } + } +} + +// setWet flags a sponge as wet. It replaces the block at pos by a wet sponge block and displays a block break +// particle at the sponge's position with an offset of 0.5 on each axis. +func (s Sponge) setWet(pos cube.Pos, tx *world.Tx) { + s.Wet = true + tx.SetBlock(pos, s, nil) + tx.AddParticle(pos.Vec3Centre(), particle.BlockBreak{Block: Water{Depth: 1}}) +} + +// absorbWater replaces water blocks near the sponge by air out to a taxicab geometry of 7 in all directions. +// The maximum for absorbed blocks is 65. +// The returned int specifies the amount of replaced water blocks. +func (s Sponge) absorbWater(pos cube.Pos, tx *world.Tx) int { + // distanceToSponge binds a world.Position to its distance from the sponge's position. + type distanceToSponge struct { + block cube.Pos + distance int32 + } + + queue := make([]distanceToSponge, 0) + queue = append(queue, distanceToSponge{pos, 0}) + + // A sponge can only absorb up to 65 water blocks. + replaced := 0 + for replaced < 65 { + if len(queue) == 0 { + break + } + + // Pop the next distanceToSponge entry from the queue. + next := queue[0] + queue = queue[1:] + + next.block.Neighbours(func(neighbour cube.Pos) { + liquid, found := tx.Liquid(neighbour) + if found { + if _, isWater := liquid.(Water); isWater { + tx.SetLiquid(neighbour, nil) + replaced++ + if next.distance < 7 { + queue = append(queue, distanceToSponge{neighbour, next.distance + 1}) + } + } + } + }, tx.Range()) + } + + return replaced +} diff --git a/server/block/spore_blossom.go b/server/block/spore_blossom.go new file mode 100644 index 0000000..5e00f50 --- /dev/null +++ b/server/block/spore_blossom.go @@ -0,0 +1,65 @@ +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" +) + +// SporeBlossom is a decorative block. +type SporeBlossom struct { + empty + transparent +} + +// HasLiquidDrops ... +func (s SporeBlossom) HasLiquidDrops() bool { + return true +} + +// NeighbourUpdateTick ... +func (s SporeBlossom) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if !tx.Block(pos.Side(cube.FaceUp)).Model().FaceSolid(pos.Side(cube.FaceUp), cube.FaceDown, tx) { + breakBlock(s, pos, tx) + } +} + +// UseOnBlock ... +func (s SporeBlossom) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, s) + if !used { + return + } + if !tx.Block(pos.Side(cube.FaceUp)).Model().FaceSolid(pos.Side(cube.FaceUp), cube.FaceDown, tx) { + return + } + + place(tx, pos, s, user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (s SporeBlossom) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(s)) +} + +// FlammabilityInfo ... +func (SporeBlossom) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(15, 100, true) +} + +// CompostChance ... +func (SporeBlossom) CompostChance() float64 { + return 0.65 +} + +// EncodeItem ... +func (s SporeBlossom) EncodeItem() (name string, meta int16) { + return "minecraft:spore_blossom", 0 +} + +// EncodeBlock ... +func (s SporeBlossom) EncodeBlock() (string, map[string]any) { + return "minecraft:spore_blossom", nil +} diff --git a/server/block/stained_glass.go b/server/block/stained_glass.go new file mode 100644 index 0000000..cdbc941 --- /dev/null +++ b/server/block/stained_glass.go @@ -0,0 +1,40 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// StainedGlass is a decorative, fully transparent solid block that is dyed into a different colour. +type StainedGlass struct { + transparent + solid + clicksAndSticks + + // Colour specifies the colour of the block. + Colour item.Colour +} + +// BreakInfo ... +func (g StainedGlass) BreakInfo() BreakInfo { + return newBreakInfo(0.3, alwaysHarvestable, nothingEffective, silkTouchOnlyDrop(g)) +} + +// EncodeItem ... +func (g StainedGlass) EncodeItem() (name string, meta int16) { + return "minecraft:" + g.Colour.String() + "_stained_glass", 0 +} + +// EncodeBlock ... +func (g StainedGlass) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:" + g.Colour.String() + "_stained_glass", nil +} + +// allStainedGlass returns stained-glass blocks with all possible colours. +func allStainedGlass() []world.Block { + b := make([]world.Block, 0, 16) + for _, c := range item.Colours() { + b = append(b, StainedGlass{Colour: c}) + } + return b +} diff --git a/server/block/stained_glass_pane.go b/server/block/stained_glass_pane.go new file mode 100644 index 0000000..919d252 --- /dev/null +++ b/server/block/stained_glass_pane.go @@ -0,0 +1,47 @@ +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" +) + +// StainedGlassPane is a transparent block that can be used as a more efficient alternative to glass blocks. +type StainedGlassPane struct { + transparent + thin + clicksAndSticks + sourceWaterDisplacer + + // Colour specifies the colour of the block. + Colour item.Colour +} + +// SideClosed ... +func (p StainedGlassPane) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// BreakInfo ... +func (p StainedGlassPane) BreakInfo() BreakInfo { + return newBreakInfo(0.3, alwaysHarvestable, nothingEffective, silkTouchOnlyDrop(p)) +} + +// EncodeItem ... +func (p StainedGlassPane) EncodeItem() (name string, meta int16) { + return "minecraft:" + p.Colour.String() + "_stained_glass_pane", 0 +} + +// EncodeBlock ... +func (p StainedGlassPane) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:" + p.Colour.String() + "_stained_glass_pane", nil +} + +// allStainedGlassPane returns stained-glass panes with all possible colours. +func allStainedGlassPane() []world.Block { + b := make([]world.Block, 0, 16) + for _, c := range item.Colours() { + b = append(b, StainedGlassPane{Colour: c}) + } + return b +} diff --git a/server/block/stained_terracotta.go b/server/block/stained_terracotta.go new file mode 100644 index 0000000..c1ae9c3 --- /dev/null +++ b/server/block/stained_terracotta.go @@ -0,0 +1,51 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// StainedTerracotta is a block formed from clay, with a hardness and blast resistance comparable to stone. In contrast +// to Terracotta, t can be coloured in the same 16 colours that wool can be dyed, but more dulled and earthen. +type StainedTerracotta struct { + solid + bassDrum + + // Colour specifies the colour of the block. + Colour item.Colour +} + +// SoilFor ... +func (t StainedTerracotta) SoilFor(block world.Block) bool { + _, ok := block.(DeadBush) + return ok +} + +// BreakInfo ... +func (t StainedTerracotta) BreakInfo() BreakInfo { + return newBreakInfo(1.25, pickaxeHarvestable, pickaxeEffective, oneOf(t)).withBlastResistance(21) +} + +// SmeltInfo ... +func (t StainedTerracotta) SmeltInfo() item.SmeltInfo { + return newSmeltInfo(item.NewStack(GlazedTerracotta{Colour: t.Colour}, 1), 0.1) +} + +// EncodeItem ... +func (t StainedTerracotta) EncodeItem() (name string, meta int16) { + return "minecraft:" + t.Colour.String() + "_terracotta", 0 +} + +// EncodeBlock ... +func (t StainedTerracotta) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:" + t.Colour.String() + "_terracotta", nil +} + +// allStainedTerracotta returns stained terracotta blocks with all possible colours. +func allStainedTerracotta() []world.Block { + b := make([]world.Block, 0, 16) + for _, c := range item.Colours() { + b = append(b, StainedTerracotta{Colour: c}) + } + return b +} diff --git a/server/block/stairs.go b/server/block/stairs.go new file mode 100644 index 0000000..ff0e482 --- /dev/null +++ b/server/block/stairs.go @@ -0,0 +1,117 @@ +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/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" +) + +// Stairs are blocks that allow entities to walk up blocks without jumping. +type Stairs struct { + transparent + sourceWaterDisplacer + + // Block is the block to use for the type of stair. + Block world.Block + // UpsideDown specifies if the stairs are upside down. If set to true, the full side is at the top part + // of the block. + UpsideDown bool + // Facing is the direction that the full side of the stairs is facing. + Facing cube.Direction +} + +// UseOnBlock handles the directional placing of stairs and makes sure they are properly placed upside down +// when needed. +func (s Stairs) UseOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, face, used = firstReplaceable(tx, pos, face, s) + if !used { + return + } + s.Facing = user.Rotation().Direction() + if face == cube.FaceDown || (clickPos[1] > 0.5 && face != cube.FaceUp) { + s.UpsideDown = true + } + + place(tx, pos, s, user, ctx) + return placed(ctx) +} + +// Model ... +func (s Stairs) Model() world.BlockModel { + return model.Stair{Facing: s.Facing, UpsideDown: s.UpsideDown} +} + +// BreakInfo ... +func (s Stairs) BreakInfo() BreakInfo { + breakInfo := s.Block.(Breakable).BreakInfo() + return newBreakInfo(breakInfo.Hardness, breakInfo.Harvestable, breakInfo.Effective, oneOf(s)).withBlastResistance(breakInfo.BlastResistance) +} + +// Instrument ... +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() +} + +// FlammabilityInfo ... +func (s Stairs) FlammabilityInfo() FlammabilityInfo { + if flammable, ok := s.Block.(Flammable); ok { + return flammable.FlammabilityInfo() + } + return newFlammabilityInfo(0, 0, false) +} + +// FuelInfo ... +func (s Stairs) FuelInfo() item.FuelInfo { + if fuel, ok := s.Block.(item.Fuel); ok { + return fuel.FuelInfo() + } + return item.FuelInfo{} +} + +// EncodeItem ... +func (s Stairs) EncodeItem() (name string, meta int16) { + return "minecraft:" + encodeStairsBlock(s.Block) + "_stairs", 0 +} + +// EncodeBlock ... +func (s Stairs) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:" + encodeStairsBlock(s.Block) + "_stairs", map[string]any{"upside_down_bit": s.UpsideDown, "weirdo_direction": toStairsDirection(s.Facing)} +} + +// toStairDirection converts a facing to a stair's direction for Minecraft. +func toStairsDirection(v cube.Direction) int32 { + return int32(3 - v) +} + +// SideClosed ... +func (s Stairs) SideClosed(pos, side cube.Pos, tx *world.Tx) bool { + return s.Model().FaceSolid(pos, pos.Face(side), tx) +} + +// CanRedstoneWireStepDown ... +func (Stairs) CanRedstoneWireStepDown(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// allStairs returns all states of stairs. +func allStairs() (stairs []world.Block) { + f := func(facing cube.Direction, upsideDown bool) { + for _, s := range StairsBlocks() { + stairs = append(stairs, Stairs{Facing: facing, UpsideDown: upsideDown, Block: s}) + } + } + for i := cube.Direction(0); i <= 3; i++ { + f(i, true) + f(i, false) + } + return +} diff --git a/server/block/stairs_type.go b/server/block/stairs_type.go new file mode 100644 index 0000000..a2b1e8d --- /dev/null +++ b/server/block/stairs_type.go @@ -0,0 +1,188 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/world" +) + +// encodeStairsBlock encodes the provided block in to an identifier and meta value that can be used to encode the stairs. +func encodeStairsBlock(block world.Block) string { + switch block := block.(type) { + case Andesite: + if block.Polished { + return "polished_andesite" + } + return "andesite" + case Blackstone: + if block.Type == NormalBlackstone() { + return "blackstone" + } else if block.Type == PolishedBlackstone() { + return "polished_blackstone" + } + case BambooMosaic: + return "bamboo_mosaic" + case Bricks: + return "brick" + case Cobblestone: + if block.Mossy { + return "mossy_cobblestone" + } + return "stone" + case Copper: + if block.Type == CutCopper() { + name := "cut_copper" + if block.Oxidation != UnoxidisedOxidation() { + name = block.Oxidation.String() + "_" + name + } + if block.Waxed { + name = "waxed_" + name + } + return name + } + case Deepslate: + if block.Type == CobbledDeepslate() { + return "cobbled_deepslate" + } else if block.Type == PolishedDeepslate() { + return "polished_deepslate" + } + case DeepslateBricks: + if !block.Cracked { + return "deepslate_brick" + } + case DeepslateTiles: + if !block.Cracked { + return "deepslate_tile" + } + case Diorite: + if block.Polished { + return "polished_diorite" + } + return "diorite" + case EndBricks: + return "end_brick" + case Granite: + if block.Polished { + return "polished_granite" + } + return "granite" + case MudBricks: + return "mud_brick" + case NetherBricks: + if block.Type == RedNetherBricks() { + return "nether_brick" + } + return "red_nether_brick" + case Planks: + return block.Wood.String() + case PolishedBlackstoneBrick: + if !block.Cracked { + return "polished_blackstone_brick" + } + case PolishedTuff: + return "polished_tuff" + case Prismarine: + switch block.Type { + case NormalPrismarine(): + return "prismarine" + case DarkPrismarine(): + return "dark_prismarine" + case BrickPrismarine(): + return "prismarine_bricks" + } + panic("invalid prismarine type") + case Purpur: + return "purpur" + case Quartz: + if block.Smooth { + return "smooth_quartz" + } + return "quartz" + case ResinBricks: + return "resin_brick" + case Sandstone: + switch block.Type { + case NormalSandstone(): + if block.Red { + return "red_sandstone" + } + return "sandstone" + case SmoothSandstone(): + if block.Red { + return "smooth_red_sandstone" + } + return "smooth_sandstone" + } + panic("invalid sandstone type") + case Stone: + if !block.Smooth { + return "normal_stone" + } + case StoneBricks: + if block.Type == MossyStoneBricks() { + return "mossy_stone_brick" + } + return "stone_brick" + case Tuff: + if !block.Chiseled { + return "tuff" + } + case TuffBricks: + if !block.Chiseled { + return "tuff_brick" + } + } + panic("invalid block used for stairs") +} + +// StairsBlocks returns a list of all possible blocks for stairs. +func StairsBlocks() []world.Block { + b := []world.Block{ + Andesite{Polished: true}, + Andesite{}, + BambooMosaic{}, + Blackstone{Type: PolishedBlackstone()}, + Blackstone{}, + Bricks{}, + Cobblestone{Mossy: true}, + Cobblestone{}, + DeepslateBricks{}, + DeepslateTiles{}, + Deepslate{Type: CobbledDeepslate()}, + Deepslate{Type: PolishedDeepslate()}, + Diorite{Polished: true}, + Diorite{}, + EndBricks{}, + Granite{Polished: true}, + Granite{}, + MudBricks{}, + NetherBricks{Type: RedNetherBricks()}, + NetherBricks{}, + PolishedBlackstoneBrick{}, + PolishedTuff{}, + Purpur{}, + Quartz{Smooth: true}, + Quartz{}, + ResinBricks{}, + StoneBricks{Type: MossyStoneBricks()}, + StoneBricks{}, + Stone{}, + Tuff{}, + TuffBricks{}, + } + for _, p := range PrismarineTypes() { + b = append(b, Prismarine{Type: p}) + } + for _, s := range SandstoneTypes() { + if s != CutSandstone() && s != ChiseledSandstone() { + b = append(b, Sandstone{Type: s}) + b = append(b, Sandstone{Type: s, Red: true}) + } + } + for _, w := range WoodTypes() { + b = append(b, Planks{Wood: w}) + } + for _, o := range OxidationTypes() { + b = append(b, Copper{Type: CutCopper(), Oxidation: o}) + b = append(b, Copper{Type: CutCopper(), Oxidation: o, Waxed: true}) + } + return b +} diff --git a/server/block/stone.go b/server/block/stone.go new file mode 100644 index 0000000..3727132 --- /dev/null +++ b/server/block/stone.go @@ -0,0 +1,125 @@ +package block + +import "github.com/df-mc/dragonfly/server/item" + +type ( + // Stone is a block found underground in the world or on mountains. + Stone struct { + solid + bassDrum + + // Smooth specifies if the stone is its smooth variant. + Smooth bool + } + + // Granite is a type of igneous rock. + Granite polishable + // Diorite is a type of igneous rock. + Diorite polishable + // Andesite is a type of igneous rock. + Andesite polishable + + // polishable forms the base of blocks that may be polished. + polishable struct { + solid + bassDrum + // Polished specifies if the block is polished or not. When set to true, the block will represent its + // polished variant, for example polished andesite. + Polished bool + } +) + +// BreakInfo ... +func (s Stone) BreakInfo() BreakInfo { + if s.Smooth { + return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(s)).withBlastResistance(30) + } + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, silkTouchOneOf(Cobblestone{}, Stone{})).withBlastResistance(30) +} + +// BreakInfo ... +func (g Granite) BreakInfo() BreakInfo { + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(g)).withBlastResistance(30) +} + +// BreakInfo ... +func (d Diorite) BreakInfo() BreakInfo { + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(d)).withBlastResistance(30) +} + +// BreakInfo ... +func (a Andesite) BreakInfo() BreakInfo { + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(a)).withBlastResistance(30) +} + +// SmeltInfo ... +func (s Stone) SmeltInfo() item.SmeltInfo { + if s.Smooth { + return item.SmeltInfo{} + } + return newSmeltInfo(item.NewStack(Stone{Smooth: true}, 1), 0.1) +} + +// EncodeItem ... +func (s Stone) EncodeItem() (name string, meta int16) { + if s.Smooth { + return "minecraft:smooth_stone", 0 + } + return "minecraft:stone", 0 +} + +// EncodeBlock ... +func (s Stone) EncodeBlock() (string, map[string]any) { + if s.Smooth { + return "minecraft:smooth_stone", nil + } + return "minecraft:stone", nil +} + +// EncodeItem ... +func (a Andesite) EncodeItem() (name string, meta int16) { + if a.Polished { + return "minecraft:polished_andesite", 0 + } + return "minecraft:andesite", 0 +} + +// EncodeBlock ... +func (a Andesite) EncodeBlock() (string, map[string]any) { + if a.Polished { + return "minecraft:polished_andesite", nil + } + return "minecraft:andesite", nil +} + +// EncodeItem ... +func (d Diorite) EncodeItem() (name string, meta int16) { + if d.Polished { + return "minecraft:polished_diorite", 0 + } + return "minecraft:diorite", 0 +} + +// EncodeBlock ... +func (d Diorite) EncodeBlock() (string, map[string]any) { + if d.Polished { + return "minecraft:polished_diorite", nil + } + return "minecraft:diorite", nil +} + +// EncodeItem ... +func (g Granite) EncodeItem() (name string, meta int16) { + if g.Polished { + return "minecraft:polished_granite", 0 + } + return "minecraft:granite", 0 +} + +// EncodeBlock ... +func (g Granite) EncodeBlock() (string, map[string]any) { + if g.Polished { + return "minecraft:polished_granite", nil + } + return "minecraft:granite", nil +} diff --git a/server/block/stone_bricks.go b/server/block/stone_bricks.go new file mode 100644 index 0000000..0e043ce --- /dev/null +++ b/server/block/stone_bricks.go @@ -0,0 +1,47 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// StoneBricks are materials found in structures such as strongholds, igloo basements, jungle temples, ocean ruins +// and ruined portals. +type StoneBricks struct { + solid + bassDrum + + // Type is the type of stone bricks of the block. + Type StoneBricksType +} + +// BreakInfo ... +func (s StoneBricks) BreakInfo() BreakInfo { + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(s)).withBlastResistance(30) +} + +// SmeltInfo ... +func (s StoneBricks) SmeltInfo() item.SmeltInfo { + if s.Type == NormalStoneBricks() { + return newSmeltInfo(item.NewStack(StoneBricks{Type: CrackedStoneBricks()}, 1), 0.1) + } + return item.SmeltInfo{} +} + +// EncodeItem ... +func (s StoneBricks) EncodeItem() (name string, meta int16) { + return "minecraft:" + s.Type.String(), 0 +} + +// EncodeBlock ... +func (s StoneBricks) EncodeBlock() (string, map[string]any) { + return "minecraft:" + s.Type.String(), nil +} + +// allStoneBricks returns a list of all stoneBricks block variants. +func allStoneBricks() (s []world.Block) { + for _, t := range StoneBricksTypes() { + s = append(s, StoneBricks{Type: t}) + } + return +} diff --git a/server/block/stone_bricks_type.go b/server/block/stone_bricks_type.go new file mode 100644 index 0000000..a831fe5 --- /dev/null +++ b/server/block/stone_bricks_type.go @@ -0,0 +1,68 @@ +package block + +// StoneBricksType represents a type of stone bricks. +type StoneBricksType struct { + stoneBricks +} + +type stoneBricks uint8 + +// NormalStoneBricks is the normal variant of stone bricks. +func NormalStoneBricks() StoneBricksType { + return StoneBricksType{0} +} + +// MossyStoneBricks is the mossy variant of stone bricks. +func MossyStoneBricks() StoneBricksType { + return StoneBricksType{1} +} + +// CrackedStoneBricks is the cracked variant of stone bricks. +func CrackedStoneBricks() StoneBricksType { + return StoneBricksType{2} +} + +// ChiseledStoneBricks is the chiseled variant of stone bricks. +func ChiseledStoneBricks() StoneBricksType { + return StoneBricksType{3} +} + +// Uint8 returns the stone bricks as a uint8. +func (s stoneBricks) Uint8() uint8 { + return uint8(s) +} + +// Name ... +func (s stoneBricks) Name() string { + switch s { + case 0: + return "Stone Bricks" + case 1: + return "Mossy Stone Bricks" + case 2: + return "Cracked Stone Bricks" + case 3: + return "Chiseled Stone Bricks" + } + panic("unknown stone bricks type") +} + +// String ... +func (s stoneBricks) String() string { + switch s { + case 0: + return "stone_bricks" + case 1: + return "mossy_stone_bricks" + case 2: + return "cracked_stone_bricks" + case 3: + return "chiseled_stone_bricks" + } + panic("unknown stone bricks type") +} + +// StoneBricksTypes ... +func StoneBricksTypes() []StoneBricksType { + return []StoneBricksType{NormalStoneBricks(), MossyStoneBricks(), CrackedStoneBricks(), ChiseledStoneBricks()} +} diff --git a/server/block/stonecutter.go b/server/block/stonecutter.go new file mode 100644 index 0000000..641279a --- /dev/null +++ b/server/block/stonecutter.go @@ -0,0 +1,66 @@ +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/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// Stonecutter is used to craft stone and copper related blocks in smaller and more precise quantities than crafting, +// and is more efficient than crafting for certain recipes. +type Stonecutter struct { + bassDrum + + // Facing is the direction the stonecutter is facing. + Facing cube.Direction +} + +// Model ... +func (Stonecutter) Model() world.BlockModel { + return model.Stonecutter{} +} + +// BreakInfo ... +func (s Stonecutter) BreakInfo() BreakInfo { + return newBreakInfo(3.5, pickaxeHarvestable, pickaxeEffective, oneOf(s)) +} + +// Activate ... +func (Stonecutter) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + if opener, ok := u.(ContainerOpener); ok { + opener.OpenBlockContainer(pos, tx) + return true + } + return false +} + +// UseOnBlock ... +func (s Stonecutter) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, s) + if !used { + return + } + s.Facing = user.Rotation().Direction().Opposite() + place(tx, pos, s, user, ctx) + return placed(ctx) +} + +// EncodeItem ... +func (Stonecutter) EncodeItem() (name string, meta int16) { + return "minecraft:stonecutter_block", 0 +} + +// EncodeBlock ... +func (s Stonecutter) EncodeBlock() (name string, properties map[string]interface{}) { + return "minecraft:stonecutter_block", map[string]any{"minecraft:cardinal_direction": s.Facing.String()} +} + +// allStonecutters ... +func allStonecutters() (stonecutters []world.Block) { + for _, d := range cube.Directions() { + stonecutters = append(stonecutters, Stonecutter{Facing: d}) + } + return +} diff --git a/server/block/string.go b/server/block/string.go new file mode 100644 index 0000000..97769d0 --- /dev/null +++ b/server/block/string.go @@ -0,0 +1,85 @@ +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" +) + +// String is an item obtained from spiders and cobwebs. When placed, it creates a tripwire that +// detects entities passing through it. +// TODO: Redstone functionality (entity detection, tripwire hook circuits, shears-disarm propagation). +type String struct { + empty + transparent + + // Attached is true if the tripwire is connected to valid tripwire hooks on both sides. + Attached bool + // Disarmed is true if the tripwire was cut using shears, preventing it from activating. + Disarmed bool + // Powered is true if the tripwire is currently activated by an entity passing through it. + Powered bool + // Suspended is true if the tripwire is not resting on a solid surface. + Suspended bool +} + +// UseOnBlock places the string as a tripwire on the target surface. +func (s String) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, canPlace := firstReplaceable(tx, pos, face, s) + if !canPlace { + return false + } + below := pos.Side(cube.FaceDown) + s.Suspended = !tx.Block(below).Model().FaceSolid(below, cube.FaceUp, tx) + place(tx, pos, s, user, ctx) + return placed(ctx) +} + +// NeighbourUpdateTick ... +func (s String) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + below := pos.Side(cube.FaceDown) + suspended := !tx.Block(below).Model().FaceSolid(below, cube.FaceUp, tx) + if suspended != s.Suspended { + s.Suspended = suspended + tx.SetBlock(pos, s, nil) + } +} + +// HasLiquidDrops ... +func (s String) HasLiquidDrops() bool { + return true +} + +// BreakInfo ... +func (s String) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(String{})) +} + +// EncodeItem ... +func (s String) EncodeItem() (name string, meta int16) { + return "minecraft:string", 0 +} + +// EncodeBlock ... +func (s String) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:trip_wire", map[string]any{ + "attached_bit": boolByte(s.Attached), + "disarmed_bit": boolByte(s.Disarmed), + "powered_bit": boolByte(s.Powered), + "suspended_bit": boolByte(s.Suspended), + } +} + +// allString ... +func allString() (blocks []world.Block) { + for meta := 0; meta < 16; meta++ { + blocks = append(blocks, String{ + Powered: meta&0x1 != 0, + Suspended: meta&0x2 != 0, + Attached: meta&0x4 != 0, + Disarmed: meta&0x8 != 0, + }) + } + return blocks +} diff --git a/server/block/sugar_cane.go b/server/block/sugar_cane.go new file mode 100644 index 0000000..0f7b467 --- /dev/null +++ b/server/block/sugar_cane.go @@ -0,0 +1,126 @@ +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" +) + +// SugarCane is a plant block that generates naturally near water. +type SugarCane struct { + empty + transparent + + // Age is the growth state of sugar cane. Values range from 0 to 15. + Age int +} + +// UseOnBlock ensures the placement of the block is OK. +func (c SugarCane) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, c) + if !used { + return false + } + if !c.canGrowHere(pos, tx, true) { + return false + } + + place(tx, pos, c, user, ctx) + return placed(ctx) +} + +// NeighbourUpdateTick ... +func (c SugarCane) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if !c.canGrowHere(pos, tx, true) { + breakBlock(c, pos, tx) + } +} + +// RandomTick ... +func (c SugarCane) RandomTick(pos cube.Pos, tx *world.Tx, _ *rand.Rand) { + if !c.canGrowHere(pos, tx, true) { + breakBlock(c, pos, tx) + return + } + if c.Age < 15 { + c.Age++ + } else if c.Age == 15 { + c.Age = 0 + if c.canGrowHere(pos.Side(cube.FaceDown), tx, false) { + for y := 1; y < 3; y++ { + if _, ok := tx.Block(pos.Add(cube.Pos{0, y})).(Air); ok { + tx.SetBlock(pos.Add(cube.Pos{0, y}), SugarCane{}, nil) + break + } else if _, ok := tx.Block(pos.Add(cube.Pos{0, y})).(SugarCane); !ok { + break + } + } + } + } + tx.SetBlock(pos, c, nil) +} + +// BoneMeal ... +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) + } + if c.canGrowHere(pos.Side(cube.FaceDown), tx, false) { + for y := 1; y < 3; y++ { + if _, ok := tx.Block(pos.Add(cube.Pos{0, y})).(Air); ok { + tx.SetBlock(pos.Add(cube.Pos{0, y}), SugarCane{}, nil) + } + } + return item.BoneMealResultSmall + } + return item.BoneMealResultNone +} + +// canGrowHere implements logic to check if sugar cane can live/grow here. +func (c SugarCane) canGrowHere(pos cube.Pos, tx *world.Tx, recursive bool) bool { + if _, ok := tx.Block(pos.Side(cube.FaceDown)).(SugarCane); ok && recursive { + return c.canGrowHere(pos.Side(cube.FaceDown), tx, recursive) + } + + if supportsVegetation(c, tx.Block(pos.Sub(cube.Pos{0, 1}))) { + for _, face := range cube.HorizontalFaces() { + if liquid, ok := tx.Liquid(pos.Side(face).Side(cube.FaceDown)); ok { + if _, ok := liquid.(Water); ok { + return true + } + } + } + } + return false +} + +// HasLiquidDrops ... +func (c SugarCane) HasLiquidDrops() bool { + return true +} + +// BreakInfo ... +func (c SugarCane) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(c)) +} + +// EncodeItem ... +func (c SugarCane) EncodeItem() (name string, meta int16) { + return "minecraft:sugar_cane", 0 +} + +// EncodeBlock ... +func (c SugarCane) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:reeds", map[string]any{"age": int32(c.Age)} +} + +// allSugarCane returns all possible states of a sugar cane block. +func allSugarCane() (b []world.Block) { + for i := 0; i < 16; i++ { + b = append(b, SugarCane{Age: i}) + } + return +} diff --git a/server/block/terracotta.go b/server/block/terracotta.go new file mode 100644 index 0000000..0490068 --- /dev/null +++ b/server/block/terracotta.go @@ -0,0 +1,31 @@ +package block + +import "github.com/df-mc/dragonfly/server/world" + +// Terracotta is a block formed from clay, with a hardness and blast resistance comparable to stone. For colouring it, +// take a look at StainedTerracotta. +type Terracotta struct { + solid + bassDrum +} + +// SoilFor ... +func (Terracotta) SoilFor(block world.Block) bool { + _, ok := block.(DeadBush) + return ok +} + +// BreakInfo ... +func (t Terracotta) BreakInfo() BreakInfo { + return newBreakInfo(1.25, pickaxeHarvestable, pickaxeEffective, oneOf(t)).withBlastResistance(21) +} + +// EncodeItem ... +func (Terracotta) EncodeItem() (name string, meta int16) { + return "minecraft:hardened_clay", meta +} + +// EncodeBlock ... +func (Terracotta) EncodeBlock() (string, map[string]any) { + return "minecraft:hardened_clay", nil +} diff --git a/server/block/tnt.go b/server/block/tnt.go new file mode 100644 index 0000000..4606f45 --- /dev/null +++ b/server/block/tnt.go @@ -0,0 +1,74 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/enchantment" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" + "math/rand/v2" + "time" +) + +// TNT is an explosive block that can be primed to generate an explosion. +type TNT struct { + solid +} + +// ProjectileHit ... +func (t TNT) ProjectileHit(pos cube.Pos, tx *world.Tx, e world.Entity, _ cube.Face) { + if f, ok := e.(flammableEntity); ok && f.OnFireDuration() > 0 { + t.Ignite(pos, tx, nil) + } +} + +// Activate ... +func (t TNT) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, ctx *item.UseContext) bool { + held, _ := u.HeldItems() + if _, ok := held.Enchantment(enchantment.FireAspect); ok { + t.Ignite(pos, tx, nil) + ctx.DamageItem(1) + return true + } + return false +} + +// Ignite ... +func (t TNT) Ignite(pos cube.Pos, tx *world.Tx, _ world.Entity) bool { + spawnTnt(pos, tx, time.Second*4) + return true +} + +// Explode ... +func (t TNT) Explode(_ mgl64.Vec3, pos cube.Pos, tx *world.Tx, _ ExplosionConfig) { + spawnTnt(pos, tx, time.Second/2+time.Duration(rand.IntN(int(time.Second+time.Second/2)))) +} + +// BreakInfo ... +func (t TNT) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(t)) +} + +// FlammabilityInfo ... +func (t TNT) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(15, 100, true) +} + +// EncodeItem ... +func (t TNT) EncodeItem() (name string, meta int16) { + return "minecraft:tnt", 0 +} + +// EncodeBlock ... +func (t TNT) EncodeBlock() (name string, properties map[string]interface{}) { + return "minecraft:tnt", map[string]interface{}{"explode_bit": false} +} + +// spawnTnt creates a new TNT entity at the given position with the given fuse duration. +func spawnTnt(pos cube.Pos, tx *world.Tx, fuse time.Duration) { + tx.PlaySound(pos.Vec3Centre(), sound.TNT{}) + tx.SetBlock(pos, nil, nil) + opts := world.EntitySpawnOpts{Position: pos.Vec3Centre()} + tx.AddEntity(tx.World().EntityRegistry().Config().TNT(opts, fuse)) +} diff --git a/server/block/torch.go b/server/block/torch.go new file mode 100644 index 0000000..9d9f783 --- /dev/null +++ b/server/block/torch.go @@ -0,0 +1,118 @@ +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" +) + +// Torch are non-solid blocks that emit light. +type Torch struct { + transparent + empty + + // Facing is the direction from the torch to the block. + Facing cube.Face + // Type is the type of fire lighting the torch. + Type FireType +} + +// BreakInfo ... +func (t Torch) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(t)) +} + +// LightEmissionLevel ... +func (t Torch) LightEmissionLevel() uint8 { + switch t.Type { + case NormalFire(): + return 14 + default: + return t.Type.LightLevel() + } +} + +// UseOnBlock ... +func (t Torch) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, face, used := firstReplaceable(tx, pos, face, t) + if !used { + return false + } + if face == cube.FaceDown { + return false + } + if !tx.Block(pos.Side(face.Opposite())).Model().FaceSolid(pos.Side(face.Opposite()), face, tx) { + found := false + for _, i := range []cube.Face{cube.FaceSouth, cube.FaceWest, cube.FaceNorth, cube.FaceEast, cube.FaceDown} { + if tx.Block(pos.Side(i)).Model().FaceSolid(pos.Side(i), i.Opposite(), tx) { + found = true + face = i.Opposite() + break + } + } + if !found { + return false + } + } + t.Facing = face.Opposite() + + place(tx, pos, t, user, ctx) + return placed(ctx) +} + +// NeighbourUpdateTick ... +func (t Torch) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if !tx.Block(pos.Side(t.Facing)).Model().FaceSolid(pos.Side(t.Facing), t.Facing.Opposite(), tx) { + breakBlock(t, pos, tx) + } +} + +// HasLiquidDrops ... +func (t Torch) HasLiquidDrops() bool { + return true +} + +// EncodeItem ... +func (t Torch) EncodeItem() (name string, meta int16) { + switch t.Type { + case NormalFire(): + return "minecraft:torch", 0 + case SoulFire(): + return "minecraft:soul_torch", 0 + } + panic("invalid fire type") +} + +// EncodeBlock ... +func (t Torch) EncodeBlock() (name string, properties map[string]any) { + var face string + switch t.Facing { + case cube.FaceDown: + face = "top" + case unknownFace: + face = "unknown" + default: + face = t.Facing.String() + } + + switch t.Type { + case NormalFire(): + return "minecraft:torch", map[string]any{"torch_facing_direction": face} + case SoulFire(): + return "minecraft:soul_torch", map[string]any{"torch_facing_direction": face} + } + panic("invalid fire type") +} + +// allTorches ... +func allTorches() (torch []world.Block) { + for _, face := range cube.Faces() { + if face == cube.FaceUp { + face = unknownFace + } + torch = append(torch, Torch{Type: NormalFire(), Facing: face}) + torch = append(torch, Torch{Type: SoulFire(), Facing: face}) + } + return +} diff --git a/server/block/tuff.go b/server/block/tuff.go new file mode 100644 index 0000000..5ea6689 --- /dev/null +++ b/server/block/tuff.go @@ -0,0 +1,31 @@ +package block + +// Tuff is an ornamental rock formed from volcanic ash, occurring in underground blobs below Y=16. +type Tuff struct { + solid + bassDrum + + // Chiseled specifies if the tuff is chiseled. + Chiseled bool +} + +// BreakInfo ... +func (t Tuff) BreakInfo() BreakInfo { + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(t)).withBlastResistance(30) +} + +// EncodeItem ... +func (t Tuff) EncodeItem() (name string, meta int16) { + if t.Chiseled { + return "minecraft:chiseled_tuff", 0 + } + return "minecraft:tuff", 0 +} + +// EncodeBlock ... +func (t Tuff) EncodeBlock() (string, map[string]any) { + if t.Chiseled { + return "minecraft:chiseled_tuff", nil + } + return "minecraft:tuff", nil +} diff --git a/server/block/tuff_bricks.go b/server/block/tuff_bricks.go new file mode 100644 index 0000000..a990983 --- /dev/null +++ b/server/block/tuff_bricks.go @@ -0,0 +1,31 @@ +package block + +// TuffBricks are a decorational variant of Tuff that can be crafted or found naturally in Trial Chambers. +type TuffBricks struct { + solid + bassDrum + + // Chiseled specifies if the tuff bricks are chiseled. + Chiseled bool +} + +// BreakInfo ... +func (t TuffBricks) BreakInfo() BreakInfo { + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(t)).withBlastResistance(30) +} + +// EncodeItem ... +func (t TuffBricks) EncodeItem() (name string, meta int16) { + if t.Chiseled { + return "minecraft:chiseled_tuff_bricks", 0 + } + return "minecraft:tuff_bricks", 0 +} + +// EncodeBlock ... +func (t TuffBricks) EncodeBlock() (string, map[string]any) { + if t.Chiseled { + return "minecraft:chiseled_tuff_bricks", nil + } + return "minecraft:tuff_bricks", nil +} diff --git a/server/block/vine.go b/server/block/vine.go new file mode 100644 index 0000000..66e32b4 --- /dev/null +++ b/server/block/vine.go @@ -0,0 +1,325 @@ +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/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" + "math/rand/v2" +) + +// Vines are climbable non-solid vegetation blocks that grow on walls. +type Vines struct { + replaceable + transparent + empty + sourceWaterDisplacer + + // NorthDirection is true if the vines are attached towards north. + NorthDirection bool + // EastDirection is true if the vines are attached towards east. + EastDirection bool + // SouthDirection is true if the vines are attached towards south. + SouthDirection bool + // WestDirection is true if the vines are attached towards west. + WestDirection bool +} + +// CompostChance ... +func (Vines) CompostChance() float64 { + return 0.5 +} + +// SideClosed ... +func (Vines) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// HasLiquidDrops ... +func (Vines) HasLiquidDrops() bool { + return false +} + +// FlammabilityInfo ... +func (Vines) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(15, 100, true) +} + +// BreakInfo ... +func (v Vines) BreakInfo() BreakInfo { + return newBreakInfo(0.2, func(t item.Tool) bool { + return t.ToolType() == item.TypeShears + }, axeEffective, oneOf(v)) +} + +// EntityInside ... +func (Vines) EntityInside(_ cube.Pos, _ *world.Tx, e world.Entity) { + if fallEntity, ok := e.(fallDistanceEntity); ok { + fallEntity.ResetFallDistance() + } +} + +// WithAttachment returns a Vines block with an attachment on the given cube.Direction. +func (v Vines) WithAttachment(direction cube.Direction, attached bool) Vines { + switch direction { + case cube.North: + v.NorthDirection = attached + return v + case cube.East: + v.EastDirection = attached + return v + case cube.South: + v.SouthDirection = attached + return v + case cube.West: + v.WestDirection = attached + return v + } + panic("should never happen") +} + +// Attachment returns the attachment of the vines at the given direction. +func (v Vines) Attachment(direction cube.Direction) bool { + switch direction { + case cube.North: + return v.NorthDirection + case cube.East: + return v.EastDirection + case cube.South: + return v.SouthDirection + case cube.West: + return v.WestDirection + } + panic("should never happen") +} + +// Attachments returns all attachments of the vines. +func (v Vines) Attachments() (attachments []cube.Direction) { + for _, d := range cube.Directions() { + if v.Attachment(d) { + attachments = append(attachments, d) + } + } + return +} + +// UseOnBlock ... +func (v Vines) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + if _, ok := tx.Block(pos).Model().(model.Solid); !ok || face.Axis() == cube.Y { + return false + } + pos, face, used := firstReplaceable(tx, pos, face, v) + if !used { + return false + } + if _, ok := tx.Block(pos).(Vines); ok { + // Do not overwrite existing vine block. + return false + } + //noinspection GoAssignmentToReceiver + v = v.WithAttachment(face.Direction().Opposite(), true) + + place(tx, pos, v, user, ctx) + return placed(ctx) +} + +// NeighbourUpdateTick ... +func (v Vines) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + above, updated := tx.Block(pos.Side(cube.FaceUp)), false + for _, d := range v.Attachments() { + if !v.canSpreadTo(tx, pos.Side(d.Face())) { + if o, ok := above.(Vines); !ok || !o.Attachment(d) { + //noinspection GoAssignmentToReceiver + v = v.WithAttachment(d, false) + updated = true + } + } + } + if !updated { + return + } + if len(v.Attachments()) == 0 { + breakBlock(v, pos, tx) + return + } + tx.SetBlock(pos, v, nil) +} + +// RandomTick ... +func (v Vines) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + if r.Float64() > 0.25 { + // Vines have a 25% chance of spreading. + return + } + + // Choose a random direction to spread. + face := cube.Face(r.IntN(len(cube.Faces()))) + selectedPos := pos.Side(face) + + // If a horizontal direction was chosen and the vine block is not already + // attached in that direction, attempt to spread in that direction. + if face.Axis() != cube.Y && !v.Attachment(face.Direction()) { + if !v.canSpread(tx, pos) { + // No further attempt to spread vertically will be made. + return + } + // Attempt to create a new vine block if there is a neighbouring air block + // in the chosen direction. + if _, ok := tx.Block(selectedPos).(Air); ok { + rightRotatedFace := face.RotateRight() + leftRotatedFace := face.RotateLeft() + + attachedOnRight := v.Attachment(rightRotatedFace.Direction()) + attachedOnLeft := v.Attachment(leftRotatedFace.Direction()) + + rightSelectedPos := selectedPos.Side(rightRotatedFace) + leftSelectedPos := selectedPos.Side(leftRotatedFace) + + // Four attempts to create a new vine block will be made, in the + // following order: + // 1) If the current vine block is attached in the direction towards + // the right ("clockwise") of the chosen direction, and a solid + // block can support a vine on that direction in the selected + // position, create a new vine block attached on that clockwise + // direction at the selected position. + // 2) If the clockwise direction fails, try again with the left + // ("counter-clockwise") direction. + // 3) If the current vine block is attached in the direction towards + // the right of the chosen direction, the current vine block is + // also backed by a solid block in that same direction, and the + // block neighbouring the selected position in that direction is + // air, spread into that air block onto the face opposite of the + // chosen direction. The vine jumps from one face of a block onto + // another as a result. + // 4) If the clockwise direction fails, try again with the left + // direction. + if attachedOnRight && v.canSpreadTo(tx, rightSelectedPos) { + tx.SetBlock(selectedPos, (Vines{}).WithAttachment(rightRotatedFace.Direction(), true), nil) + } else if attachedOnLeft && v.canSpreadTo(tx, leftSelectedPos) { + tx.SetBlock(selectedPos, (Vines{}).WithAttachment(leftRotatedFace.Direction(), true), nil) + } else if _, ok = tx.Block(rightSelectedPos).(Air); ok && attachedOnRight && v.canSpreadTo(tx, pos.Side(rightRotatedFace)) { + tx.SetBlock(rightSelectedPos, (Vines{}).WithAttachment(face.Opposite().Direction(), true), nil) + } else if _, ok = tx.Block(leftSelectedPos).(Air); ok && attachedOnLeft && v.canSpreadTo(tx, pos.Side(leftRotatedFace)) { + tx.SetBlock(leftSelectedPos, (Vines{}).WithAttachment(face.Opposite().Direction(), true), nil) + } + } else if v.canSpreadTo(tx, selectedPos) { + // If the neighbouring block is solid, update the vine to be attached in that direction. + tx.SetBlock(pos, v.WithAttachment(face.Direction(), true), nil) + } + return + } + + // If the chosen direction is Up and the position above is within the height + // limit, attempt to spread upwards. + if face == cube.FaceUp && selectedPos.OutOfBounds(tx.Range()) { + // Vines can only spread upwards into an air block. + if _, ok := tx.Block(selectedPos).(Air); ok { + if !v.canSpread(tx, pos) { + // No further attempt to spread down will be made. + return + } + newVines := Vines{} + for _, f := range cube.HorizontalFaces() { + // For each direction the current vine block is attached on, + // there is a 50% chance for the new above vine block to + // attach onto the direction, if there is also a solid block + // in that direction to support the vine. + if r.IntN(2) == 0 && v.Attachment(f.Direction()) && v.canSpreadTo(tx, selectedPos.Side(f)) { + newVines = newVines.WithAttachment(f.Direction(), true) + } + } + if len(newVines.Attachments()) > 0 { + tx.SetBlock(selectedPos, newVines, nil) + } + return + } + } + + // If an attempt to spread horizontally or upwards has failed but not exited + // early, attempt to spread downwards. + selectedPos = pos.Side(cube.FaceDown) + if selectedPos.OutOfBounds(tx.Range()) { + return + } + newVines, vines := tx.Block(selectedPos).(Vines) + if _, ok := tx.Block(selectedPos).(Air); !ok && !vines { + // The block under the current vine block must be air or a vine block. + return + } + var changed bool + for _, f := range cube.HorizontalFaces() { + // For each direction the current vine block is attached on, there is a + // 50% chance for the below vine block to attach onto the direction if + // it is not already attached in that direction. + if r.IntN(2) == 0 && v.Attachment(f.Direction()) && !newVines.Attachment(f.Direction()) { + newVines, changed = newVines.WithAttachment(f.Direction(), true), true + } + } + if changed { + tx.SetBlock(selectedPos, newVines, nil) + } +} + +// EncodeItem ... +func (Vines) EncodeItem() (name string, meta int16) { + return "minecraft:vine", 0 +} + +// EncodeBlock ... +func (v Vines) EncodeBlock() (string, map[string]any) { + var bits int + for i, ok := range []bool{v.SouthDirection, v.WestDirection, v.NorthDirection, v.EastDirection} { + if ok { + bits |= 1 << i + } + } + return "minecraft:vine", map[string]any{"vine_direction_bits": int32(bits)} +} + +// canSpreadTo returns true if the vines can spread onto the block at the +// given position. Vines may only spread onto fully solid blocks. +func (Vines) canSpreadTo(tx *world.Tx, pos cube.Pos) bool { + _, ok := tx.Block(pos).Model().(model.Solid) + return ok +} + +// canSpread returns true if the vines can spread from the given position. Vines +// may only spread horizontally or upwards if there are fewer than 4 vines within +// a 9x9x3 area centred around the Vines. +func (v Vines) canSpread(tx *world.Tx, pos cube.Pos) bool { + var count int + for x := -4; x <= 4; x++ { + for z := -4; z <= 4; z++ { + for y := -1; y <= 1; y++ { + if _, ok := tx.Block(pos.Add(cube.Pos{x, y, z})).(Vines); ok { + count++ + // The centre vine is counted, for a max of 4+1=5. + if count >= 5 { + return false + } + } + } + } + } + return true +} + +// allVines ... +func allVines() (b []world.Block) { + for _, north := range []bool{true, false} { + for _, east := range []bool{true, false} { + for _, south := range []bool{true, false} { + for _, west := range []bool{true, false} { + b = append(b, Vines{ + NorthDirection: north, + EastDirection: east, + SouthDirection: south, + WestDirection: west, + }) + } + } + } + } + return +} diff --git a/server/block/wall.go b/server/block/wall.go new file mode 100644 index 0000000..a01f244 --- /dev/null +++ b/server/block/wall.go @@ -0,0 +1,280 @@ +package block + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/block/model" + "github.com/df-mc/dragonfly/server/internal/sliceutil" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" + "math" +) + +// Wall is a block similar to fences that prevents players from jumping over and is thinner than the usual block. It is +// available for many blocks and all types connect together as if they were the same type. +type Wall struct { + transparent + sourceWaterDisplacer + + // Block is the block to use for the type of wall. + Block world.Block + // NorthConnection is the type of connection in the north direction of the post. + NorthConnection WallConnectionType + // EastConnection is the type of connection in the east direction of the post. + EastConnection WallConnectionType + // SouthConnection is the type of connection in the south direction of the post. + SouthConnection WallConnectionType + // WestConnection is the type of connection in the west direction of the post. + WestConnection WallConnectionType + // Post is if the wall is extended to the full height of a block or not. + Post bool +} + +// EncodeItem ... +func (w Wall) EncodeItem() (string, int16) { + name := encodeWallBlock(w.Block) + return "minecraft:" + name + "_wall", 0 +} + +// EncodeBlock ... +func (w Wall) EncodeBlock() (string, map[string]any) { + properties := map[string]any{ + "wall_connection_type_north": w.NorthConnection.String(), + "wall_connection_type_east": w.EastConnection.String(), + "wall_connection_type_south": w.SouthConnection.String(), + "wall_connection_type_west": w.WestConnection.String(), + "wall_post_bit": boolByte(w.Post), + } + name := encodeWallBlock(w.Block) + return "minecraft:" + name + "_wall", properties +} + +// Model ... +func (w Wall) Model() world.BlockModel { + return model.Wall{ + NorthConnection: w.NorthConnection.Height(), + EastConnection: w.EastConnection.Height(), + SouthConnection: w.SouthConnection.Height(), + WestConnection: w.WestConnection.Height(), + Post: w.Post, + } +} + +// BreakInfo ... +func (w Wall) BreakInfo() BreakInfo { + breakable, ok := w.Block.(Breakable) + if !ok { + panic("wall block is not breakable") + } + return newBreakInfo(breakable.BreakInfo().Hardness, pickaxeHarvestable, pickaxeEffective, oneOf(w)).withBlastResistance(breakable.BreakInfo().BlastResistance) +} + +// NeighbourUpdateTick ... +func (w Wall) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + w, connectionsUpdated := w.calculateConnections(tx, pos) + w, postUpdated := w.calculatePost(tx, pos) + if connectionsUpdated || postUpdated { + tx.SetBlock(pos, w, nil) + } +} + +// UseOnBlock ... +func (w Wall) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, w) + if !used { + return + } + w, _ = w.calculateConnections(tx, pos) + w, _ = w.calculatePost(tx, pos) + place(tx, pos, w, user, ctx) + return placed(ctx) +} + +// SideClosed ... +func (Wall) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// ConnectionType returns the connection type of the wall in the given direction. +func (w Wall) ConnectionType(direction cube.Direction) WallConnectionType { + switch direction { + case cube.North: + return w.NorthConnection + case cube.East: + return w.EastConnection + case cube.South: + return w.SouthConnection + case cube.West: + return w.WestConnection + } + panic("unknown direction") +} + +// WithConnectionType returns the wall with the given connection type in the given direction. +func (w Wall) WithConnectionType(direction cube.Direction, connection WallConnectionType) Wall { + switch direction { + case cube.North: + w.NorthConnection = connection + case cube.East: + w.EastConnection = connection + case cube.South: + w.SouthConnection = connection + case cube.West: + w.WestConnection = connection + } + return w +} + +// calculateConnections calculates the correct connections for the wall at a given position in a world. The updated wall +// is returned and a bool to determine if any changes were made. +func (w Wall) calculateConnections(tx *world.Tx, pos cube.Pos) (Wall, bool) { + var updated bool + abovePos := pos.Add(cube.Pos{0, 1, 0}) + above := tx.Block(abovePos) + for _, face := range cube.HorizontalFaces() { + sidePos := pos.Side(face) + side := tx.Block(sidePos) + // A wall can only connect to a block if the side is solid, with the only exception being thin blocks (such as + // glass panes and iron bars) as well as the sides of fence gates. + connected := side.Model().FaceSolid(sidePos, face.Opposite(), tx) + if !connected { + if _, ok := tx.Block(sidePos).(Wall); ok { + connected = true + } else if gate, ok := tx.Block(sidePos).(WoodFenceGate); ok { + connected = gate.Facing.Face().Axis() != face.Axis() + } else if _, ok := tx.Block(sidePos).Model().(model.Thin); ok { + connected = true + } + } + var connectionType WallConnectionType + if connected { + // If the wall is connected to the side, it has the possibility of having a tall connection. This is + // calculated by checking for any overlapping blocks in the area of the connection. + connectionType = ShortWallConnection() + boxes := above.Model().BBox(abovePos, tx) + for _, bb := range boxes { + if bb.Min().Y() == 0 { + xOverlap := bb.Min().X() < 0.75 && bb.Max().X() > 0.25 + zOverlap := bb.Min().Z() < 0.75 && bb.Max().Z() > 0.25 + var tall bool + switch face { + case cube.FaceNorth: + tall = xOverlap && bb.Max().Z() > 0.75 + case cube.FaceEast: + tall = bb.Min().X() < 0.25 && zOverlap + case cube.FaceSouth: + tall = xOverlap && bb.Min().Z() < 0.25 + case cube.FaceWest: + tall = bb.Max().X() > 0.75 && zOverlap + } + if tall { + connectionType = TallWallConnection() + break + } + } + } + + } + if w.ConnectionType(face.Direction()) != connectionType { + updated = true + w = w.WithConnectionType(face.Direction(), connectionType) + } + } + return w, updated +} + +// calculatePost calculates the correct post bit for the wall at a given position in a world. The updated wall is +// returned and a bool to determine if any changes were made. +func (w Wall) calculatePost(tx *world.Tx, pos cube.Pos) (Wall, bool) { + var updated bool + abovePos := pos.Add(cube.Pos{0, 1, 0}) + above := tx.Block(abovePos) + connections := len(sliceutil.Filter(cube.HorizontalFaces(), func(face cube.Face) bool { + return w.ConnectionType(face.Direction()) != NoWallConnection() + })) + var post bool + switch above := above.(type) { + case Lantern: + // Lanterns only make a wall become a post when placed on the wall and not hanging from above. + post = !above.Hanging + case Sign: + // Signs only make a wall become a post when placed on the wall and not placed on the side of a block. + post = !above.Attach.hanging + case Torch: + // Torches only make a wall become a post when placed on the wall and not placed on the side of a block. + post = above.Facing == cube.FaceDown + case WoodTrapdoor: + // Trapdoors only make a wall become a post when they are opened and not closed and above a connection. + if above.Open { + switch above.Facing { + case cube.North: + post = w.NorthConnection != NoWallConnection() + case cube.East: + post = w.EastConnection != NoWallConnection() + case cube.South: + post = w.SouthConnection != NoWallConnection() + case cube.West: + post = w.WestConnection != NoWallConnection() + } + } + case Wall: + // A wall only make a wall become a post if it is a post itself. + post = above.Post + } + if !post { + // If a wall has two connections that are in different axis then it becomes a post regardless of the above block. + if connections < 2 { + post = true + } else { + switch { + case w.NorthConnection != NoWallConnection() && w.SouthConnection != NoWallConnection(): + post = w.EastConnection != NoWallConnection() || w.WestConnection != NoWallConnection() + case w.EastConnection != NoWallConnection() && w.WestConnection != NoWallConnection(): + post = w.NorthConnection != NoWallConnection() || w.SouthConnection != NoWallConnection() + default: + post = true + } + } + } + if w.Post != post { + updated = true + w.Post = post + } + return w, updated +} + +// allWalls returns a list of all wall types. +func allWalls() (walls []world.Block) { + for _, block := range WallBlocks() { + if _, hash := block.Hash(); hash > math.MaxUint16 { + name, _ := block.EncodeBlock() + panic(fmt.Errorf("hash of block %s exceeds 16 bytes", name)) + } + for _, north := range WallConnectionTypes() { + for _, east := range WallConnectionTypes() { + for _, south := range WallConnectionTypes() { + for _, west := range WallConnectionTypes() { + walls = append(walls, Wall{ + Block: block, + NorthConnection: north, + EastConnection: east, + SouthConnection: south, + WestConnection: west, + Post: false, + }) + walls = append(walls, Wall{ + Block: block, + NorthConnection: north, + EastConnection: east, + SouthConnection: south, + WestConnection: west, + Post: true, + }) + } + } + } + } + } + return +} diff --git a/server/block/wall_connection_type.go b/server/block/wall_connection_type.go new file mode 100644 index 0000000..2fa4290 --- /dev/null +++ b/server/block/wall_connection_type.go @@ -0,0 +1,59 @@ +package block + +// WallConnectionType represents the connection type of a wall. +type WallConnectionType struct { + wallConnectionType +} + +// NoWallConnection returns the no connection type of a wall. +func NoWallConnection() WallConnectionType { + return WallConnectionType{0} +} + +// ShortWallConnection returns the short connection type of a wall. +func ShortWallConnection() WallConnectionType { + return WallConnectionType{1} +} + +// TallWallConnection returns the tall connection type of a wall. +func TallWallConnection() WallConnectionType { + return WallConnectionType{2} +} + +// WallConnectionTypes returns a list of all wall connection types. +func WallConnectionTypes() []WallConnectionType { + return []WallConnectionType{NoWallConnection(), ShortWallConnection(), TallWallConnection()} +} + +type wallConnectionType uint8 + +// Uint8 returns the wall connection as a uint8. +func (w wallConnectionType) Uint8() uint8 { + return uint8(w) +} + +// String ... +func (w wallConnectionType) String() string { + switch w { + case 0: + return "none" + case 1: + return "short" + case 2: + return "tall" + } + panic("unknown wall connection type") +} + +// Height returns the height of the connection for the block model. +func (w wallConnectionType) Height() float64 { + switch w { + case 0: + return 0 + case 1: + return 0.75 + case 2: + return 1 + } + panic("unknown wall connection type") +} diff --git a/server/block/wall_type.go b/server/block/wall_type.go new file mode 100644 index 0000000..cf79f3f --- /dev/null +++ b/server/block/wall_type.go @@ -0,0 +1,124 @@ +package block + +import "github.com/df-mc/dragonfly/server/world" + +// encodeWallBlock encodes the provided block in to an identifier and meta value that can be used to encode the wall. +func encodeWallBlock(block world.Block) string { + switch block := block.(type) { + case Andesite: + if !block.Polished { + return "andesite" + } + case Blackstone: + if block.Type == NormalBlackstone() { + return "blackstone" + } else if block.Type == PolishedBlackstone() { + return "polished_blackstone" + } + case Bricks: + return "brick" + case Cobblestone: + if block.Mossy { + return "mossy_cobblestone" + } + return "cobblestone" + case Deepslate: + if block.Type == CobbledDeepslate() { + return "cobbled_deepslate" + } else if block.Type == PolishedDeepslate() { + return "polished_deepslate" + } + case DeepslateBricks: + if !block.Cracked { + return "deepslate_brick" + } + case DeepslateTiles: + if !block.Cracked { + return "deepslate_tile" + } + case Diorite: + if !block.Polished { + return "diorite" + } + case EndBricks: + return "end_stone_brick" + case Granite: + if !block.Polished { + return "granite" + } + case MudBricks: + return "mud_brick" + case NetherBricks: + if block.Type == NormalNetherBricks() { + return "nether_brick" + } else if block.Type == RedNetherBricks() { + return "red_nether_brick" + } + case PolishedBlackstoneBrick: + if !block.Cracked { + return "polished_blackstone_brick" + } + case PolishedTuff: + return "polished_tuff" + case Prismarine: + if block.Type == NormalPrismarine() { + return "prismarine" + } + case ResinBricks: + return "resin_brick" + case Sandstone: + if block.Type == NormalSandstone() { + if block.Red { + return "red_sandstone" + } + return "sandstone" + } + case StoneBricks: + if block.Type == NormalStoneBricks() { + return "stone_brick" + } else if block.Type == MossyStoneBricks() { + return "mossy_stone_brick" + } + case Tuff: + if !block.Chiseled { + return "tuff" + } + case TuffBricks: + if !block.Chiseled { + return "tuff_brick" + } + } + panic("invalid block used for wall") +} + +// WallBlocks returns a list of all possible blocks for a wall. +func WallBlocks() []world.Block { + return []world.Block{ + Andesite{}, + Blackstone{Type: PolishedBlackstone()}, + Blackstone{}, + Bricks{}, + Cobblestone{Mossy: true}, + Cobblestone{}, + DeepslateBricks{}, + DeepslateTiles{}, + Deepslate{Type: CobbledDeepslate()}, + Deepslate{Type: PolishedDeepslate()}, + Diorite{}, + EndBricks{}, + Granite{}, + MudBricks{}, + NetherBricks{Type: RedNetherBricks()}, + NetherBricks{}, + PolishedBlackstoneBrick{}, + PolishedTuff{}, + Prismarine{}, + ResinBricks{}, + Sandstone{Red: true}, + Sandstone{}, + StoneBricks{Type: MossyStoneBricks()}, + StoneBricks{}, + Tuff{}, + TuffBricks{}, + } +} diff --git a/server/block/water.go b/server/block/water.go new file mode 100644 index 0000000..040e83a --- /dev/null +++ b/server/block/water.go @@ -0,0 +1,212 @@ +package block + +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/potion" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" +) + +// Water is a natural fluid that generates abundantly in the world. +type Water struct { + empty + + // Still makes the water appear as if it is not flowing. + Still bool + // Depth is the depth of the water. This is a number from 1-8, where 8 is a source block and 1 is the + // smallest possible water block. + Depth int + // Falling specifies if the water is falling. Falling water will always appear as a source block, but its + // behaviour differs when it starts spreading. + Falling bool +} + +// ReplaceableBy ... +func (w Water) ReplaceableBy(b world.Block) bool { + if _, ok := b.(LiquidRemovable); ok { + _, displacer := b.(world.LiquidDisplacer) + _, liquid := b.(world.Liquid) + return displacer || liquid + } + return true +} + +// EntityInside ... +func (w Water) EntityInside(_ cube.Pos, _ *world.Tx, e world.Entity) { + if fallEntity, ok := e.(fallDistanceEntity); ok { + fallEntity.ResetFallDistance() + } + if flammable, ok := e.(flammableEntity); ok { + flammable.Extinguish() + } +} + +// FillBottle ... +func (w Water) FillBottle() (world.Block, item.Stack, bool) { + if w.Depth == 8 { + return w, item.NewStack(item.Potion{Type: potion.Water()}, 1), true + } + return nil, item.Stack{}, false +} + +// LiquidDepth returns the depth of the water. +func (w Water) LiquidDepth() int { + return w.Depth +} + +// SpreadDecay returns 1 - The amount of levels decreased upon spreading. +func (Water) SpreadDecay() int { + return 1 +} + +// WithDepth returns the water with the depth passed. +func (w Water) WithDepth(depth int, falling bool) world.Liquid { + w.Depth = depth + w.Falling = falling + w.Still = false + return w +} + +// LiquidFalling returns Water.Falling. +func (w Water) LiquidFalling() bool { + return w.Falling +} + +// BlastResistance always returns 500. +func (Water) BlastResistance() float64 { + return 500 +} + +// HasLiquidDrops ... +func (Water) HasLiquidDrops() bool { + return false +} + +// LiquidRemoveBlock drops the items of the removed block at the position passed if it has liquid drops. +func (Water) LiquidRemoveBlock(pos cube.Pos, tx *world.Tx, removed world.Block) { + r, ok := removed.(LiquidRemovable) + if !ok || !r.HasLiquidDrops() { + return + } + b, ok := removed.(Breakable) + if !ok { + panic("liquid drops should always implement breakable") + } + for _, d := range b.BreakInfo().Drops(item.ToolNone{}, nil) { + dropItem(tx, d, pos.Vec3Centre()) + } +} + +// LightDiffusionLevel ... +func (Water) LightDiffusionLevel() uint8 { + return 2 +} + +// ScheduledTick ... +func (w Water) ScheduledTick(pos cube.Pos, tx *world.Tx, _ *rand.Rand) { + if w.Depth == 7 { + // Attempt to form new water source blocks. + count := 0 + pos.Neighbours(func(neighbour cube.Pos) { + if neighbour[1] == pos[1] { + if liquid, ok := tx.Liquid(neighbour); ok { + if water, ok := liquid.(Water); ok && water.Depth == 8 && !water.Falling { + count++ + } + } + } + }, tx.Range()) + if count >= 2 { + if !canFlowInto(w, tx, pos.Side(cube.FaceDown), true) { + // Only form a new source block if there either is no water below this block, or if the water + // below this is not falling (full source block). + res := Water{Depth: 8, Still: true} + ctx := event.C(tx) + if tx.World().Handler().HandleLiquidFlow(ctx, pos, pos, res, w); ctx.Cancelled() { + return + } + tx.SetLiquid(pos, res) + } + } + } + tickLiquid(w, pos, tx) +} + +// NeighbourUpdateTick ... +func (w Water) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if tx.World().Dimension().WaterEvaporates() { + // Particles are spawned client-side. + tx.SetLiquid(pos, nil) + return + } + tx.ScheduleBlockUpdate(pos, w, time.Second/4) +} + +// LiquidType ... +func (Water) LiquidType() string { + return "water" +} + +// Harden hardens the water if lava flows into it. +func (w Water) Harden(pos cube.Pos, tx *world.Tx, flownIntoBy *cube.Pos) bool { + if flownIntoBy == nil { + return false + } + if lava, ok := tx.Block(pos.Side(cube.FaceUp)).(Lava); ok { + ctx := event.C(tx) + if tx.World().Handler().HandleLiquidHarden(ctx, pos, w, lava, Stone{}); ctx.Cancelled() { + return false + } + tx.SetBlock(pos, Stone{}, nil) + tx.PlaySound(pos.Vec3Centre(), sound.Fizz{}) + return true + } else if lava, ok := tx.Block(*flownIntoBy).(Lava); ok { + ctx := event.C(tx) + if tx.World().Handler().HandleLiquidHarden(ctx, pos, w, lava, Cobblestone{}); ctx.Cancelled() { + return false + } + tx.SetBlock(*flownIntoBy, Cobblestone{}, nil) + tx.PlaySound(pos.Vec3Centre(), sound.Fizz{}) + return true + } + return false +} + +// EncodeBlock ... +func (w Water) EncodeBlock() (name string, properties map[string]any) { + if w.Depth < 1 || w.Depth > 8 { + panic("invalid water depth, must be between 1 and 8") + } + v := 8 - w.Depth + if w.Falling { + v += 8 + } + if w.Still { + return "minecraft:water", map[string]any{"liquid_depth": int32(v)} + } + return "minecraft:flowing_water", map[string]any{"liquid_depth": int32(v)} +} + +// allWater returns a list of all water states. +func allWater() (b []world.Block) { + f := func(still, falling bool) { + b = append(b, Water{Still: still, Falling: falling, Depth: 8}) + b = append(b, Water{Still: still, Falling: falling, Depth: 7}) + b = append(b, Water{Still: still, Falling: falling, Depth: 6}) + b = append(b, Water{Still: still, Falling: falling, Depth: 5}) + b = append(b, Water{Still: still, Falling: falling, Depth: 4}) + b = append(b, Water{Still: still, Falling: falling, Depth: 3}) + b = append(b, Water{Still: still, Falling: falling, Depth: 2}) + b = append(b, Water{Still: still, Falling: falling, Depth: 1}) + } + f(true, true) + f(true, false) + f(false, false) + f(false, true) + return +} diff --git a/server/block/wheat_seeds.go b/server/block/wheat_seeds.go new file mode 100644 index 0000000..549f4ce --- /dev/null +++ b/server/block/wheat_seeds.go @@ -0,0 +1,84 @@ +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" +) + +// WheatSeeds are a crop that can be harvested to craft bread, cake, & cookies. +type WheatSeeds struct { + crop +} + +// SameCrop ... +func (WheatSeeds) SameCrop(c Crop) bool { + _, ok := c.(WheatSeeds) + return ok +} + +// BoneMeal ... +func (s WheatSeeds) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { + if s.Growth == 7 { + return item.BoneMealResultNone + } + s.Growth = min(s.Growth+rand.IntN(4)+2, 7) + tx.SetBlock(pos, s, nil) + return item.BoneMealResultSmall +} + +// UseOnBlock ... +func (s WheatSeeds) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, s) + if !used { + return false + } + + if _, ok := tx.Block(pos.Side(cube.FaceDown)).(Farmland); !ok { + return false + } + + place(tx, pos, s, user, ctx) + return placed(ctx) +} + +// BreakInfo ... +func (s WheatSeeds) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, cropSeedDrops(s, item.Wheat{}, s.Growth)) +} + +// CompostChance ... +func (WheatSeeds) CompostChance() float64 { + return 0.3 +} + +// EncodeItem ... +func (s WheatSeeds) EncodeItem() (name string, meta int16) { + return "minecraft:wheat_seeds", 0 +} + +// RandomTick ... +func (s WheatSeeds) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + if tx.Light(pos) < 8 { + breakBlock(s, pos, tx) + } else if s.Growth < 7 && r.Float64() <= s.CalculateGrowthChance(pos, tx) { + s.Growth++ + tx.SetBlock(pos, s, nil) + } +} + +// EncodeBlock ... +func (s WheatSeeds) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:wheat", map[string]any{"growth": int32(s.Growth)} +} + +// allWheat ... +func allWheat() (wheat []world.Block) { + for i := 0; i <= 7; i++ { + wheat = append(wheat, WheatSeeds{crop{Growth: i}}) + } + return +} diff --git a/server/block/wood.go b/server/block/wood.go new file mode 100644 index 0000000..c58a070 --- /dev/null +++ b/server/block/wood.go @@ -0,0 +1,114 @@ +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" +) + +// Wood is a block that has the log's "bark" texture on all six sides. It comes in 8 types: oak, spruce, birch, jungle, +// acacia, dark oak, crimson, and warped. +// Stripped wood is a variant obtained by using an axe on the wood. +type Wood struct { + solid + bass + + // Wood is the type of wood. + Wood WoodType + // Stripped specifies if the wood is stripped or not. + Stripped bool + // Axis is the axis which the wood block faces. + Axis cube.Axis +} + +// FlammabilityInfo ... +func (w Wood) FlammabilityInfo() FlammabilityInfo { + if !w.Wood.Flammable() { + return newFlammabilityInfo(0, 0, false) + } + return newFlammabilityInfo(5, 5, true) +} + +// BreakInfo ... +func (w Wood) BreakInfo() BreakInfo { + return newBreakInfo(2.0, alwaysHarvestable, axeEffective, oneOf(w)) +} + +// SmeltInfo ... +func (Wood) SmeltInfo() item.SmeltInfo { + return newSmeltInfo(item.NewStack(item.Charcoal{}, 1), 0.15) +} + +// FuelInfo ... +func (w Wood) FuelInfo() item.FuelInfo { + if !w.Wood.Flammable() { + return item.FuelInfo{} + } + return newFuelInfo(time.Second * 15) +} + +// UseOnBlock ... +func (w Wood) 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, w) + if !used { + return + } + w.Axis = face.Axis() + + place(tx, pos, w, user, ctx) + return placed(ctx) +} + +// Strip ... +func (w Wood) Strip() (world.Block, world.Sound, bool) { + return Wood{Axis: w.Axis, Wood: w.Wood, Stripped: true}, nil, !w.Stripped +} + +// EncodeItem ... +func (w Wood) EncodeItem() (name string, meta int16) { + switch w.Wood { + case CrimsonWood(), WarpedWood(): + if w.Stripped { + return "minecraft:stripped_" + w.Wood.String() + "_hyphae", 0 + } + return "minecraft:" + w.Wood.String() + "_hyphae", 0 + default: + if w.Stripped { + return "minecraft:stripped_" + w.Wood.String() + "_wood", 0 + } + return "minecraft:" + w.Wood.String() + "_wood", 0 + } +} + +// EncodeBlock ... +func (w Wood) EncodeBlock() (name string, properties map[string]any) { + switch w.Wood { + case CrimsonWood(), WarpedWood(): + if w.Stripped { + return "minecraft:stripped_" + w.Wood.String() + "_hyphae", map[string]any{"pillar_axis": w.Axis.String()} + } + return "minecraft:" + w.Wood.String() + "_hyphae", map[string]any{"pillar_axis": w.Axis.String()} + default: + if w.Stripped { + return "minecraft:stripped_" + w.Wood.String() + "_wood", map[string]any{"pillar_axis": w.Axis.String()} + } + return "minecraft:" + w.Wood.String() + "_wood", map[string]any{"pillar_axis": w.Axis.String()} + } +} + +// 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}) + } + } + return +} diff --git a/server/block/wood_door.go b/server/block/wood_door.go new file mode 100644 index 0000000..ea23271 --- /dev/null +++ b/server/block/wood_door.go @@ -0,0 +1,163 @@ +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/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" + "time" +) + +// WoodDoor is a block that can be used as an openable 1x2 barrier. +type WoodDoor struct { + transparent + bass + sourceWaterDisplacer + + // Wood is the type of wood of the door. This field must have one of the values found in the material + // package. + Wood WoodType + // Facing is the direction that the door opens towards. When closed, the door sits on the side of its + // block on the opposite direction. + Facing cube.Direction + // Open is whether the door is open. + Open bool + // Top is whether the block is the top or bottom half of a door. + Top bool + // Right is whether the door hinge is on the right side. + Right bool +} + +// FlammabilityInfo ... +func (d WoodDoor) FlammabilityInfo() FlammabilityInfo { + if !d.Wood.Flammable() { + return newFlammabilityInfo(0, 0, false) + } + return newFlammabilityInfo(0, 0, true) +} + +// FuelInfo ... +func (d WoodDoor) FuelInfo() item.FuelInfo { + if !d.Wood.Flammable() { + return item.FuelInfo{} + } + return newFuelInfo(time.Second * 10) +} + +// Model ... +func (d WoodDoor) Model() world.BlockModel { + return model.Door{Facing: d.Facing, Open: d.Open, Right: d.Right} +} + +// NeighbourUpdateTick ... +func (d WoodDoor) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if d.Top { + if _, ok := tx.Block(pos.Side(cube.FaceDown)).(WoodDoor); !ok { + breakBlockNoDrops(d, pos, tx) + } + } else if solid := tx.Block(pos.Side(cube.FaceDown)).Model().FaceSolid(pos.Side(cube.FaceDown), cube.FaceUp, tx); !solid { + breakBlock(d, pos, tx) + } else if _, ok := tx.Block(pos.Side(cube.FaceUp)).(WoodDoor); !ok { + breakBlockNoDrops(d, pos, tx) + } +} + +// UseOnBlock handles the directional placing of doors +func (d WoodDoor) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + if face != cube.FaceUp { + // Doors can only be placed when clicking the top face. + return false + } + below := pos + pos = pos.Side(cube.FaceUp) + if !replaceableWith(tx, pos, d) || !replaceableWith(tx, pos.Side(cube.FaceUp), d) { + return false + } + if !tx.Block(below).Model().FaceSolid(below, cube.FaceUp, tx) { + return false + } + d.Facing = user.Rotation().Direction() + left := tx.Block(pos.Side(d.Facing.RotateLeft().Face())) + right := tx.Block(pos.Side(d.Facing.RotateRight().Face())) + if _, ok := left.Model().(model.Door); ok { + d.Right = true + } + // The side the door hinge is on can be affected by the blocks to the left and right of the door. In particular, + // opaque blocks on the right side of the door with transparent blocks on the left side result in a right sided + // door hinge. + if diffuser, ok := right.(LightDiffuser); !ok || diffuser.LightDiffusionLevel() != 0 { + if diffuser, ok := left.(LightDiffuser); ok && diffuser.LightDiffusionLevel() == 0 { + d.Right = true + } + } + + ctx.IgnoreBBox = true + place(tx, pos, d, user, ctx) + place(tx, pos.Side(cube.FaceUp), WoodDoor{Wood: d.Wood, Facing: d.Facing, Top: true, Right: d.Right}, user, ctx) + ctx.CountSub = 1 + return placed(ctx) +} + +// Activate ... +func (d WoodDoor) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, _ item.User, _ *item.UseContext) bool { + d.Open = !d.Open + tx.SetBlock(pos, d, nil) + + otherPos := pos.Side(cube.Face(boolByte(!d.Top))) + other := tx.Block(otherPos) + if door, ok := other.(WoodDoor); ok { + door.Open = d.Open + tx.SetBlock(otherPos, door, nil) + } + if d.Open { + tx.PlaySound(pos.Vec3Centre(), sound.DoorOpen{Block: d}) + return true + } + tx.PlaySound(pos.Vec3Centre(), sound.DoorClose{Block: d}) + return true +} + +// BreakInfo ... +func (d WoodDoor) BreakInfo() BreakInfo { + return newBreakInfo(3, alwaysHarvestable, axeEffective, oneOf(d)) +} + +// SideClosed ... +func (d WoodDoor) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// EncodeItem ... +func (d WoodDoor) EncodeItem() (name string, meta int16) { + if d.Wood == OakWood() { + return "minecraft:wooden_door", 0 + } + return "minecraft:" + d.Wood.String() + "_door", 0 +} + +// EncodeBlock ... +func (d WoodDoor) EncodeBlock() (name string, properties map[string]any) { + if d.Wood == OakWood() { + return "minecraft:wooden_door", map[string]any{"minecraft:cardinal_direction": d.Facing.RotateRight().String(), "door_hinge_bit": d.Right, "open_bit": d.Open, "upper_block_bit": d.Top} + } + return "minecraft:" + d.Wood.String() + "_door", map[string]any{"minecraft:cardinal_direction": d.Facing.RotateRight().String(), "door_hinge_bit": d.Right, "open_bit": d.Open, "upper_block_bit": d.Top} +} + +// allDoors returns a list of all door types +func allDoors() (doors []world.Block) { + for _, w := range WoodTypes() { + for i := cube.Direction(0); i <= 3; i++ { + doors = append(doors, WoodDoor{Wood: w, Facing: i, Open: false, Top: false, Right: false}) + doors = append(doors, WoodDoor{Wood: w, Facing: i, Open: false, Top: true, Right: false}) + doors = append(doors, WoodDoor{Wood: w, Facing: i, Open: true, Top: true, Right: false}) + doors = append(doors, WoodDoor{Wood: w, Facing: i, Open: true, Top: false, Right: false}) + doors = append(doors, WoodDoor{Wood: w, Facing: i, Open: false, Top: false, Right: true}) + doors = append(doors, WoodDoor{Wood: w, Facing: i, Open: false, Top: true, Right: true}) + doors = append(doors, WoodDoor{Wood: w, Facing: i, Open: true, Top: true, Right: true}) + doors = append(doors, WoodDoor{Wood: w, Facing: i, Open: true, Top: false, Right: true}) + } + } + return +} diff --git a/server/block/wood_fence.go b/server/block/wood_fence.go new file mode 100644 index 0000000..d318d1e --- /dev/null +++ b/server/block/wood_fence.go @@ -0,0 +1,70 @@ +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/item" + "github.com/df-mc/dragonfly/server/world" + "time" +) + +// WoodFence are blocks similar to Walls, which cannot normally be jumped over. Unlike walls however, +// they allow the player (but not mobs) to see through them, making for excellent barriers. +type WoodFence struct { + transparent + bass + sourceWaterDisplacer + + // Wood is the type of wood of the fence. This field must have one of the values found in the wood + // package. + Wood WoodType +} + +// BreakInfo ... +func (w WoodFence) BreakInfo() BreakInfo { + return newBreakInfo(2, alwaysHarvestable, axeEffective, oneOf(w)).withBlastResistance(15) +} + +// SideClosed ... +func (WoodFence) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// FlammabilityInfo ... +func (w WoodFence) FlammabilityInfo() FlammabilityInfo { + if !w.Wood.Flammable() { + return newFlammabilityInfo(0, 0, false) + } + return newFlammabilityInfo(5, 20, true) +} + +// FuelInfo ... +func (w WoodFence) FuelInfo() item.FuelInfo { + if !w.Wood.Flammable() { + return item.FuelInfo{} + } + return newFuelInfo(time.Second * 15) +} + +// EncodeBlock ... +func (w WoodFence) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:" + w.Wood.String() + "_fence", nil +} + +// Model ... +func (w WoodFence) Model() world.BlockModel { + return model.Fence{Wood: true} +} + +// EncodeItem ... +func (w WoodFence) EncodeItem() (name string, meta int16) { + return "minecraft:" + w.Wood.String() + "_fence", 0 +} + +// allFence ... +func allFence() (fence []world.Block) { + for _, w := range WoodTypes() { + fence = append(fence, WoodFence{Wood: w}) + } + return +} diff --git a/server/block/wood_fence_gate.go b/server/block/wood_fence_gate.go new file mode 100644 index 0000000..60ff01d --- /dev/null +++ b/server/block/wood_fence_gate.go @@ -0,0 +1,132 @@ +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/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" + "time" +) + +// WoodFenceGate is a block that can be used as an openable 1x1 barrier. +type WoodFenceGate struct { + transparent + bass + sourceWaterDisplacer + + // Wood is the type of wood of the fence gate. This field must have one of the values found in the material + // package. + Wood WoodType + // Facing is the direction the fence gate swings open. + Facing cube.Direction + // Open is whether the fence gate is open. + Open bool + // Lowered lowers the fence gate by 3 pixels and is set when placed next to wall blocks. + Lowered bool +} + +// BreakInfo ... +func (f WoodFenceGate) BreakInfo() BreakInfo { + return newBreakInfo(2, alwaysHarvestable, axeEffective, oneOf(f)).withBlastResistance(15) +} + +// FlammabilityInfo ... +func (f WoodFenceGate) FlammabilityInfo() FlammabilityInfo { + if !f.Wood.Flammable() { + return newFlammabilityInfo(0, 0, false) + } + return newFlammabilityInfo(5, 20, true) +} + +// FuelInfo ... +func (f WoodFenceGate) FuelInfo() item.FuelInfo { + if !f.Wood.Flammable() { + return item.FuelInfo{} + } + return newFuelInfo(time.Second * 15) +} + +// UseOnBlock ... +func (f WoodFenceGate) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, f) + if !used { + return false + } + f.Facing = user.Rotation().Direction() + f.Lowered = f.shouldBeLowered(pos, tx) + + place(tx, pos, f, user, ctx) + return placed(ctx) +} + +// NeighbourUpdateTick ... +func (f WoodFenceGate) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if f.shouldBeLowered(pos, tx) != f.Lowered { + f.Lowered = !f.Lowered + tx.SetBlock(pos, f, nil) + } +} + +// shouldBeLowered returns if the fence gate should be lowered or not, based on the neighbouring walls. +func (f WoodFenceGate) shouldBeLowered(pos cube.Pos, tx *world.Tx) bool { + leftSide := f.Facing.RotateLeft().Face() + _, left := tx.Block(pos.Side(leftSide)).(Wall) + _, right := tx.Block(pos.Side(leftSide.Opposite())).(Wall) + return left || right +} + +// Activate ... +func (f WoodFenceGate) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + f.Open = !f.Open + if f.Open && f.Facing.Opposite() == u.Rotation().Direction() { + f.Facing = f.Facing.Opposite() + } + tx.SetBlock(pos, f, nil) + if f.Open { + tx.PlaySound(pos.Vec3Centre(), sound.FenceGateOpen{Block: f}) + return true + } + tx.PlaySound(pos.Vec3Centre(), sound.FenceGateClose{Block: f}) + return true +} + +// SideClosed ... +func (f WoodFenceGate) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// EncodeItem ... +func (f WoodFenceGate) EncodeItem() (name string, meta int16) { + if f.Wood == OakWood() { + return "minecraft:fence_gate", 0 + } + return "minecraft:" + f.Wood.String() + "_fence_gate", 0 +} + +// EncodeBlock ... +func (f WoodFenceGate) EncodeBlock() (name string, properties map[string]any) { + if f.Wood == OakWood() { + return "minecraft:fence_gate", map[string]any{"minecraft:cardinal_direction": f.Facing.String(), "open_bit": f.Open, "in_wall_bit": f.Lowered} + } + return "minecraft:" + f.Wood.String() + "_fence_gate", map[string]any{"minecraft:cardinal_direction": f.Facing.String(), "open_bit": f.Open, "in_wall_bit": f.Lowered} +} + +// Model ... +func (f WoodFenceGate) Model() world.BlockModel { + return model.FenceGate{Facing: f.Facing, Open: f.Open} +} + +// allFenceGates returns a list of all trapdoor types. +func allFenceGates() (fenceGates []world.Block) { + for _, w := range WoodTypes() { + for i := cube.Direction(0); i <= 3; i++ { + fenceGates = append(fenceGates, WoodFenceGate{Wood: w, Facing: i, Open: false, Lowered: false}) + fenceGates = append(fenceGates, WoodFenceGate{Wood: w, Facing: i, Open: false, Lowered: true}) + fenceGates = append(fenceGates, WoodFenceGate{Wood: w, Facing: i, Open: true, Lowered: true}) + fenceGates = append(fenceGates, WoodFenceGate{Wood: w, Facing: i, Open: true, Lowered: false}) + } + } + return +} diff --git a/server/block/wood_trapdoor.go b/server/block/wood_trapdoor.go new file mode 100644 index 0000000..e525fe7 --- /dev/null +++ b/server/block/wood_trapdoor.go @@ -0,0 +1,115 @@ +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/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" + "math" + "time" +) + +// WoodTrapdoor is a block that can be used as an openable 1x1 barrier. +type WoodTrapdoor struct { + transparent + bass + sourceWaterDisplacer + + // Wood is the type of wood of the trapdoor. This field must have one of the values found in the material + // package. + Wood WoodType + // Facing is the direction the trapdoor is facing. + Facing cube.Direction + // Open is whether the trapdoor is open. + Open bool + // Top is whether the trapdoor occupies the top or bottom part of a block. + Top bool +} + +// FlammabilityInfo ... +func (t WoodTrapdoor) FlammabilityInfo() FlammabilityInfo { + if !t.Wood.Flammable() { + return newFlammabilityInfo(0, 0, false) + } + return newFlammabilityInfo(0, 0, true) +} + +// Model ... +func (t WoodTrapdoor) Model() world.BlockModel { + return model.Trapdoor{Facing: t.Facing, Top: t.Top, Open: t.Open} +} + +// UseOnBlock handles the directional placing of trapdoors and makes sure they are properly placed upside down +// when needed. +func (t WoodTrapdoor) UseOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, face, used := firstReplaceable(tx, pos, face, t) + if !used { + return false + } + t.Facing = user.Rotation().Direction().Opposite() + t.Top = (clickPos.Y() > 0.5 && face != cube.FaceUp) || face == cube.FaceDown + + place(tx, pos, t, user, ctx) + return placed(ctx) +} + +// Activate ... +func (t WoodTrapdoor) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, _ item.User, _ *item.UseContext) bool { + t.Open = !t.Open + tx.SetBlock(pos, t, nil) + if t.Open { + tx.PlaySound(pos.Vec3Centre(), sound.TrapdoorOpen{Block: t}) + return true + } + tx.PlaySound(pos.Vec3Centre(), sound.TrapdoorClose{Block: t}) + return true +} + +// BreakInfo ... +func (t WoodTrapdoor) BreakInfo() BreakInfo { + return newBreakInfo(3, alwaysHarvestable, axeEffective, oneOf(t)) +} + +// FuelInfo ... +func (t WoodTrapdoor) FuelInfo() item.FuelInfo { + if !t.Wood.Flammable() { + return item.FuelInfo{} + } + return newFuelInfo(time.Second * 15) +} + +// SideClosed ... +func (t WoodTrapdoor) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false +} + +// EncodeItem ... +func (t WoodTrapdoor) EncodeItem() (name string, meta int16) { + if t.Wood == OakWood() { + return "minecraft:trapdoor", 0 + } + return "minecraft:" + t.Wood.String() + "_trapdoor", 0 +} + +// EncodeBlock ... +func (t WoodTrapdoor) EncodeBlock() (name string, properties map[string]any) { + if t.Wood == OakWood() { + return "minecraft:trapdoor", map[string]any{"direction": int32(math.Abs(float64(t.Facing) - 3)), "open_bit": t.Open, "upside_down_bit": t.Top} + } + return "minecraft:" + t.Wood.String() + "_trapdoor", map[string]any{"direction": int32(math.Abs(float64(t.Facing) - 3)), "open_bit": t.Open, "upside_down_bit": t.Top} +} + +// allTrapdoors returns a list of all trapdoor types +func allTrapdoors() (trapdoors []world.Block) { + for _, w := range WoodTypes() { + for i := cube.Direction(0); i <= 3; i++ { + trapdoors = append(trapdoors, WoodTrapdoor{Wood: w, Facing: i, Open: false, Top: false}) + trapdoors = append(trapdoors, WoodTrapdoor{Wood: w, Facing: i, Open: false, Top: true}) + trapdoors = append(trapdoors, WoodTrapdoor{Wood: w, Facing: i, Open: true, Top: true}) + trapdoors = append(trapdoors, WoodTrapdoor{Wood: w, Facing: i, Open: true, Top: false}) + } + } + return +} diff --git a/server/block/wood_type.go b/server/block/wood_type.go new file mode 100644 index 0000000..cafd9b0 --- /dev/null +++ b/server/block/wood_type.go @@ -0,0 +1,172 @@ +package block + +// WoodType represents a type of wood of a block. Some blocks, such as log blocks, bark blocks, wooden planks and +// others carry one of these types. +type WoodType struct { + wood +} + +// OakWood returns oak wood material. +func OakWood() WoodType { + return WoodType{0} +} + +// SpruceWood returns spruce wood material. +func SpruceWood() WoodType { + return WoodType{1} +} + +// BirchWood returns birchwood material. +func BirchWood() WoodType { + return WoodType{2} +} + +// JungleWood returns jungle wood material. +func JungleWood() WoodType { + return WoodType{3} +} + +// AcaciaWood returns acacia wood material. +func AcaciaWood() WoodType { + return WoodType{4} +} + +// DarkOakWood returns dark oak wood material. +func DarkOakWood() WoodType { + return WoodType{5} +} + +// CrimsonWood returns crimson wood material. +func CrimsonWood() WoodType { + return WoodType{6} +} + +// WarpedWood returns warped wood material. +func WarpedWood() WoodType { + return WoodType{7} +} + +// MangroveWood returns mangrove wood material. +func MangroveWood() WoodType { + return WoodType{8} +} + +// CherryWood returns cherry wood material. +func CherryWood() WoodType { + return WoodType{9} +} + +// PaleOakWood returns pale oak wood material. +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(), BambooWood()} +} + +type wood uint8 + +// Uint8 returns the wood as a uint8. +func (w wood) Uint8() uint8 { + return uint8(w) +} + +// Name ... +func (w wood) Name() string { + switch w { + case 0: + return "Oak Wood" + case 1: + return "Spruce Wood" + case 2: + return "Birch Wood" + case 3: + return "Jungle Wood" + case 4: + return "Acacia Wood" + case 5: + return "Dark Oak Wood" + case 6: + return "Crimson Wood" + case 7: + return "Warped Wood" + case 8: + return "Mangrove Wood" + case 9: + return "Cherry Wood" + case 10: + return "Pale Oak Wood" + case 11: + return "Bamboo Wood" + } + panic("unknown wood type") +} + +// String ... +func (w wood) String() string { + switch w { + case 0: + return "oak" + case 1: + return "spruce" + case 2: + return "birch" + case 3: + return "jungle" + case 4: + return "acacia" + case 5: + return "dark_oak" + case 6: + return "crimson" + case 7: + return "warped" + case 8: + return "mangrove" + case 9: + return "cherry" + case 10: + return "pale_oak" + case 11: + return "bamboo" + } + panic("unknown wood type") +} + +// Flammable returns whether the wood type is flammable. +func (w wood) Flammable() bool { + return w != CrimsonWood().wood && w != WarpedWood().wood +} + +// Leaves returns the leaves type belonging to the wood type. +func (w WoodType) Leaves() (LeavesType, bool) { + switch w { + case OakWood(): + return OakLeaves(), true + case SpruceWood(): + return SpruceLeaves(), true + case BirchWood(): + return BirchLeaves(), true + case JungleWood(): + return JungleLeaves(), true + case AcaciaWood(): + return AcaciaLeaves(), true + case DarkOakWood(): + return DarkOakLeaves(), true + case MangroveWood(): + return MangroveLeaves(), true + case CherryWood(): + return CherryLeaves(), true + case PaleOakWood(): + return PaleOakLeaves(), true + default: + return LeavesType{}, false + } +} diff --git a/server/block/wool.go b/server/block/wool.go new file mode 100644 index 0000000..9d0ada4 --- /dev/null +++ b/server/block/wool.go @@ -0,0 +1,49 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" +) + +// Wool is a colourful block that can be obtained by killing/shearing sheep, or crafted using four string. +type Wool struct { + solid + + // Colour is the colour of the wool. + Colour item.Colour +} + +// Instrument ... +func (w Wool) Instrument() sound.Instrument { + return sound.Guitar() +} + +// FlammabilityInfo ... +func (w Wool) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(30, 60, true) +} + +// BreakInfo ... +func (w Wool) BreakInfo() BreakInfo { + return newBreakInfo(0.8, alwaysHarvestable, shearsEffective, oneOf(w)) +} + +// EncodeItem ... +func (w Wool) EncodeItem() (name string, meta int16) { + return "minecraft:" + w.Colour.String() + "_wool", 0 +} + +// EncodeBlock ... +func (w Wool) EncodeBlock() (name string, properties map[string]any) { + return "minecraft:" + w.Colour.String() + "_wool", nil +} + +// allWool returns wool blocks with all possible colours. +func allWool() []world.Block { + b := make([]world.Block, 0, 16) + for _, c := range item.Colours() { + b = append(b, Wool{Colour: c}) + } + return b +} diff --git a/server/cmd/argument.go b/server/cmd/argument.go new file mode 100644 index 0000000..1b76ade --- /dev/null +++ b/server/cmd/argument.go @@ -0,0 +1,320 @@ +package cmd + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/internal/sliceutil" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" + "math/rand/v2" + "reflect" + "slices" + "sort" + "strconv" + "strings" +) + +// Line represents a command line holding command arguments that were passed upon the execution of the +// command. It is a convenience wrapper around a string slice. +type Line struct { + args []string + seen []string + src Source + cmd Command +} + +// SyntaxError returns a translated syntax error. +func (line *Line) SyntaxError() error { + if len(line.args) == 0 { + return MessageSyntax.F(strings.Join(line.seen, " "), "", "") + } + next := strings.Join(line.args[1:], " ") + if next != "" { + next = " " + next + } + return MessageSyntax.F(strings.Join(line.seen, " ")+" ", line.args[0], next) +} + +// UsageError returns a translated usage error. +func (line *Line) UsageError() error { + return MessageUsage.F(line.cmd.Usage()) +} + +// Next reads the next argument from the command line and returns it. If there were no more arguments to +// consume, false is returned. +func (line *Line) Next() (string, bool) { + v, ok := line.NextN(1) + if !ok { + return "", false + } + return v[0], true +} + +// NextN reads the next N arguments from the command line and returns them. If there were not enough arguments +// (n arguments), false is returned. +func (line *Line) NextN(n int) ([]string, bool) { + if len(line.args) < n { + return nil, false + } + v := line.args[:n] + return v, true +} + +// RemoveNext consumes the next argument from the command line. +func (line *Line) RemoveNext() { + line.RemoveN(1) +} + +// RemoveN consumes the next N arguments from the command line. +func (line *Line) RemoveN(n int) { + if len(line.args) < n { + line.args = nil + return + } + line.seen = append(line.seen, line.args[:n]...) + line.args = line.args[n:] +} + +// Leftover takes the leftover arguments from the command line. +func (line *Line) Leftover() []string { + v := line.args + line.args = nil + return v +} + +// Len returns the leftover length of the arguments in the command line. +func (line *Line) Len() int { + return len(line.args) +} + +// parser manages the parsing of a Line, turning the raw arguments into values which are then stored in the +// struct fields. +type parser struct { + currentField string +} + +// parseArgument parses the next argument from the command line passed and sets it to value v passed. If +// parsing was not successful, an error is returned. +func (p parser) parseArgument(line *Line, v reflect.Value, optional bool, name string, source Source, tx *world.Tx) (error, bool) { + var err error + i := v.Interface() + if line.Len() == 0 && optional { + // The command run didn't have enough arguments for this parameter, but + // it was optional, so it does not matter. Make sure to clear the value + // though. + v.Set(reflect.Zero(v.Type())) + return nil, false + } + switch i.(type) { + case int, int8, int16, int32, int64: + err = p.int(line, v) + case uint, uint8, uint16, uint32, uint64: + err = p.uint(line, v) + case float32, float64: + err = p.float(line, v) + case string: + err = p.string(line, v) + case bool: + err = p.bool(line, v) + case mgl64.Vec3: + err = p.vec3(line, v) + case Varargs: + err = p.varargs(line, v) + case []Target: + err = p.targets(line, v, tx) + case SubCommand: + err = p.sub(line, name) + default: + if param, ok := i.(Parameter); ok { + err = param.Parse(line, v) + break + } + if enum, ok := i.(Enum); ok { + err = p.enum(line, v, enum, source) + break + } + panic(fmt.Sprintf("non-command parameter type %T in command structure", i)) + } + if err == nil { + // The argument was parsed successfully, so it needs to be removed from the command line. + line.RemoveNext() + } + return err, err == nil +} + +// int ... +func (p parser) int(line *Line, v reflect.Value) error { + arg, ok := line.Next() + if !ok { + return line.UsageError() + } + value, err := strconv.ParseInt(arg, 10, v.Type().Bits()) + if err != nil { + return MessageNumberInvalid.F(arg) + } + v.SetInt(value) + return nil +} + +// uint ... +func (p parser) uint(line *Line, v reflect.Value) error { + arg, ok := line.Next() + if !ok { + return line.UsageError() + } + value, err := strconv.ParseUint(arg, 10, v.Type().Bits()) + if err != nil { + return MessageNumberInvalid.F(arg) + } + v.SetUint(value) + return nil +} + +// float ... +func (p parser) float(line *Line, v reflect.Value) error { + arg, ok := line.Next() + if !ok { + return line.UsageError() + } + value, err := strconv.ParseFloat(arg, v.Type().Bits()) + if err != nil { + return MessageNumberInvalid.F(arg) + } + v.SetFloat(value) + return nil +} + +// string ... +func (p parser) string(line *Line, v reflect.Value) error { + arg, ok := line.Next() + if !ok { + return line.UsageError() + } + v.SetString(arg) + return nil +} + +// bool ... +func (p parser) bool(line *Line, v reflect.Value) error { + arg, ok := line.Next() + if !ok { + return line.UsageError() + } + value, err := strconv.ParseBool(arg) + if err != nil { + return MessageBooleanInvalid.F(arg) + } + v.SetBool(value) + return nil +} + +// enum ... +func (p parser) enum(line *Line, val reflect.Value, v Enum, source Source) error { + arg, ok := line.Next() + if !ok { + return line.UsageError() + } + opts := v.Options(source) + ind := slices.IndexFunc(opts, func(s string) bool { + return strings.EqualFold(s, arg) + }) + if ind < 0 { + return MessageParameterInvalid.F(arg) + } + val.SetString(opts[ind]) + return nil +} + +// sub reads verifies a SubCommand against the next argument. +func (p parser) sub(line *Line, name string) error { + arg, ok := line.Next() + if !ok { + return line.UsageError() + } + if strings.EqualFold(name, arg) { + return nil + } + return MessageParameterInvalid.F(arg) +} + +// vec3 ... +func (p parser) vec3(line *Line, v reflect.Value) error { + if err := p.float(line, v.Index(0)); err != nil { + return err + } + line.RemoveNext() + if err := p.float(line, v.Index(1)); err != nil { + return err + } + line.RemoveNext() + return p.float(line, v.Index(2)) +} + +// varargs ... +func (p parser) varargs(line *Line, v reflect.Value) error { + v.SetString(strings.Join(line.Leftover(), " ")) + return nil +} + +// targets ... +func (p parser) targets(line *Line, v reflect.Value, tx *world.Tx) error { + targets, err := p.parseTargets(line, tx) + if err != nil { + return err + } + if len(targets) == 0 { + return MessageNoTargets.F() + } + v.Set(reflect.ValueOf(targets)) + return nil +} + +// parseTargets parses one or more Targets from the Line passed. +func (p parser) parseTargets(line *Line, tx *world.Tx) ([]Target, error) { + entities, players := targets(tx) + first, ok := line.Next() + if !ok { + return nil, line.UsageError() + } + switch first[:min(len(first), 2)] { + case "@p": + pos := line.src.Position() + playerDistances := make([]float64, len(players)) + for i, p := range players { + playerDistances[i] = p.Position().Sub(pos).Len() + } + sort.Slice(players, func(i, j int) bool { + return playerDistances[i] < playerDistances[j] + }) + if len(players) == 0 { + return nil, nil + } + return sliceutil.Convert[Target](players[0:1]), nil + case "@e": + return entities, nil + case "@a": + return sliceutil.Convert[Target](players), nil + case "@s": + return []Target{line.src}, nil + case "@r": + if len(players) == 0 { + return nil, nil + } + return []Target{players[rand.IntN(len(players))]}, nil + default: + target, err := p.parsePlayer(first, players) + if err != nil { + return nil, err + } + return []Target{target}, nil + } +} + +// parsePlayer attempts to find a target whose name matches the name passed. +func (p parser) parsePlayer(name string, players []NamedTarget) (Target, error) { + if ind := slices.IndexFunc(players, func(target NamedTarget) bool { + return strings.EqualFold(target.Name(), name) + }); ind != -1 { + return players[ind], nil + } + return nil, MessagePlayerNotFound.F() +} diff --git a/server/cmd/command.go b/server/cmd/command.go new file mode 100644 index 0000000..c32500f --- /dev/null +++ b/server/cmd/command.go @@ -0,0 +1,354 @@ +package cmd + +import ( + "encoding/csv" + "fmt" + "go/ast" + "reflect" + "slices" + "strings" + + "github.com/df-mc/dragonfly/server/world" +) + +// Runnable represents a Command that may be run by a Command source. The Command must be a struct type and +// its fields represent the parameters of the Command. When the Run method is called, these fields are set +// and may be used for behaviour in the Command. Fields unexported or ignored using the `cmd:"-"` struct tag (see +// below) have their values copied but retained. +// A Runnable may have exported fields only of the following types: +// int8, int16, int32, int64, int, uint8, uint16, uint32, uint64, uint, +// float32, float64, string, bool, mgl64.Vec3, Varargs, []Target, cmd.SubCommand, Optional[T] (to make a parameter +// optional), or a type that implements the cmd.Parameter or cmd.Enum interface. cmd.Enum implementations must be of the +// type string. +// Fields in the Runnable struct may have `cmd:` struct tag to specify the name and suffix of a parameter as such: +// +// type T struct { +// Param int `cmd:"name,suffix"` +// } +// +// If no name is set, the field name is used. Additionally, the name as specified in the struct tag may be '-' to make +// the parser ignore the field. In this case, the field does not have to be of one of the types above. +type Runnable interface { + // Run runs the Command, using the arguments passed to the Command. The source is passed to the method, + // which is the source of the Command execution, and the output is passed, to which messages may be + // added which get sent to the source. + Run(src Source, o *Output, tx *world.Tx) +} + +// Allower may be implemented by a type also implementing Runnable to limit the sources that may run the +// command. +type Allower interface { + // Allow checks if the Source passed is allowed to execute the command. True is returned if the Source is + // allowed to execute the command. + Allow(src Source) bool +} + +// Command is a wrapper around a Runnable. It provides additional identity and utility methods for the actual +// runnable command so that it may be identified more easily. +type Command struct { + v []reflect.Value + name string + description string + usage string + aliases []string +} + +// New returns a new Command using the name and description passed. Command +// names and aliases are all converted to lowercase. The Runnable passed must +// be a (pointer to a) struct, with its fields representing the parameters of +// the command. When the command is run, the Run method of the Runnable will be +// called after all fields have their values from the parsed command set. If r +// is not a struct or a pointer to a struct, New panics. +func New(name, description string, aliases []string, r ...Runnable) Command { + name = strings.ToLower(name) + for i, alias := range aliases { + aliases[i] = strings.ToLower(alias) + } + + usages := make([]string, len(r)) + runnableValues := make([]reflect.Value, len(r)) + + if len(aliases) > 0 && slices.Index(aliases, name) == -1 { + aliases = append(aliases, name) + } + + for i, runnable := range r { + t := reflect.TypeOf(runnable) + if t.Kind() != reflect.Struct && (t.Kind() != reflect.Ptr || t.Elem().Kind() != reflect.Struct) { + panic(fmt.Sprintf("Runnable r must be struct or pointer to struct, but got %v", t.Kind())) + } + original := reflect.ValueOf(runnable) + if t.Kind() == reflect.Ptr { + original = original.Elem() + } + + cp := reflect.New(original.Type()).Elem() + if err := verifySignature(cp); err != nil { + panic(err.Error()) + } + runnableValues[i], usages[i] = original, parseUsage(name, cp) + } + + return Command{name: name, description: description, aliases: aliases, v: runnableValues, usage: strings.Join(usages, "\n")} +} + +// Name returns the name of the command. The name is guaranteed to be lowercase and will never have spaces in +// it. This name is used to call the command, and is shown in the /help list. +func (cmd Command) Name() string { + return cmd.name +} + +// Description returns the description of the command. The description is shown in the /help list, and +// provides information on the functionality of a command. +func (cmd Command) Description() string { + return cmd.description +} + +// Usage returns the usage of the command. The usage will be roughly equal to the one showed by the client +// in-game. +func (cmd Command) Usage() string { + return cmd.usage +} + +// Aliases returns a list of aliases for the command. In addition to the name of the command, the command may +// be called using one of these aliases. +func (cmd Command) Aliases() []string { + return cmd.aliases +} + +// Execute executes the Command as a source with the args passed. The args are parsed assuming they do not +// start with the command name. Execute will attempt to parse and execute one Runnable at a time. If one of +// the Runnable was able to parse args correctly, it will be executed and no more Runnables will be attempted +// to be run. +// If parsing of all Runnables was unsuccessful, a command output with an error message is sent to the Source +// passed, and the Run method of the Runnables are not called. +// The Source passed must not be nil. The method will panic if a nil Source is passed. +func (cmd Command) Execute(args string, source Source, tx *world.Tx) { + if source == nil { + panic("execute: invalid command source: source must not be nil") + } + output := &Output{} + defer source.SendCommandOutput(output) + + var leastErroneous error + var leastArgsLeft *Line + + for _, v := range cmd.v { + cp := reflect.New(v.Type()) + cp.Elem().Set(v) + line, err := cmd.executeRunnable(cp, args, source, output, tx) + if err == nil { + // Command was executed successfully: We won't execute any of the other Runnable values passed, as + // we've already found an overload that works. + return + } + if line == nil { + // This Runnable was not runnable by the source passed. Only if no error was yet set, we set an + // error for the wrong source. + if leastErroneous == nil { + leastErroneous = err + } + continue + } + if leastArgsLeft == nil || line.Len() <= leastArgsLeft.Len() { + // If the line had less (or equal) arguments left than the previous lowest, we update the error, + // so that we can return an error that applies for the most successful Runnable. + leastErroneous = err + leastArgsLeft = line + } + } + // No working Runnable found for the arguments passed. We add the most + // applicable error to the output and stop there. + if leastArgsLeft != nil { + output.Error(leastArgsLeft.SyntaxError()) + } + output.Error(leastErroneous) +} + +// ParamInfo holds the information of a parameter in a Runnable. Information of a parameter may be obtained +// by calling Command.Params(). +type ParamInfo struct { + Name string + Value any + Optional bool + Suffix string +} + +// Params returns a list of all parameters of the runnables. No assumptions should be done on the values that +// they hold: Only the types are guaranteed to be consistent. +func (cmd Command) Params(src Source) [][]ParamInfo { + params := make([][]ParamInfo, 0, len(cmd.v)) + for _, runnable := range cmd.v { + if allower, ok := runnable.Interface().(Allower); ok && !allower.Allow(src) { + // This source cannot execute this runnable. + continue + } + + // If the runnable can describe its own parameters, prefer that over reflection. + if d, ok := runnable.Interface().(ParamDescriber); ok { + params = append(params, d.DescribeParams(src)) + continue + } + + elem := reflect.New(runnable.Type()).Elem() + elem.Set(runnable) + + var fields []ParamInfo + for _, t := range exportedFields(elem) { + field := elem.FieldByName(t.Name) + fields = append(fields, ParamInfo{ + Name: name(t), + Value: unwrap(field).Interface(), + Optional: optional(field), + Suffix: suffix(t), + }) + } + params = append(params, fields) + } + return params +} + +// Runnables returns a map of all Runnable implementations of the Command that a Source can execute. +func (cmd Command) Runnables(src Source) map[int]Runnable { + m := make(map[int]Runnable, len(cmd.v)) + for i, runnable := range cmd.v { + v := runnable.Interface().(Runnable) + if allower, ok := v.(Allower); !ok || allower.Allow(src) { + m[i] = v + } + } + return m +} + +// String returns the usage of the command. The usage will be roughly equal to the one showed by the client +// in-game. +func (cmd Command) String() string { + return cmd.usage +} + +// executeRunnable executes a Runnable v, by parsing the args passed using the source and output obtained. If +// parsing was not successful or the Runnable could not be run by this source, an error is returned, and the +// leftover command line. +func (cmd Command) executeRunnable(v reflect.Value, args string, source Source, output *Output, tx *world.Tx) (*Line, error) { + if a, ok := v.Interface().(Allower); ok && !a.Allow(source) { + return nil, MessageUnknown.F(cmd.name) + } + + var argFrags []string + if args != "" { + r := csv.NewReader(strings.NewReader(args)) + r.Comma, r.LazyQuotes = ' ', true + record, err := r.Read() + if err != nil { + // When LazyQuotes is enabled, this really never appears to return + // an error when we read only one line. Just in case it does though, + // we return the command usage. + return nil, MessageUsage.F(cmd.Usage()) + } + argFrags = record + } + parser := parser{} + arguments := &Line{args: argFrags, src: source, seen: []string{"/" + cmd.name}, cmd: cmd} + + // We iterate over all the fields of the struct: Each of the fields will have an argument parsed to + // produce its value. + signature := v.Elem() + for _, t := range exportedFields(signature) { + field := signature.FieldByName(t.Name) + parser.currentField = t.Name + opt := optional(field) + + val := field + if opt { + val = reflect.New(field.Field(0).Type()).Elem() + } + + err, success := parser.parseArgument(arguments, val, opt, name(t), source, tx) + if err != nil { + // Parsing was not successful, we return immediately as we don't + // need to call the Runnable. + return arguments, err + } + if success && opt { + field.Set(reflect.ValueOf(field.Interface().(optionalT).with(val.Interface()))) + } + } + if arguments.Len() != 0 { + return arguments, arguments.UsageError() + } + + v.Interface().(Runnable).Run(source, output, tx) + return arguments, nil +} + +// parseUsage parses the usage of a command found in value v using the name passed. It accounts for optional +// parameters and converts types to a more friendly representation. +func parseUsage(commandName string, command reflect.Value) string { + parts := make([]string, 0, command.NumField()+1) + parts = append(parts, "/"+commandName) + + for _, t := range exportedFields(command) { + field := command.FieldByName(t.Name) + + typeName := typeNameOf(field.Interface(), name(t)) + if _, ok := field.Interface().(optionalT); ok { + typeName = typeNameOf(reflect.New(field.Field(0).Type()).Elem().Interface(), name(t)) + } + if _, ok := field.Interface().(SubCommand); ok { + parts = append(parts, typeName) + continue + } + if optional(field) { + parts = append(parts, "["+name(t)+": "+typeName+"]"+suffix(t)) + continue + } + parts = append(parts, "<"+name(t)+": "+typeName+">"+suffix(t)) + } + return strings.Join(parts, " ") +} + +// verifySignature verifies the passed struct pointer value signature to ensure it is a valid command, +// checking things such as the validity of the optional struct tags. +// If not valid, an error is returned. +func verifySignature(command reflect.Value) error { + optionalField := false + for _, t := range exportedFields(command) { + field := command.FieldByName(t.Name) + + // If the field is not optional, while the last field WAS optional, we return an error, as this is + // not parsable in an expected way. + opt := optional(field) + if !opt && optionalField { + return fmt.Errorf("command must only have optional parameters at the end") + } + val := field + if opt { + val = reflect.New(field.Field(0).Type()).Elem() + } + if _, ok := val.Interface().(Enum); ok && val.Kind() != reflect.String { + return fmt.Errorf("parameters implementing Enum must be of the type string") + } + optionalField = opt + } + return nil +} + +// exportedFields returns all exported struct fields of the reflect.Value passed. It returns the fields as returned by +// reflect.VisibleFields, but filters out unexported fields, anonymous fields and fields that have a name value in the +// 'cmd' tag of '-'. +func exportedFields(command reflect.Value) []reflect.StructField { + visible := reflect.VisibleFields(command.Type()) + fields := make([]reflect.StructField, 0, len(visible)) + + for _, t := range visible { + if !ast.IsExported(t.Name) || name(t) == "-" || t.Anonymous { + continue + } + field := command.FieldByName(t.Name) + if !field.CanSet() { + continue + } + fields = append(fields, t) + } + return fields +} diff --git a/server/cmd/doc.go b/server/cmd/doc.go new file mode 100644 index 0000000..7ae9b0a --- /dev/null +++ b/server/cmd/doc.go @@ -0,0 +1,27 @@ +// Package cmd implements a Minecraft specific command system, which may be used simply by 'plugging' it in +// and sending commands registered in an AvailableCommandsPacket. +// +// The cmd package handles commands in a specific way: It requires a struct to be passed to the cmd.New() +// function, which implements the Runnable interface. For every exported field in the struct, executing the +// command will result in the parsing of the arguments using the types of the fields of the struct, in the +// order that they appear in. Fields unexported or ignored using the `cmd:"-"` struct tag (see below) have +// their values copied but retained. +// +// A Runnable may have exported fields only of the following types: +// int8, int16, int32, int64, int, uint8, uint16, uint32, uint64, uint, +// float32, float64, string, bool, mgl64.Vec3, Varargs, []Target, cmd.SubCommand, Optional[T] (to make a parameter +// optional), or a type that implements the cmd.Parameter or cmd.Enum interface. cmd.Enum implementations must be of the +// type string. +// Fields in the Runnable struct may have `cmd:` struct tag to specify the name and suffix of a parameter as such: +// +// type T struct { +// Param int `cmd:"name,suffix"` +// } +// +// If no name is set, the field name is used. Additionally, the name as specified in the struct tag may be '-' to make +// the parser ignore the field. In this case, the field does not have to be of one of the types above. +// +// Commands may be registered using the cmd.Register() method. By itself, this method will not ensure that the +// client will be able to use the command: The user of the cmd package must handle commands itself and run the +// appropriate one using the cmd.ByAlias function. +package cmd diff --git a/server/cmd/output.go b/server/cmd/output.go new file mode 100644 index 0000000..7e9b4f3 --- /dev/null +++ b/server/cmd/output.go @@ -0,0 +1,95 @@ +package cmd + +import ( + "errors" + "fmt" + "github.com/df-mc/dragonfly/server/player/chat" + "golang.org/x/text/language" +) + +// Output holds the output of a command execution. It holds success messages +// and error messages, which the source of a command execution gets sent. +type Output struct { + errors []error + messages []fmt.Stringer +} + +// Errorf formats an error message and adds it to the command output. +func (o *Output) Errorf(format string, a ...any) { + o.errors = append(o.errors, fmt.Errorf(format, a...)) +} + +// Error formats an error message and adds it to the command output. +func (o *Output) Error(a ...any) { + if len(a) == 1 { + if err, ok := a[0].(error); ok { + o.errors = append(o.errors, err) + return + } + } + o.errors = append(o.errors, errors.New(fmt.Sprint(a...))) +} + +// Errort adds a translation as an error message and parameterises it using the +// arguments passed. Errort panics if the number of arguments is incorrect. +func (o *Output) Errort(t chat.Translation, a ...any) { + o.errors = append(o.errors, t.F(a...)) +} + +// Printf formats a (success) message and adds it to the command output. +func (o *Output) Printf(format string, a ...any) { + o.messages = append(o.messages, stringer(fmt.Sprintf(format, a...))) +} + +// Print formats a (success) message and adds it to the command output. +func (o *Output) Print(a ...any) { + o.messages = append(o.messages, stringer(fmt.Sprint(a...))) +} + +// Printt adds a translation as a (success) message and parameterises it using +// the arguments passed. Printt panics if the number of arguments is incorrect. +func (o *Output) Printt(t chat.Translation, a ...any) { + o.messages = append(o.messages, t.F(a...)) +} + +// Errors returns a list of all errors added to the command output. Usually +// only one error message is set: After one error message, execution of a +// command typically terminates. +func (o *Output) Errors() []error { + return o.errors +} + +// ErrorCount returns the count of errors that the command output has. +func (o *Output) ErrorCount() int { + return len(o.errors) +} + +// Messages returns a list of all messages added to the command output. The +// amount of messages present depends on the command called. +func (o *Output) Messages() []fmt.Stringer { + return o.messages +} + +// MessageCount returns the count of (success) messages that the command output +// has. +func (o *Output) MessageCount() int { + return len(o.messages) +} + +type stringer string + +func (s stringer) String() string { return string(s) } + +var MessageSyntax = chat.Translate(str("%commands.generic.syntax"), 3, `Syntax error: unexpected value: at "%v>>%v<<%v"`).Enc("%v") +var MessageUsage = chat.Translate(str("%commands.generic.usage"), 1, `Usage: %v`).Enc("%v") +var MessageUnknown = chat.Translate(str("%commands.generic.unknown"), 1, `Unknown command: "%v": Please check that the command exists and that you have permission to use it.`).Enc("%v") +var MessageNoTargets = chat.Translate(str("%commands.generic.noTargetMatch"), 0, `No targets matched selector`).Enc("%v") +var MessageNumberInvalid = chat.Translate(str("%commands.generic.num.invalid"), 1, `'%v' is not a valid number`).Enc("> %v") +var MessageBooleanInvalid = chat.Translate(str("%commands.generic.boolean.invalid"), 1, `'%v' is not true or false`).Enc("> %v") +var MessagePlayerNotFound = chat.Translate(str("%commands.generic.player.notFound"), 0, `That player cannot be found`).Enc("> %v") +var MessageParameterInvalid = chat.Translate(str("%commands.generic.parameter.invalid"), 1, `'%v' is not a valid parameter`).Enc("> %v") + +type str string + +// Resolve returns the translation identifier as a string. +func (s str) Resolve(language.Tag) string { return string(s) } diff --git a/server/cmd/parameter.go b/server/cmd/parameter.go new file mode 100644 index 0000000..3106a88 --- /dev/null +++ b/server/cmd/parameter.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "reflect" + "strings" + + "github.com/go-gl/mathgl/mgl64" +) + +// Parameter is an interface for a generic parameters. Users may have types as command parameters that +// implement this parameter. +type Parameter interface { + // Parse takes an arbitrary amount of arguments from the command Line passed and parses it, so that it can + // store it to value v. If the arguments cannot be parsed from the Line, an error should be returned. + Parse(line *Line, v reflect.Value) error + // Type returns the type of the parameter. It will show up in the usage of the command, and, if one of the + // known type names, will also show up client-side. + Type() string +} + +// Enum is an interface for enum-type parameters. Users may have types as command parameters that implement +// this parameter in order to allow a specific set of options only. +// Enum implementations must be of the type string, for example: +// +// type GameMode string +// func (GameMode) Type() string { return "GameMode" } +// func (GameMode) Options(Source) []string { return []string{"survival", "creative"} } +// +// Their values will then automatically be set to whichever option returned in Enum.Options is selected by +// the user. +type Enum interface { + // Type returns the type of the enum. This type shows up client-side in the command usage, in the spot + // where parameter types otherwise are. + // Type names returned are used as an identifier for this enum type. Different Enum implementations must + // return a different string in the Type method. + Type() string + // Options should return a list of options that show up on the client side. The command will ensure that + // the argument passed to the enum parameter will be equal to one of these options. The provided Source + // can also be used to change the enums for each player. + Options(source Source) []string +} + +// ParamDescriber may be implemented by a Runnable to programmatically describe its parameters +// without relying on struct field reflection. +type ParamDescriber interface { + DescribeParams(src Source) []ParamInfo +} + +// SubCommand represents a subcommand that may be added as a static value that must be written. Adding +// multiple Runnable implementations to the command in New with different SubCommand fields as the +// first parameter allows for commands with subcommands. +type SubCommand struct{} + +// Varargs is an argument type that may be used to capture all arguments that follow. This is useful for, +// for example, messages and names. +type Varargs string + +// Optional is an argument type that may be used to make any of the available parameter types optional. Optional command +// parameters may only occur at the end of the Runnable struct. No non-optional parameter is allowed after an optional +// parameter. +type Optional[T any] struct { + val T + set bool +} + +// Load returns the value specified upon executing the command and a bool that is true if the parameter was filled out +// by the Source. +func (o Optional[T]) Load() (T, bool) { + return o.val, o.set +} + +// LoadOr returns the value specified upon executing the command, or a value 'or' if the parameter was not filled out +// by the Source. +func (o Optional[T]) LoadOr(or T) T { + if o.set { + return o.val + } + return or +} + +// with returns an Optional[T] with the value passed. It also sets the 'set' field to true. +func (o Optional[T]) with(val any) any { + return Optional[T]{val: val.(T), set: true} +} + +// optionalT is used to identify a parameter of the Optional type. +type optionalT interface { + with(val any) any +} + +// typeNameOf returns a readable type name for the interface value passed. If none could be found, 'value' +// is returned. +func typeNameOf(i any, name string) string { + switch i.(type) { + case int, int8, int16, int32, int64: + return "int" + case uint, uint8, uint16, uint32, uint64: + return "uint" + case float32, float64: + return "float" + case string: + return "string" + case Varargs: + return "text" + case bool: + return "bool" + case mgl64.Vec3: + return "x y z" + case []Target: + return "target" + case SubCommand: + return name + } + if param, ok := i.(Parameter); ok { + return param.Type() + } + if enum, ok := i.(Enum); ok { + return enum.Type() + } + return "value" +} + +// unwrap returns the underlying reflect.Value of a reflect.Value, assuming it is of the Optional[T] type. +func unwrap(v reflect.Value) reflect.Value { + if _, ok := v.Interface().(optionalT); ok { + return reflect.New(v.Field(0).Type()).Elem() + } + return v +} + +// optional checks if the reflect.Value passed implements the optionalT interface. +func optional(v reflect.Value) bool { + _, ok := v.Interface().(optionalT) + return ok +} + +// suffix returns the suffix of the parameter as set in the struct field, if any. +func suffix(v reflect.StructField) string { + _, str := tag(v) + return str +} + +// name returns the name of the parameter as set in the struct tag if it exists, or the field's name if not. +func name(v reflect.StructField) string { + str, _ := tag(v) + if str == "" { + return v.Name + } + return str +} + +// tag returns the name and suffix as specified in the 'cmd' tag, or empty strings if not present. +func tag(v reflect.StructField) (name string, suffix string) { + t, _ := v.Tag.Lookup("cmd") + a, b, _ := strings.Cut(t, ",") + return a, b +} diff --git a/server/cmd/register.go b/server/cmd/register.go new file mode 100644 index 0000000..1516e40 --- /dev/null +++ b/server/cmd/register.go @@ -0,0 +1,35 @@ +package cmd + +import "sync" + +// commands holds a list of registered commands indexed by their name. +var commands sync.Map + +// Register registers a command with its name and all aliases that it has. Any command with the same name or +// aliases will be overwritten. +func Register(command Command) { + commands.Store(command.name, command) + for _, alias := range command.aliases { + commands.Store(alias, command) + } +} + +// ByAlias looks up a command by an alias. If found, the command and true are returned. If not, the returned +// command is nil and the bool is false. +func ByAlias(alias string) (Command, bool) { + command, ok := commands.Load(alias) + if !ok { + return Command{}, false + } + return command.(Command), ok +} + +// Commands returns a map of all registered commands indexed by the alias they were registered with. +func Commands() map[string]Command { + cmd := make(map[string]Command) + commands.Range(func(key, value any) bool { + cmd[key.(string)] = value.(Command) + return true + }) + return cmd +} diff --git a/server/cmd/source.go b/server/cmd/source.go new file mode 100644 index 0000000..f25b157 --- /dev/null +++ b/server/cmd/source.go @@ -0,0 +1,12 @@ +package cmd + +// Source represents a source of a command execution. Commands may limit the sources that can run them by +// implementing the Allower interface. +// Source implements Target. A Source must always be able to target itself. +type Source interface { + Target + // SendCommandOutput sends a command output to the source. The way the output is applied, depends on what + // kind of source it is. + // SendCommandOutput is called by a Command automatically after being run. + SendCommandOutput(o *Output) +} diff --git a/server/cmd/target.go b/server/cmd/target.go new file mode 100644 index 0000000..088dfdb --- /dev/null +++ b/server/cmd/target.go @@ -0,0 +1,29 @@ +package cmd + +import ( + "github.com/df-mc/dragonfly/server/internal/sliceutil" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" + "slices" +) + +// Target represents the target of a command. A []Target may be used as command parameter +// types to allow passing targets to the command. +type Target interface { + // Position returns the position of the Target as an mgl64.Vec3. + Position() mgl64.Vec3 +} + +// NamedTarget is a Target that has a name. +type NamedTarget interface { + Target + // Name returns a name of the Target. Note that this name needs not to be and is not unique for a Target. + Name() string +} + +// targets returns all Targets selectable by the Source passed. +func targets(tx *world.Tx) (entities []Target, players []NamedTarget) { + ent := sliceutil.Convert[Target](slices.Collect(tx.Entities())) + pl := sliceutil.Convert[NamedTarget](slices.Collect(tx.Players())) + return ent, pl +} diff --git a/server/conf.go b/server/conf.go new file mode 100644 index 0000000..16acc68 --- /dev/null +++ b/server/conf.go @@ -0,0 +1,369 @@ +package server + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "slices" + "time" + _ "unsafe" + + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/entity" + "github.com/df-mc/dragonfly/server/internal/packbuilder" + "github.com/df-mc/dragonfly/server/player" + "github.com/df-mc/dragonfly/server/player/chat" + "github.com/df-mc/dragonfly/server/player/playerdb" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/biome" + "github.com/df-mc/dragonfly/server/world/generator" + "github.com/df-mc/dragonfly/server/world/mcdb" + "github.com/google/uuid" + "github.com/sandertv/gophertunnel/minecraft" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" + "github.com/sandertv/gophertunnel/minecraft/resource" +) + +// Config contains options for starting a Minecraft server. +type Config struct { + // Log is the Logger to use for logging information. If nil, Log is set to + // slog.Default(). Errors reported by the underlying network are only logged + // if Log has at least debug level. + Log *slog.Logger + // Listeners is a list of functions to create a Listener using a Config, one + // for each Listener to be added to the Server. If left empty, no players + // will be able to connect to the Server. + Listeners []func(conf Config) (Listener, error) + // Name is the name of the server. By default, it is shown to users in the + // server list before joining the server and when opening the in-game menu. + Name string + // Resources is a slice of resource packs to use on the server. When joining + // the server, the player will then first be requested to download these + // resource packs. + Resources []*resource.Pack + // ResourcesRequires specifies if the downloading of resource packs is + // required to join the server. If set to true, players will not be able to + // join without first downloading and applying the Resources above. + ResourcesRequired bool + // DisableResourceBuilding specifies if automatic resource pack building for + // custom items should be disabled. Dragonfly, by default, automatically + // produces a resource pack for custom items. If this is not desired (for + // example if a resource pack already exists), this can be set to false. + DisableResourceBuilding bool + // Allower may be used to specify what players can join the server and what + // players cannot. By returning false in the Allow method, for example if + // the player has been banned, will prevent the player from joining. + Allower Allower + // AuthDisabled specifies if XBOX Live authentication should be disabled. + // Note that this should generally only be done for testing purposes or for + // local games. Allowing players to join without authentication is generally + // a security hazard. + AuthDisabled bool + // MuteEmoteChat specifies if the player emote chat should be muted or not. + MuteEmoteChat bool + // MaxPlayers is the maximum amount of players allowed to join the server at + // once. + MaxPlayers int + // MaxChunkRadius is the maximum view distance that each player may have, + // measured in chunks. A chunk radius generally leads to more memory usage. + MaxChunkRadius int + // JoinMessage, QuitMessage and ShutdownMessage are the messages to send for + // when a player joins or quits the server and when the server shuts down, + // kicking all online players. If set, JoinMessage and QuitMessage must have + // exactly 1 argument, which will be replaced with the name of the player + // joining or quitting. + // ShutdownMessage is set to chat.MessageServerDisconnect if empty. + JoinMessage, QuitMessage, ShutdownMessage chat.Translation + // StatusProvider provides the server status shown to players in the server + // list. By default, StatusProvider will show the server name from the Name + // field and the current player count and maximum players. + StatusProvider minecraft.ServerStatusProvider + // Compression is the packet compression used for connections accepted by + // the default listener. If nil, gophertunnel's default compression is used. + Compression packet.Compression + // PlayerProvider is the player.Provider used for storing and loading player + // data. If left as nil, player data will be newly created every time a + // player joins the server and no data will be stored. + PlayerProvider player.Provider + // WorldProvider is the world.Provider used for storing and loading world + // data. If left as nil, world data will be newly created every time and + // chunks will always be newly generated when loaded. The world provider + // will be used for storing/loading the default overworld, nether and end. + WorldProvider world.Provider + // ReadOnlyWorld specifies if the standard worlds should be read only. If + // set to true, the WorldProvider won't be saved to at all. + ReadOnlyWorld bool + // Generator should return a function that specifies the world.Generator to + // use for every world.Dimension (world.Overworld, world.Nether and + // world.End). If left empty, Generator will be set to a flat world for each + // of the dimensions (with netherrack and end stone for nether/end + // respectively). + Generator func(dim world.Dimension) world.Generator + // RandomTickSpeed specifies the rate at which blocks should be ticked in + // the default worlds. Setting this value to -1 or lower will stop random + // ticking altogether, while setting it higher results in faster ticking. If + // left as 0, the RandomTickSpeed will default to a speed of 3 blocks per + // sub chunk per tick (normal ticking speed). + RandomTickSpeed int + // SaveInterval specifies how often a World should be automatically saved to + // disk. This includes chunks, entities and level.dat data. If ReadOnlyWorld + // is set to true, changing SaveInterval will have no effect. + // By default, SaveInterval is set to 10 minutes. Setting SaveInterval to + // a negative number disables automatic saving entirely. + SaveInterval time.Duration + // ChunkUnloadInterval specifies how often unused chunks should be unloaded + // from memory when no longer in use. By default, this is set to 2 minutes. + // ChunkUnloadInterval should not be used to prevent chunks from unloading + // altogether. This should be done using a Loader with a custom Viewer. + ChunkUnloadInterval time.Duration + // Entities is a world.EntityRegistry with all entity types registered that + // may be added to the Server's worlds. If no entity types are registered, + // Entities will be set to entity.DefaultRegistry. + Entities world.EntityRegistry + // Blocks is the BlockRegistry template used for newly created worlds. If nil, world.DefaultBlockRegistry is used. + // For a non-default registry, set this to world.NewBlockRegistry(), register blocks on that instance, and ensure + // it is finalized before use. + Blocks world.BlockRegistry +} + +// New creates a Server using fields of conf. The Server's worlds are created +// and connections from the Server's listeners may be accepted by calling +// Server.Listen() and Server.Accept() afterwards. +func (conf Config) New() *Server { + if conf.Log == nil { + conf.Log = slog.Default() + } + if len(conf.Listeners) == 0 { + conf.Log.Warn("config: no listeners set, no connections will be accepted") + } + if conf.Name == "" { + conf.Name = "Dragonfly Server" + } + if conf.StatusProvider == nil { + conf.StatusProvider = statusProvider{name: conf.Name} + } + if conf.PlayerProvider == nil { + conf.PlayerProvider = player.NopProvider{} + } + if conf.Allower == nil { + conf.Allower = allower{} + } + if conf.WorldProvider == nil { + conf.WorldProvider = world.NopProvider{} + } + if conf.Generator == nil { + conf.Generator = loadGenerator + } + if conf.MaxChunkRadius == 0 { + conf.MaxChunkRadius = 12 + } + if conf.ShutdownMessage.Zero() { + conf.ShutdownMessage = chat.MessageServerDisconnect + } + if len(conf.Entities.Types()) == 0 { + conf.Entities = entity.DefaultRegistry + } + if conf.Blocks == nil { + conf.Blocks = world.DefaultBlockRegistry + } + + // Initialize the passed block registry and also initialize the default block registry which + // is used in some vanilla paths. + conf.Blocks.Finalize() + world.DefaultBlockRegistry.Finalize() + + if !conf.DisableResourceBuilding { + if pack, ok := packbuilder.BuildResourcePack(conf.Blocks); ok { + conf.Resources = append(conf.Resources, pack) + } + } + // Copy resources so that the slice can't be edited afterward. + conf.Resources = slices.Clone(conf.Resources) + + srv := &Server{ + conf: conf, + incoming: make(chan incoming), + p: make(map[uuid.UUID]*onlinePlayer), + world: &world.World{}, nether: &world.World{}, end: &world.World{}, + } + for _, lf := range conf.Listeners { + l, err := lf(conf) + if err != nil { + conf.Log.Error("create listener: " + err.Error()) + } + srv.listeners = append(srv.listeners, l) + } + + creative_registerCreativeItems() + recipe_registerVanilla() + + srv.world = srv.createWorld(world.Overworld, &srv.nether, &srv.end) + srv.nether = srv.createWorld(world.Nether, &srv.world, &srv.end) + srv.end = srv.createWorld(world.End, &srv.nether, &srv.world) + + return srv +} + +// UserConfig is the user configuration for a Dragonfly server. It holds +// settings that affect different aspects of the server, such as its name and +// maximum players. UserConfig may be serialised and can be converted to a +// Config by calling UserConfig.Config(). +type UserConfig struct { + // Network holds settings related to network aspects of the server. + Network struct { + // Address is the address on which the server should listen. Players may + // connect to this address in order to join. + Address string + } + Server struct { + // Name is the name of the server as it shows up in the server list. + Name string + // AuthEnabled controls whether players must be connected to Xbox Live + // in order to join the server. + AuthEnabled bool + // DisableJoinQuitMessages specifies if default join and quit messages + // for players should be disabled. + DisableJoinQuitMessages bool + // MuteEmoteChat specifies if the player emote chat should be muted or not. + MuteEmoteChat bool + } + World struct { + // SaveData controls whether a world's data will be saved and loaded. + // If true, the server will use the default LevelDB data provider and if + // false, an empty provider will be used. To use your own provider, turn + // this value to false, as you will still be able to pass your own + // provider. + SaveData bool + // Folder is the folder that the data of the world resides in. + Folder string + } + Players struct { + // MaxCount is the maximum amount of players allowed to join the server + // at the same time. If set to 0, the amount of maximum players will + // grow every time a player joins. + MaxCount int + // MaximumChunkRadius is the maximum chunk radius that players may set + // in their settings. If they try to set it above this number, it will + // be capped and set to the max. + MaximumChunkRadius int + // SaveData controls whether a player's data will be saved and loaded. + // If true, the server will use the default LevelDB data provider and if + // false, an empty provider will be used. To use your own provider, turn + // this value to false, as you will still be able to pass your own + // provider. + SaveData bool + // Folder controls where the player data will be stored by the default + // LevelDB player provider if it is enabled. + Folder string + } + Resources struct { + // AutoBuildPack is if the server should automatically generate a + // resource pack for custom features. + AutoBuildPack bool + // Folder controls the location where resource packs will be loaded + // from. + Folder string + // Required is a boolean to force the client to load the resource pack + // on join. If they do not accept, they'll have to leave the server. + Required bool + } +} + +// Config converts a UserConfig to a Config, so that it may be used for creating +// a Server. An error is returned if creating data providers or loading +// resources failed. +func (uc UserConfig) Config(log *slog.Logger) (Config, error) { + var err error + conf := Config{ + Log: log, + Name: uc.Server.Name, + ResourcesRequired: uc.Resources.Required, + AuthDisabled: !uc.Server.AuthEnabled, + MuteEmoteChat: uc.Server.MuteEmoteChat, + MaxPlayers: uc.Players.MaxCount, + MaxChunkRadius: uc.Players.MaximumChunkRadius, + DisableResourceBuilding: !uc.Resources.AutoBuildPack, + } + if !uc.Server.DisableJoinQuitMessages { + conf.JoinMessage, conf.QuitMessage = chat.MessageJoin, chat.MessageQuit + } + if uc.World.SaveData { + conf.WorldProvider, err = mcdb.Config{Log: log}.Open(uc.World.Folder) + if err != nil { + return conf, fmt.Errorf("create world provider: %w", err) + } + } + conf.Resources, err = loadResources(uc.Resources.Folder) + if err != nil { + return conf, fmt.Errorf("load resources: %w", err) + } + if uc.Players.SaveData { + conf.PlayerProvider, err = playerdb.NewProvider(uc.Players.Folder) + if err != nil { + return conf, fmt.Errorf("create player provider: %w", err) + } + } + conf.Listeners = append(conf.Listeners, uc.listenerFunc) + return conf, nil +} + +// loadResources loads all resource packs found in a directory passed. +func loadResources(dir string) ([]*resource.Pack, error) { + _ = os.MkdirAll(dir, 0777) + + resources, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("read dir: %w", err) + } + packs := make([]*resource.Pack, len(resources)) + for i, entry := range resources { + packs[i], err = resource.ReadPath(filepath.Join(dir, entry.Name())) + if err != nil { + return nil, fmt.Errorf("compile resource (%v): %w", entry.Name(), err) + } + } + return packs, nil +} + +// loadGenerator loads a standard world.Generator for a world.Dimension. The +// generators returned are flat generators with grass/dirt, netherrack or end +// stone depending on the dimension passed. +func loadGenerator(dim world.Dimension) world.Generator { + switch dim { + case world.Overworld: + return generator.NewFlat(biome.Plains{}, []world.Block{block.Grass{}, block.Dirt{}, block.Dirt{}, block.Bedrock{}}) + case world.Nether: + return generator.NewFlat(biome.NetherWastes{}, []world.Block{block.Netherrack{}, block.Netherrack{}, block.Netherrack{}, block.Bedrock{}}) + case world.End: + return generator.NewFlat(biome.End{}, []world.Block{block.EndStone{}, block.EndStone{}, block.EndStone{}, block.Bedrock{}}) + } + panic("should never happen") +} + +// DefaultConfig returns a configuration with the default values filled out. +func DefaultConfig() UserConfig { + c := UserConfig{} + c.Network.Address = ":19132" + c.Server.Name = "Dragonfly Server" + c.Server.AuthEnabled = true + c.World.SaveData = true + c.World.Folder = "world" + c.Players.MaximumChunkRadius = 32 + c.Players.SaveData = true + c.Players.Folder = "players" + c.Resources.AutoBuildPack = true + c.Resources.Folder = "resources" + c.Resources.Required = false + return c +} + +// noinspection ALL +// +//go:linkname creative_registerCreativeItems github.com/df-mc/dragonfly/server/item/creative.registerCreativeItems +func creative_registerCreativeItems() + +// noinspection ALL +// +//go:linkname recipe_registerVanilla github.com/df-mc/dragonfly/server/item/recipe.registerVanilla +func recipe_registerVanilla() diff --git a/server/doc.go b/server/doc.go new file mode 100644 index 0000000..fae665f --- /dev/null +++ b/server/doc.go @@ -0,0 +1,13 @@ +// Package server implements a high-level implementation of a Minecraft server. +// Creating such a server may be done using the `server.New()` function. +// Additional configuration may be passed by using server.Config{...}.New(). +// `Server.Listen()` may be called to start and run the server. It should be +// followed up by a loop as such: +// +// for p := range srv.Accept() { +// // Use p +// } +// +// `Server.Accept()` blocks until a new player connects to the server and +// spawns in the default world. +package server diff --git a/server/entity/action.go b/server/entity/action.go new file mode 100644 index 0000000..ccfe32d --- /dev/null +++ b/server/entity/action.go @@ -0,0 +1,66 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/world" + "time" +) + +// SwingArmAction is a world.EntityAction that makes an entity or player swing its arm. +type SwingArmAction struct{ action } + +// HurtAction is a world.EntityAction that makes an entity display the animation for being hurt. The entity will be +// shown as red for a short duration. +type HurtAction struct{ action } + +// CriticalHitAction is a world.EntityAction that makes an entity display critical hit particles. This will show stars +// around the entity. +type CriticalHitAction struct { + action + // Count is the count of particles around the entity. + Count int +} + +// EnchantedHitAction is a world.Action that makes an entity display enchanted hit particles. This will show circles +// around the entity. +type EnchantedHitAction struct { + action + // Count is the count of particles around the entity. + Count int +} + +// DeathAction is a world.EntityAction that makes an entity display the death animation. After this animation, the +// entity disappears from viewers watching it. +type DeathAction struct{ action } + +// EatAction is a world.EntityAction that makes an entity display the eating particles at its mouth to viewers with the +// item in its hand being eaten. +type EatAction struct{ action } + +// ArrowShakeAction makes an arrow entity display a shaking animation for the given duration. +type ArrowShakeAction struct { + // Duration is the duration of the shake. + Duration time.Duration + + action +} + +// PickedUpAction is a world.EntityAction that makes an item get picked up by a collector. After this animation, the +// item disappears from viewers watching it. +type PickedUpAction struct { + // Collector is the entity that collected the item. + Collector world.Entity + + action +} + +// FireworkExplosionAction is a world.EntityAction that makes a Firework rocket display an explosion particle. +type FireworkExplosionAction struct{ action } + +// TotemUseAction is a world.EntityAction that displays the totem use particles and animation. +type TotemUseAction struct{ action } + +// action implements the Action interface. Structures in this package may embed it to gets its functionality +// out of the box. +type action struct{} + +func (action) EntityAction() {} diff --git a/server/entity/area_effect_cloud.go b/server/entity/area_effect_cloud.go new file mode 100644 index 0000000..1599855 --- /dev/null +++ b/server/entity/area_effect_cloud.go @@ -0,0 +1,83 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/item/potion" + "github.com/df-mc/dragonfly/server/world" + "time" +) + +// NewAreaEffectCloud creates a new area effect cloud entity and returns it. +func NewAreaEffectCloud(opts world.EntitySpawnOpts, p potion.Potion) *world.EntityHandle { + config := areaEffectCloudConf + config.Potion = p + for _, e := range p.Effects() { + if _, ok := e.Type().(effect.LastingType); !ok { + config.ReapplicationDelay = 0 + break + } + } + return opts.New(AreaEffectCloudType, config) +} + +var areaEffectCloudConf = AreaEffectCloudBehaviourConfig{ + RadiusUseGrowth: -0.5, + RadiusTickGrowth: -0.005, + ReapplicationDelay: time.Second * 2, +} + +// NewAreaEffectCloudWith ... +func NewAreaEffectCloudWith(opts world.EntitySpawnOpts, t potion.Potion, duration, reapplicationDelay, durationOnUse time.Duration, radius, radiusOnUse, radiusGrowth float64) *world.EntityHandle { + config := AreaEffectCloudBehaviourConfig{ + Potion: t, + Radius: radius, + RadiusUseGrowth: radiusOnUse, + RadiusTickGrowth: radiusGrowth, + Duration: duration, + DurationUseGrowth: durationOnUse, + ReapplicationDelay: reapplicationDelay, + } + return opts.New(AreaEffectCloudType, config) +} + +// AreaEffectCloudType is a world.EntityType implementation for AreaEffectCloud. +var AreaEffectCloudType areaEffectCloudType + +type areaEffectCloudType struct{} + +func (t areaEffectCloudType) Open(tx *world.Tx, handle *world.EntityHandle, data *world.EntityData) world.Entity { + return &Ent{tx: tx, handle: handle, data: data} +} + +func (areaEffectCloudType) EncodeEntity() string { return "minecraft:area_effect_cloud" } +func (areaEffectCloudType) BBox(e world.Entity) cube.BBox { + r := e.(*Ent).Behaviour().(*AreaEffectCloudBehaviour).Radius() + return cube.Box(-r, 0, -r, r, 0.5, r) +} + +func (areaEffectCloudType) DecodeNBT(m map[string]any, data *world.EntityData) { + data.Data = AreaEffectCloudBehaviourConfig{ + Potion: potion.From(nbtconv.Int32(m, "PotionId")), + Radius: float64(nbtconv.Float32(m, "Radius")), + RadiusUseGrowth: float64(nbtconv.Float32(m, "RadiusOnUse")), + RadiusTickGrowth: float64(nbtconv.Float32(m, "RadiusPerTick")), + Duration: nbtconv.TickDuration[int32](m, "Duration"), + DurationUseGrowth: nbtconv.TickDuration[int32](m, "ReapplicationDelay"), + ReapplicationDelay: nbtconv.TickDuration[int32](m, "DurationOnUse"), + }.New() +} + +func (areaEffectCloudType) EncodeNBT(data *world.EntityData) map[string]any { + a := data.Data.(*AreaEffectCloudBehaviour) + return map[string]any{ + "PotionId": int32(a.conf.Potion.Uint8()), + "ReapplicationDelay": int32(a.conf.ReapplicationDelay), + "RadiusPerTick": float32(a.conf.RadiusTickGrowth), + "RadiusOnUse": float32(a.conf.RadiusUseGrowth), + "DurationOnUse": int32(a.conf.DurationUseGrowth), + "Radius": float32(a.radius), + "Duration": int32(a.duration), + } +} diff --git a/server/entity/area_effect_cloud_behaviour.go b/server/entity/area_effect_cloud_behaviour.go new file mode 100644 index 0000000..056b944 --- /dev/null +++ b/server/entity/area_effect_cloud_behaviour.go @@ -0,0 +1,182 @@ +package entity + +import ( + "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 +// effect cloud entity. +type AreaEffectCloudBehaviourConfig struct { + Potion potion.Potion + // Radius specifies the initial radius of the cloud. Defaults to 3.0. + Radius float64 + // RadiusUseGrowth is the value that is added to the radius every time the + // effect cloud is used/consumed. This is typically a negative value. (-0.5) + RadiusUseGrowth float64 + // RadiusTickGrowth is the value added to the radius every tick. This is + // typically a negative value. (-0.005) + RadiusTickGrowth float64 + // Duration specifies the initial duration of the cloud. Defaults to 30s. + Duration time.Duration + // DurationUseGrowth is the duration that is added to the effect cloud every + // time it is used/consumed. This is 0 in normal situations. + DurationUseGrowth time.Duration + // ReapplicationDelay specifies the delay with which the effects from the + // cloud can be re-applied to users. + ReapplicationDelay time.Duration +} + +func (conf AreaEffectCloudBehaviourConfig) Apply(data *world.EntityData) { + data.Data = conf.New() +} + +// New creates an AreaEffectCloudBehaviour using the parameter in conf and t. +func (conf AreaEffectCloudBehaviourConfig) New() *AreaEffectCloudBehaviour { + if conf.Radius == 0 { + conf.Radius = 3.0 + } + if conf.Duration == 0 { + conf.Duration = time.Second * 30 + } + stationary := StationaryBehaviourConfig{ExistenceDuration: conf.Duration} + return &AreaEffectCloudBehaviour{ + conf: conf, + stationary: stationary.New(), + duration: conf.Duration, + radius: conf.Radius, + targets: make(map[*world.EntityHandle]time.Duration), + } +} + +// AreaEffectCloudBehaviour is the cloud that is created when: lingering +// potions are thrown; creepers with potion effects explode; dragon fireballs +// hit the ground. +type AreaEffectCloudBehaviour struct { + conf AreaEffectCloudBehaviourConfig + + stationary *StationaryBehaviour + + duration time.Duration + radius float64 + targets map[*world.EntityHandle]time.Duration +} + +// Radius returns the current radius of the area effect cloud. +func (a *AreaEffectCloudBehaviour) Radius() float64 { + return a.radius +} + +// Effects returns the effects the area effect cloud provides. +func (a *AreaEffectCloudBehaviour) Effects() []effect.Effect { + return a.conf.Potion.Effects() +} + +// Tick ... +func (a *AreaEffectCloudBehaviour) Tick(e *Ent, tx *world.Tx) *Movement { + a.stationary.Tick(e, tx) + if a.stationary.close || e.Age() < time.Second/2 { + // The cloud lives for at least half a second before it may begin + // spreading effects and growing/shrinking. + return nil + } + + pos := e.Position() + if a.subtractTickRadius() { + for _, v := range tx.Viewers(pos) { + v.ViewEntityState(e) + } + } + + if int16(e.Age()/(time.Second*20))%10 != 0 { + // Area effect clouds only trigger updates every ten ticks. + return nil + } + + for target, expiration := range a.targets { + if e.Age() >= expiration { + delete(a.targets, target) + } + } + if a.applyEffects(pos, e, a.filter(tx.EntitiesWithin(e.H().Type().BBox(e).Translate(pos)))) { + for _, v := range tx.Viewers(pos) { + v.ViewEntityState(e) + } + } + return nil +} + +func (a *AreaEffectCloudBehaviour) filter(seq iter.Seq[world.Entity]) iter.Seq[world.Entity] { + return func(yield func(world.Entity) bool) { + for e := range seq { + _, target := a.targets[e.H()] + _, living := e.(Living) + if !living || target { + continue + } + if !yield(e) { + return + } + } + } +} + +// applyEffects applies the effects of an area effect cloud at pos to all +// entities passed if they were within the radius and don't have an active +// cooldown period. +func (a *AreaEffectCloudBehaviour) applyEffects(pos mgl64.Vec3, ent *Ent, entities iter.Seq[world.Entity]) bool { + var update bool + for e := range entities { + delta := e.Position().Sub(pos) + delta[1] = 0 + if delta.Len() <= a.radius { + l := e.(Living) + for _, eff := range a.Effects() { + if lasting, ok := eff.Type().(effect.LastingType); ok { + l.AddEffect(effect.New(lasting, eff.Level(), eff.Duration()/4)) + continue + } + l.AddEffect(eff) + } + + a.targets[e.H()] = ent.Age() + a.conf.ReapplicationDelay + a.subtractUseDuration() + a.subtractUseRadius() + + update = true + } + } + return update +} + +// subtractTickRadius grows the cloud's radius by the radiusTickGrowth value. If the +// radius goes under 1/2, it will close the entity. +func (a *AreaEffectCloudBehaviour) subtractTickRadius() bool { + a.radius += a.conf.RadiusTickGrowth + if a.radius < 0.5 { + a.stationary.close = true + } + return a.conf.RadiusTickGrowth != 0 +} + +// subtractUseDuration grows duration by the durationUseGrowth factor. If duration +// goes under zero, it will close the entity. +func (a *AreaEffectCloudBehaviour) subtractUseDuration() { + a.duration += a.conf.DurationUseGrowth + if a.duration <= 0 { + a.stationary.close = true + } +} + +// subtractUseRadius grows radius by the radiusUseGrowth factor. If radius goes +// under 1/2, it will close the entity. +func (a *AreaEffectCloudBehaviour) subtractUseRadius() { + a.radius += a.conf.RadiusUseGrowth + if a.radius <= 0.5 { + a.stationary.close = true + } +} diff --git a/server/entity/arrow.go b/server/entity/arrow.go new file mode 100644 index 0000000..23714f9 --- /dev/null +++ b/server/entity/arrow.go @@ -0,0 +1,98 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/enchantment" + "github.com/df-mc/dragonfly/server/item/potion" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" +) + +// NewArrow creates a new Arrow and returns it. It is equivalent to calling NewTippedArrow with `potion.Potion{}` as +// tip. +func NewArrow(opts world.EntitySpawnOpts, owner world.Entity) *world.EntityHandle { + return NewTippedArrowWithDamage(opts, 2.0, owner, potion.Potion{}) +} + +// NewArrowWithDamage creates a new Arrow with the given base damage, and returns it. It is equivalent to calling +// NewTippedArrowWithDamage with `potion.Potion{}` as tip. +func NewArrowWithDamage(opts world.EntitySpawnOpts, damage float64, owner world.Entity) *world.EntityHandle { + return NewTippedArrowWithDamage(opts, damage, owner, potion.Potion{}) +} + +// NewTippedArrow creates a new Arrow with a potion effect added to an entity when hit. +func NewTippedArrow(opts world.EntitySpawnOpts, owner world.Entity, tip potion.Potion) *world.EntityHandle { + return NewTippedArrowWithDamage(opts, 2.0, owner, tip) +} + +// NewTippedArrowWithDamage creates a new Arrow with a potion effect added to an entity when hit and, and returns it. +// It uses the given damage as the base damage. +func NewTippedArrowWithDamage(opts world.EntitySpawnOpts, damage float64, owner world.Entity, tip potion.Potion) *world.EntityHandle { + conf := arrowConf + conf.Damage = damage + conf.Potion = tip + conf.Owner = owner.H() + return opts.New(ArrowType, conf) +} + +var arrowConf = ProjectileBehaviourConfig{ + Gravity: 0.05, + Drag: 0.01, + Damage: 2.0, + Sound: sound.ArrowHit{}, + SurviveBlockCollision: true, +} + +// boolByte returns 1 if the bool passed is true, or 0 if it is false. +func boolByte(b bool) uint8 { + if b { + return 1 + } + return 0 +} + +// ArrowType is a world.EntityType implementation for Arrow. +var ArrowType arrowType + +type arrowType struct{} + +func (t arrowType) Open(tx *world.Tx, handle *world.EntityHandle, data *world.EntityData) world.Entity { + return &Ent{tx: tx, handle: handle, data: data} +} + +func (arrowType) EncodeEntity() string { return "minecraft:arrow" } +func (arrowType) BBox(world.Entity) cube.BBox { + return cube.Box(-0.125, 0, -0.125, 0.125, 0.25, 0.125) +} + +func (arrowType) DecodeNBT(m map[string]any, data *world.EntityData) { + conf := arrowConf + conf.Damage = float64(nbtconv.Float32(m, "Damage")) + conf.Potion = potion.From(nbtconv.Int32(m, "auxValue") - 1) + conf.DisablePickup = !nbtconv.Bool(m, "player") + if !nbtconv.Bool(m, "isCreative") { + conf.PickupItem = item.NewStack(item.Arrow{Tip: conf.Potion}, 1) + } + conf.KnockBackForceAddend = enchantment.Punch.KnockBackMultiplier() * float64(nbtconv.Uint8(m, "enchantPunch")) + conf.CollisionPosition = nbtconv.Pos(m, "StuckToBlockPos") + + data.Data = conf.New() +} + +func (arrowType) EncodeNBT(data *world.EntityData) map[string]any { + b := data.Data.(*ProjectileBehaviour) + m := map[string]any{ + "Damage": float32(b.conf.Damage), + "enchantPunch": byte(b.conf.KnockBackForceAddend / enchantment.Punch.KnockBackMultiplier()), + "auxValue": int32(b.conf.Potion.Uint8() + 1), + "player": boolByte(!b.conf.DisablePickup), + "isCreative": boolByte(b.conf.PickupItem.Empty()), + } + // TODO: Save critical flag if Minecraft ever saves it? + if b.collided { + m["StuckToBlockPos"] = nbtconv.PosToInt32Slice(b.collisionPos) + } + return m +} diff --git a/server/entity/bottle_of_enchanting.go b/server/entity/bottle_of_enchanting.go new file mode 100644 index 0000000..153c604 --- /dev/null +++ b/server/entity/bottle_of_enchanting.go @@ -0,0 +1,63 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/block/cube/trace" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/particle" + "github.com/df-mc/dragonfly/server/world/sound" + "math/rand/v2" +) + +// NewBottleOfEnchanting ... +func NewBottleOfEnchanting(opts world.EntitySpawnOpts, owner world.Entity) *world.EntityHandle { + conf := bottleOfEnchantingConf + conf.Owner = owner.H() + return opts.New(BottleOfEnchantingType, conf) +} + +var bottleOfEnchantingConf = ProjectileBehaviourConfig{ + Gravity: 0.07, + Drag: 0.01, + Particle: particle.Splash{}, + Sound: sound.GlassBreak{}, + Hit: spawnExperience, + Damage: -1, +} + +// spawnExperience spawns experience orbs with a value of 3-11 at the target of +// a trace.Result. +func spawnExperience(_ *Ent, tx *world.Tx, target trace.Result) { + for _, orb := range NewExperienceOrbs(target.Position(), rand.IntN(9)+3) { + tx.AddEntity(orb) + } +} + +// BottleOfEnchantingType is a world.EntityType for BottleOfEnchanting. +var BottleOfEnchantingType bottleOfEnchantingType + +type bottleOfEnchantingType struct{} + +func (t bottleOfEnchantingType) Open(tx *world.Tx, handle *world.EntityHandle, data *world.EntityData) world.Entity { + return &Ent{tx: tx, handle: handle, data: data} +} + +// Glint returns true if the bottle should render with glint. It always returns +// true for bottles of enchanting. +func (bottleOfEnchantingType) Glint() bool { + return true +} +func (bottleOfEnchantingType) EncodeEntity() string { + return "minecraft:xp_bottle" +} +func (bottleOfEnchantingType) BBox(world.Entity) cube.BBox { + return cube.Box(-0.125, 0, -0.125, 0.125, 0.25, 0.125) +} + +func (bottleOfEnchantingType) DecodeNBT(_ map[string]any, data *world.EntityData) { + data.Data = bottleOfEnchantingConf.New() +} + +func (bottleOfEnchantingType) EncodeNBT(_ *world.EntityData) map[string]any { + return nil +} diff --git a/server/entity/damage.go b/server/entity/damage.go new file mode 100644 index 0000000..1103786 --- /dev/null +++ b/server/entity/damage.go @@ -0,0 +1,95 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/enchantment" + "github.com/df-mc/dragonfly/server/world" +) + +type ( + // AttackDamageSource is used for damage caused by other entities, for + // example when a player attacks another player. + AttackDamageSource struct { + // Attacker holds the attacking entity. The entity may be a player or + // any other entity. + Attacker world.Entity + } + + // VoidDamageSource is used for damage caused by an entity being in the + // void. + VoidDamageSource struct{} + + // SuffocationDamageSource is used for damage caused by an entity + // suffocating in a block. + SuffocationDamageSource struct{} + + // DrowningDamageSource is used for damage caused by an entity drowning in + // water. + DrowningDamageSource struct{} + + // FallDamageSource is used for damage caused by falling. + FallDamageSource struct{} + + // GlideDamageSource is used for damage caused by gliding into a block. + GlideDamageSource struct{} + + // LightningDamageSource is used for damage caused by being struck by + // lightning. + LightningDamageSource struct{} + + // ProjectileDamageSource is used for damage caused by a projectile. + ProjectileDamageSource struct { + // Projectile and Owner are the world.Entity that dealt the damage and + // the one that fired the projectile respectively. + Projectile, Owner world.Entity + } + + // ExplosionDamageSource is used for damage caused by an explosion. + ExplosionDamageSource struct{} +) + +func (FallDamageSource) ReducedByArmour() bool { return false } +func (FallDamageSource) ReducedByResistance() bool { return true } +func (FallDamageSource) Fire() bool { return false } +func (FallDamageSource) AffectedByEnchantment(e item.EnchantmentType) bool { + return e == enchantment.FeatherFalling +} +func (FallDamageSource) IgnoreTotem() bool { return false } +func (GlideDamageSource) ReducedByArmour() bool { return false } +func (GlideDamageSource) ReducedByResistance() bool { return true } +func (GlideDamageSource) Fire() bool { return false } +func (GlideDamageSource) IgnoreTotem() bool { return false } +func (LightningDamageSource) ReducedByArmour() bool { return true } +func (LightningDamageSource) ReducedByResistance() bool { return true } +func (LightningDamageSource) Fire() bool { return false } +func (LightningDamageSource) IgnoreTotem() bool { return false } +func (AttackDamageSource) ReducedByArmour() bool { return true } +func (AttackDamageSource) ReducedByResistance() bool { return true } +func (AttackDamageSource) Fire() bool { return false } +func (AttackDamageSource) IgnoreTotem() bool { return false } +func (VoidDamageSource) ReducedByResistance() bool { return false } +func (VoidDamageSource) ReducedByArmour() bool { return false } +func (VoidDamageSource) Fire() bool { return false } +func (VoidDamageSource) IgnoreTotem() bool { return true } +func (SuffocationDamageSource) ReducedByResistance() bool { return false } +func (SuffocationDamageSource) ReducedByArmour() bool { return false } +func (SuffocationDamageSource) Fire() bool { return false } +func (SuffocationDamageSource) IgnoreTotem() bool { return false } +func (DrowningDamageSource) ReducedByResistance() bool { return false } +func (DrowningDamageSource) ReducedByArmour() bool { return false } +func (DrowningDamageSource) Fire() bool { return false } +func (DrowningDamageSource) IgnoreTotem() bool { return false } +func (ProjectileDamageSource) ReducedByResistance() bool { return true } +func (ProjectileDamageSource) ReducedByArmour() bool { return true } +func (ProjectileDamageSource) Fire() bool { return false } +func (ProjectileDamageSource) AffectedByEnchantment(e item.EnchantmentType) bool { + return e == enchantment.ProjectileProtection +} +func (ProjectileDamageSource) IgnoreTotem() bool { return false } +func (ExplosionDamageSource) ReducedByResistance() bool { return true } +func (ExplosionDamageSource) ReducedByArmour() bool { return true } +func (ExplosionDamageSource) Fire() bool { return false } +func (ExplosionDamageSource) AffectedByEnchantment(e item.EnchantmentType) bool { + return e == enchantment.BlastProtection +} +func (ExplosionDamageSource) IgnoreTotem() bool { return false } diff --git a/server/entity/direction.go b/server/entity/direction.go new file mode 100644 index 0000000..770cb06 --- /dev/null +++ b/server/entity/direction.go @@ -0,0 +1,22 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// Eyed represents an entity that has eyes. +type Eyed interface { + // EyeHeight returns the offset from their base position that the eyes of an entity are found at. + EyeHeight() float64 +} + +// EyePosition returns the position of the eyes of the entity if the entity implements entity.Eyed, or the +// actual position if it doesn't. +func EyePosition(e world.Entity) mgl64.Vec3 { + pos := e.Position() + if eyed, ok := e.(Eyed); ok { + pos[1] += eyed.EyeHeight() + } + return pos +} diff --git a/server/entity/effect.go b/server/entity/effect.go new file mode 100644 index 0000000..b4568c2 --- /dev/null +++ b/server/entity/effect.go @@ -0,0 +1,132 @@ +package entity + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/world" + "maps" + "reflect" + "slices" +) + +// EffectManager manages the effects of an entity. The effect manager will only store effects that last for +// a specific duration. Instant effects are applied instantly and not stored. +type EffectManager struct { + initialEffects []effect.Effect + effects map[reflect.Type]effect.Effect +} + +// NewEffectManager creates and returns a new initialised EffectManager. +func NewEffectManager(eff ...effect.Effect) *EffectManager { + return &EffectManager{effects: make(map[reflect.Type]effect.Effect), initialEffects: eff} +} + +// Add adds an effect to the manager. If the effect is instant, it is applied to the Living entity passed +// immediately. If not, the effect is added to the EffectManager and is applied to the entity every time the +// Tick method is called. +// Effect levels of 0 or below will not do anything. +// Effect returns the final effect it added to the entity. That might be the effect passed or an effect with +// a higher level/duration than the one passed. Add panics if the effect has a negative duration or level. +func (m *EffectManager) Add(e effect.Effect, entity Living) effect.Effect { + lvl, dur := e.Level(), e.Duration() + if lvl <= 0 { + panic(fmt.Sprintf("(*EffectManager).Add: effect cannot have level of 0 or below: %v", lvl)) + } + if dur < 0 { + panic(fmt.Sprintf("(*EffectManager).Add: effect cannot have negative duration: %v", dur)) + } + + m.flushInitialEffects(entity) + + t, ok := e.Type().(effect.LastingType) + if !ok { + e.Type().Apply(entity, e) + return e + } + typ := reflect.TypeOf(e.Type()) + + existing, ok := m.effects[typ] + if !ok { + m.effects[typ] = e + + t.Start(entity, lvl) + return e + } + if existing.Level() > lvl || (existing.Level() == lvl && ((existing.Duration() > dur && !e.Infinite()) || existing.Infinite())) { + return existing + } + m.effects[typ] = e + + existing.Type().(effect.LastingType).End(entity, existing.Level()) + t.Start(entity, lvl) + return e +} + +// Remove removes any Effect present in the EffectManager with the type of the effect passed. +func (m *EffectManager) Remove(e effect.Type, entity Living) { + m.flushInitialEffects(entity) + + t := reflect.TypeOf(e) + if existing, ok := m.effects[t]; ok { + delete(m.effects, t) + existing.Type().(effect.LastingType).End(entity, existing.Level()) + } +} + +// Effect returns the effect instance and true if the entity has the effect. If not found, it will return an empty +// effect instance and false. +func (m *EffectManager) Effect(e effect.Type) (effect.Effect, bool) { + for _, eff := range m.initialEffects { + if eff.Type() == e { + return eff, true + } + } + + existing, ok := m.effects[reflect.TypeOf(e)] + return existing, ok +} + +// Effects returns a list of all effects currently present in the effect manager. This will never include +// effects that have expired. +func (m *EffectManager) Effects() []effect.Effect { + return append(slices.Collect(maps.Values(m.effects)), m.initialEffects...) +} + +// Tick ticks the EffectManager, applying all of its effects to the Living entity passed when applicable and +// removing expired effects. +func (m *EffectManager) Tick(entity Living, tx *world.Tx) { + update := false + + m.flushInitialEffects(entity) + + for i, eff := range m.effects { + if m.expired(eff) { + delete(m.effects, i) + eff.Type().(effect.LastingType).End(entity, eff.Level()) + update = true + continue + } + eff.Type().Apply(entity, eff) + m.effects[i] = eff.TickDuration() + } + + if update { + for _, v := range tx.Viewers(entity.Position()) { + v.ViewEntityState(entity) + } + } +} + +// flushInitialEffects flushes the initial effects, applying them onto the Living entity passed. +func (m *EffectManager) flushInitialEffects(entity Living) { + initialEffects := m.initialEffects + m.initialEffects = nil + for _, e := range initialEffects { + m.Add(e, entity) + } +} + +// expired checks if an Effect has expired. +func (m *EffectManager) expired(e effect.Effect) bool { + return e.Duration() <= 0 && !e.Infinite() +} diff --git a/server/entity/effect/absorption.go b/server/entity/effect/absorption.go new file mode 100644 index 0000000..046b79a --- /dev/null +++ b/server/entity/effect/absorption.go @@ -0,0 +1,37 @@ +package effect + +import ( + "github.com/df-mc/dragonfly/server/world" + "image/color" +) + +// Absorption is a lasting effect that increases the health of an entity over +// the maximum. Once this extra health is lost, it will not regenerate. +var Absorption absorption + +type absorption struct { + nopLasting +} + +// Start ... +func (absorption) Start(e world.Entity, lvl int) { + if i, ok := e.(interface { + SetAbsorption(health float64) + }); ok { + i.SetAbsorption(4 * float64(lvl)) + } +} + +// End ... +func (absorption) End(e world.Entity, _ int) { + if i, ok := e.(interface { + SetAbsorption(health float64) + }); ok { + i.SetAbsorption(0) + } +} + +// RGBA ... +func (absorption) RGBA() color.RGBA { + return color.RGBA{R: 0x25, G: 0x52, B: 0xa5, A: 0xff} +} diff --git a/server/entity/effect/blindness.go b/server/entity/effect/blindness.go new file mode 100644 index 0000000..4817f5f --- /dev/null +++ b/server/entity/effect/blindness.go @@ -0,0 +1,18 @@ +package effect + +import ( + "image/color" +) + +// Blindness is a lasting effect that greatly reduces the vision range of the +// entity affected. +var Blindness blindness + +type blindness struct { + nopLasting +} + +// RGBA ... +func (blindness) RGBA() color.RGBA { + return color.RGBA{R: 0x1f, G: 0x1f, B: 0x23, A: 0xff} +} diff --git a/server/entity/effect/conduit_power.go b/server/entity/effect/conduit_power.go new file mode 100644 index 0000000..c66cdd3 --- /dev/null +++ b/server/entity/effect/conduit_power.go @@ -0,0 +1,28 @@ +package effect + +import ( + "image/color" +) + +// ConduitPower is a lasting effect that grants the affected entity the ability +// to breathe underwater and allows the entity to break faster when underwater +// or in the rain. (Similarly to haste.) +var ConduitPower conduitPower + +type conduitPower struct { + nopLasting +} + +// Multiplier returns the mining speed multiplier from this effect. +func (conduitPower) Multiplier(lvl int) float64 { + v := 1 - float64(lvl)*0.1 + if v < 0 { + v = 0 + } + return v +} + +// RGBA ... +func (conduitPower) RGBA() color.RGBA { + return color.RGBA{R: 0x1d, G: 0xc2, B: 0xd1, A: 0xff} +} diff --git a/server/entity/effect/darkness.go b/server/entity/effect/darkness.go new file mode 100644 index 0000000..e5f77d9 --- /dev/null +++ b/server/entity/effect/darkness.go @@ -0,0 +1,18 @@ +package effect + +import ( + "image/color" +) + +// Darkness is a lasting effect that causes the player's vision to dim +// occasionally. +var Darkness darkness + +type darkness struct { + nopLasting +} + +// RGBA ... +func (darkness) RGBA() color.RGBA { + return color.RGBA{R: 0x29, G: 0x27, B: 0x21, A: 0xff} +} diff --git a/server/entity/effect/effect.go b/server/entity/effect/effect.go new file mode 100644 index 0000000..b3cad3f --- /dev/null +++ b/server/entity/effect/effect.go @@ -0,0 +1,191 @@ +package effect + +import ( + "github.com/df-mc/dragonfly/server/world" + "image/color" + "time" +) + +// LastingType represents an effect type that can have a duration. An effect +// can be made using it by calling effect.New with the LastingType. +type LastingType interface { + Type + // Start is called for lasting effects when they are initially added. + Start(e world.Entity, lvl int) + // End is called for lasting effects when they are removed. + End(e world.Entity, lvl int) +} + +// Type is an effect implementation that can be applied to an entity. +type Type interface { + // RGBA returns the colour of the effect. If multiple effects are present, + // the colours will be mixed together to form a new colour. + RGBA() color.RGBA + // Apply applies the effect to an entity. Apply is called only once for + // instant effects, such as instantHealth, while it is called every tick for + // lasting effects. The Effect holding the Type is passed along with the + // current tick. + Apply(e world.Entity, eff Effect) +} + +// Effect is an effect that can be added to an entity. Effects are either +// instant (applying the effect only once) or lasting (applying the effect +// every tick). +type Effect struct { + t Type + d time.Duration + lvl int + potency float64 + ambient, particlesHidden bool + infinite bool + tick int +} + +// NewInstant returns a new instant Effect using the Type passed. The effect +// will be applied to an entity once and will expire immediately after. +// NewInstant creates an Effect with a potency of 1.0. +func NewInstant(t Type, lvl int) Effect { + return NewInstantWithPotency(t, lvl, 1) +} + +// NewInstantWithPotency returns a new instant Effect using the Type and level +// passed. The effect will be applied to an entity once and expire immediately +// after. The potency passed additionally influences the strength of the effect. +// A higher potency (> 1.0) increases the effect power, while a lower potency +// (< 1.0) reduces it. +func NewInstantWithPotency(t Type, lvl int, potency float64) Effect { + return Effect{t: t, lvl: lvl, potency: potency} +} + +// New creates a new Effect using a LastingType passed. Once added to an entity, the time.Duration passed will be ticked down +// by the entity until it reaches a duration of 0. +func New(t LastingType, lvl int, d time.Duration) Effect { + return Effect{t: t, lvl: lvl, d: d} +} + +// NewAmbient creates a new ambient (reduced particles, as when using a beacon) Effect using a LastingType passed. Once added +// to an entity, the time.Duration passed will be ticked down by the entity until it reaches a duration of 0. +func NewAmbient(t LastingType, lvl int, d time.Duration) Effect { + return Effect{t: t, lvl: lvl, d: d, ambient: true} +} + +// NewInfinite creates a new Effect using a LastingType passed. Once added to an entity, the effect will persist indefinitely, +// until the effect is removed. +func NewInfinite(t LastingType, lvl int) Effect { + return Effect{t: t, lvl: lvl, infinite: true} +} + +// WithoutParticles returns the same Effect with particles disabled. Adding the effect to players will not display the +// particles around the player. +func (e Effect) WithoutParticles() Effect { + e.particlesHidden = true + return e +} + +// ParticlesHidden returns true if the Effect had its particles hidden by calling WithoutParticles. +func (e Effect) ParticlesHidden() bool { + return e.particlesHidden +} + +// Level returns the level of the Effect. +func (e Effect) Level() int { + return e.lvl +} + +// Duration returns the leftover duration of the Effect. The duration returned is always 0 if NewInstant or NewInfinite +// were used to create the effect. +func (e Effect) Duration() time.Duration { + return e.d +} + +// Ambient returns whether the Effect is an ambient effect, leading to reduced particles shown to the client. False is +// always returned if the Effect was created using New or NewInstant. +func (e Effect) Ambient() bool { + return e.ambient +} + +// Infinite returns if the Effect duration is infinite. +func (e Effect) Infinite() bool { + return e.infinite +} + +// Type returns the underlying type of the Effect. It is either of the type Type or LastingType, depending on whether it +// was created using New or NewAmbient, or NewInstant. +func (e Effect) Type() Type { + return e.t +} + +// TickDuration ticks the effect duration, subtracting time.Second/20 from the leftover time and returning the resulting +// Effect. +func (e Effect) TickDuration() Effect { + if _, ok := e.t.(LastingType); ok { + if !e.Infinite() { + e.d -= time.Second / 20 + } + e.tick++ + } + return e +} + +// Tick returns the current tick of the Effect. This is the number of ticks that +// the Effect has been applied for. +func (e Effect) Tick() int { + return e.tick +} + +// nopLasting is a lasting effect with no (server-side) behaviour. It does not implement the RGBA method. +type nopLasting struct{} + +func (nopLasting) Apply(world.Entity, Effect) {} +func (nopLasting) End(world.Entity, int) {} +func (nopLasting) Start(world.Entity, int) {} + +// ResultingColour calculates the resulting colour of the effects passed and returns a bool specifying if the +// effects were ambient effects, which will cause their particles to display less frequently. +func ResultingColour(effects []Effect) (color.RGBA, bool) { + r, g, b, a, n := 0, 0, 0, 0, 0 + ambient := true + for _, e := range effects { + if e.particlesHidden { + // Don't take effects with hidden particles into account for colour + // calculation: Their particles are hidden after all. + continue + } + c := e.Type().RGBA() + r += int(c.R) + g += int(c.G) + b += int(c.B) + a += int(c.A) + n++ + if !e.Ambient() { + ambient = false + } + } + if n == 0 { + return color.RGBA{R: 0x38, G: 0x5d, B: 0xc6, A: 0xff}, false + } + return color.RGBA{R: uint8(r / n), G: uint8(g / n), B: uint8(b / n), A: uint8(a / n)}, ambient +} + +// living represents a living entity that has health and the ability to move around. +type living interface { + world.Entity + // Health returns the health of the entity. + Health() float64 + // MaxHealth returns the maximum health of the entity. + MaxHealth() float64 + // SetMaxHealth changes the maximum health of the entity to the value passed. + SetMaxHealth(v float64) + // Hurt hurts the entity for a given amount of damage. The source passed represents the cause of the + // damage, for example entity.AttackDamageSource if the entity is attacked by another entity. + // If the final damage exceeds the health that the player currently has, the entity is killed. + Hurt(damage float64, source world.DamageSource) (n float64, vulnerable bool) + // Heal heals the entity for a given amount of health. The source passed represents the cause of the + // healing, for example entity.FoodHealingSource if the entity healed by having a full food bar. If the health + // added to the original health exceeds the entity's max health, Heal may not add the full amount. + Heal(health float64, source world.HealingSource) + // speed returns the current speed of the living entity. The default value is different for each entity. + Speed() float64 + // SetSpeed sets the speed of an entity to a new value. + SetSpeed(float64) +} diff --git a/server/entity/effect/fatal_poison.go b/server/entity/effect/fatal_poison.go new file mode 100644 index 0000000..efed2d4 --- /dev/null +++ b/server/entity/effect/fatal_poison.go @@ -0,0 +1,30 @@ +package effect + +import ( + "github.com/df-mc/dragonfly/server/world" + "image/color" +) + +// FatalPoison is a lasting effect that causes the affected entity to lose +// health gradually. fatalPoison, unlike poison, can kill the entity it is +// applied to. +var FatalPoison fatalPoison + +type fatalPoison struct { + nopLasting +} + +// Apply ... +func (fatalPoison) Apply(e world.Entity, eff Effect) { + interval := max(50>>(eff.Level()-1), 1) + if eff.Tick()%interval == 0 { + if l, ok := e.(living); ok { + l.Hurt(1, PoisonDamageSource{Fatal: true}) + } + } +} + +// RGBA ... +func (fatalPoison) RGBA() color.RGBA { + return color.RGBA{R: 0x4e, G: 0x93, B: 0x31, A: 0xff} +} diff --git a/server/entity/effect/fire_resistance.go b/server/entity/effect/fire_resistance.go new file mode 100644 index 0000000..d87bd8f --- /dev/null +++ b/server/entity/effect/fire_resistance.go @@ -0,0 +1,17 @@ +package effect + +import ( + "image/color" +) + +// FireResistance is a lasting effect that grants immunity to fire & lava damage. +var FireResistance fireResistance + +type fireResistance struct { + nopLasting +} + +// RGBA ... +func (fireResistance) RGBA() color.RGBA { + return color.RGBA{R: 0xff, G: 0x99, B: 0x00, A: 0xff} +} diff --git a/server/entity/effect/haste.go b/server/entity/effect/haste.go new file mode 100644 index 0000000..cfdfd92 --- /dev/null +++ b/server/entity/effect/haste.go @@ -0,0 +1,27 @@ +package effect + +import ( + "image/color" +) + +// Haste is a lasting effect that increases the mining speed of a player by 20% +// for each level of the effect. +var Haste haste + +type haste struct { + nopLasting +} + +// Multiplier returns the mining speed multiplier from this effect. +func (haste) Multiplier(lvl int) float64 { + v := 1 - float64(lvl)*0.1 + if v < 0 { + v = 0 + } + return v +} + +// RGBA ... +func (haste) RGBA() color.RGBA { + return color.RGBA{R: 0xd9, G: 0xc0, B: 0x43, A: 0xff} +} diff --git a/server/entity/effect/health_boost.go b/server/entity/effect/health_boost.go new file mode 100644 index 0000000..73404bd --- /dev/null +++ b/server/entity/effect/health_boost.go @@ -0,0 +1,33 @@ +package effect + +import ( + "github.com/df-mc/dragonfly/server/world" + "image/color" +) + +// HealthBoost causes the affected entity to have its maximum health changed +// for a specific duration. +var HealthBoost healthBoost + +type healthBoost struct { + nopLasting +} + +// Start ... +func (healthBoost) Start(e world.Entity, lvl int) { + if l, ok := e.(living); ok { + l.SetMaxHealth(l.MaxHealth() + 4*float64(lvl)) + } +} + +// End ... +func (healthBoost) End(e world.Entity, lvl int) { + if l, ok := e.(living); ok { + l.SetMaxHealth(l.MaxHealth() - 4*float64(lvl)) + } +} + +// RGBA ... +func (healthBoost) RGBA() color.RGBA { + return color.RGBA{R: 0xf8, G: 0x7d, B: 0x23, A: 0xff} +} diff --git a/server/entity/effect/hunger.go b/server/entity/effect/hunger.go new file mode 100644 index 0000000..cf165ff --- /dev/null +++ b/server/entity/effect/hunger.go @@ -0,0 +1,28 @@ +package effect + +import ( + "github.com/df-mc/dragonfly/server/world" + "image/color" +) + +// Hunger is a lasting effect that causes an affected player to gradually lose +// saturation and food. +var Hunger hunger + +type hunger struct { + nopLasting +} + +// Apply ... +func (hunger) Apply(e world.Entity, eff Effect) { + if i, ok := e.(interface { + Exhaust(points float64) + }); ok { + i.Exhaust(float64(eff.Level()) * 0.005) + } +} + +// RGBA ... +func (hunger) RGBA() color.RGBA { + return color.RGBA{R: 0x58, G: 0x76, B: 0x53, A: 0xff} +} diff --git a/server/entity/effect/instant_damage.go b/server/entity/effect/instant_damage.go new file mode 100644 index 0000000..0f5ad39 --- /dev/null +++ b/server/entity/effect/instant_damage.go @@ -0,0 +1,35 @@ +package effect + +import ( + "github.com/df-mc/dragonfly/server/world" + "image/color" +) + +// InstantDamage is an instant effect that causes a living entity to +// immediately take some damage, depending on the level and the potency of the +// effect. +var InstantDamage instantDamage + +type instantDamage struct{} + +// Apply ... +func (i instantDamage) Apply(e world.Entity, eff Effect) { + base := 3 << eff.Level() + if l, ok := e.(living); ok { + l.Hurt(float64(base)*eff.potency, InstantDamageSource{}) + } +} + +// RGBA ... +func (instantDamage) RGBA() color.RGBA { + return color.RGBA{R: 0xa9, G: 0x65, B: 0x6a, A: 0xff} +} + +// InstantDamageSource is used for damage caused by an effect.instantDamage +// applied to an entity. +type InstantDamageSource struct{} + +func (InstantDamageSource) ReducedByArmour() bool { return false } +func (InstantDamageSource) ReducedByResistance() bool { return true } +func (InstantDamageSource) Fire() bool { return false } +func (InstantDamageSource) IgnoreTotem() bool { return false } diff --git a/server/entity/effect/instant_health.go b/server/entity/effect/instant_health.go new file mode 100644 index 0000000..db12a19 --- /dev/null +++ b/server/entity/effect/instant_health.go @@ -0,0 +1,33 @@ +package effect + +import ( + "github.com/df-mc/dragonfly/server/world" + "image/color" +) + +// InstantHealth is an instant effect that causes the player that it is applied +// to immediately regain some health. The amount of health regained depends on +// the effect level and potency. +var InstantHealth instantHealth + +type instantHealth struct{} + +// Apply instantly heals the world.Entity passed for a bit of health, depending on the effect level and +// potency. +func (i instantHealth) Apply(e world.Entity, eff Effect) { + base := 2 << eff.Level() + if l, ok := e.(living); ok { + l.Heal(float64(base)*eff.potency, InstantHealingSource{}) + } +} + +// RGBA ... +func (instantHealth) RGBA() color.RGBA { + return color.RGBA{R: 0xf8, G: 0x24, B: 0x23, A: 0xff} +} + +// InstantHealingSource is a healing source used when an entity regains +// health from an effect.instantHealth. +type InstantHealingSource struct{} + +func (InstantHealingSource) HealingSource() {} diff --git a/server/entity/effect/invisibility.go b/server/entity/effect/invisibility.go new file mode 100644 index 0000000..b37aa15 --- /dev/null +++ b/server/entity/effect/invisibility.go @@ -0,0 +1,40 @@ +package effect + +import ( + "github.com/df-mc/dragonfly/server/world" + "image/color" +) + +// Invisibility is a lasting effect that causes the affected entity to turn +// invisible. While invisible, the entity's armour is still visible and effect +// particles will still be displayed. +var Invisibility invisibility + +type invisibility struct { + nopLasting +} + +// Start ... +func (invisibility) Start(e world.Entity, _ int) { + if i, ok := e.(interface { + SetInvisible() + SetVisible() + }); ok { + i.SetInvisible() + } +} + +// End ... +func (invisibility) End(e world.Entity, _ int) { + if i, ok := e.(interface { + SetInvisible() + SetVisible() + }); ok { + i.SetVisible() + } +} + +// RGBA ... +func (invisibility) RGBA() color.RGBA { + return color.RGBA{R: 0xf6, G: 0xf6, B: 0xf6, A: 0xff} +} diff --git a/server/entity/effect/jump_boost.go b/server/entity/effect/jump_boost.go new file mode 100644 index 0000000..857f098 --- /dev/null +++ b/server/entity/effect/jump_boost.go @@ -0,0 +1,18 @@ +package effect + +import ( + "image/color" +) + +// JumpBoost is a lasting effect that causes the affected entity to be able to +// jump much higher, depending on the level of the effect. +var JumpBoost jumpBoost + +type jumpBoost struct { + nopLasting +} + +// RGBA ... +func (jumpBoost) RGBA() color.RGBA { + return color.RGBA{R: 0xfd, G: 0xff, B: 0x84, A: 0xff} +} diff --git a/server/entity/effect/levitation.go b/server/entity/effect/levitation.go new file mode 100644 index 0000000..c1b4a79 --- /dev/null +++ b/server/entity/effect/levitation.go @@ -0,0 +1,18 @@ +package effect + +import ( + "image/color" +) + +// Levitation is a lasting effect that causes the affected entity to slowly +// levitate upwards. It is roughly the opposite of the slowFalling effect. +var Levitation levitation + +type levitation struct { + nopLasting +} + +// RGBA ... +func (levitation) RGBA() color.RGBA { + return color.RGBA{R: 0xce, G: 0xff, B: 0xff, A: 0xff} +} diff --git a/server/entity/effect/mining_fatigue.go b/server/entity/effect/mining_fatigue.go new file mode 100644 index 0000000..bbb8537 --- /dev/null +++ b/server/entity/effect/mining_fatigue.go @@ -0,0 +1,24 @@ +package effect + +import ( + "image/color" + "math" +) + +// MiningFatigue is a lasting effect that decreases the mining speed of a +// player by 10% for each level of the effect. +var MiningFatigue miningFatigue + +type miningFatigue struct { + nopLasting +} + +// Multiplier returns the mining speed multiplier from this effect. +func (miningFatigue) Multiplier(lvl int) float64 { + return math.Pow(3, float64(lvl)) +} + +// RGBA ... +func (miningFatigue) RGBA() color.RGBA { + return color.RGBA{R: 0x4a, G: 0x42, B: 0x17, A: 0xff} +} diff --git a/server/entity/effect/nausea.go b/server/entity/effect/nausea.go new file mode 100644 index 0000000..b3312bc --- /dev/null +++ b/server/entity/effect/nausea.go @@ -0,0 +1,18 @@ +package effect + +import ( + "image/color" +) + +// Nausea is a lasting effect that causes the screen to warp, similarly to when +// entering a nether portal. +var Nausea nausea + +type nausea struct { + nopLasting +} + +// RGBA ... +func (nausea) RGBA() color.RGBA { + return color.RGBA{R: 0x55, G: 0x1d, B: 0x4a, A: 0xff} +} diff --git a/server/entity/effect/night_vision.go b/server/entity/effect/night_vision.go new file mode 100644 index 0000000..a9dcbea --- /dev/null +++ b/server/entity/effect/night_vision.go @@ -0,0 +1,18 @@ +package effect + +import ( + "image/color" +) + +// NightVision is a lasting effect that causes the affected entity to see in +// dark places as though they were fully lit up. +var NightVision nightVision + +type nightVision struct { + nopLasting +} + +// RGBA ... +func (nightVision) RGBA() color.RGBA { + return color.RGBA{R: 0xc2, G: 0xff, B: 0x66, A: 0xff} +} diff --git a/server/entity/effect/poison.go b/server/entity/effect/poison.go new file mode 100644 index 0000000..9bbb80e --- /dev/null +++ b/server/entity/effect/poison.go @@ -0,0 +1,42 @@ +package effect + +import ( + "github.com/df-mc/dragonfly/server/world" + "image/color" +) + +// Poison is a lasting effect that causes the affected entity to lose health +// gradually. poison cannot kill, unlike fatalPoison. +var Poison poison + +type poison struct { + nopLasting +} + +// Apply ... +func (poison) Apply(e world.Entity, eff Effect) { + interval := max(50>>(eff.Level()-1), 1) + if eff.Tick()%interval == 0 { + if l, ok := e.(living); ok && l.Health() > 1 { + l.Hurt(1, PoisonDamageSource{}) + } + } +} + +// RGBA ... +func (poison) RGBA() color.RGBA { + return color.RGBA{R: 0x87, G: 0xa3, B: 0x63, A: 0xff} +} + +// PoisonDamageSource is used for damage caused by an effect.poison or +// effect.fatalPoison applied to an entity. +type PoisonDamageSource struct { + // Fatal specifies if the damage was caused by effect.fatalPoison and if + // the damage could therefore kill the entity. + Fatal bool +} + +func (PoisonDamageSource) ReducedByResistance() bool { return true } +func (PoisonDamageSource) ReducedByArmour() bool { return false } +func (PoisonDamageSource) Fire() bool { return false } +func (PoisonDamageSource) IgnoreTotem() bool { return false } diff --git a/server/entity/effect/regeneration.go b/server/entity/effect/regeneration.go new file mode 100644 index 0000000..a53be13 --- /dev/null +++ b/server/entity/effect/regeneration.go @@ -0,0 +1,36 @@ +package effect + +import ( + "github.com/df-mc/dragonfly/server/world" + "image/color" +) + +// Regeneration is an effect that causes the entity that it is added to slowly +// regenerate health. The level of the effect influences the speed with which +// the entity regenerates. +var Regeneration regeneration + +type regeneration struct { + nopLasting +} + +// Apply applies health to the world.Entity passed if the duration of the effect is at the right tick. +func (regeneration) Apply(e world.Entity, eff Effect) { + interval := max(50>>(eff.Level()-1), 1) + if eff.Tick()%interval == 0 { + if l, ok := e.(living); ok { + l.Heal(1, RegenerationHealingSource{}) + } + } +} + +// RGBA ... +func (regeneration) RGBA() color.RGBA { + return color.RGBA{R: 0xcd, G: 0x5c, B: 0xab, A: 0xff} +} + +// RegenerationHealingSource is a healing source used when an entity regenerates +// health from an effect.regeneration. +type RegenerationHealingSource struct{} + +func (RegenerationHealingSource) HealingSource() {} diff --git a/server/entity/effect/register.go b/server/entity/effect/register.go new file mode 100644 index 0000000..4a720ca --- /dev/null +++ b/server/entity/effect/register.go @@ -0,0 +1,62 @@ +package effect + +// Register registers an Effect with a specific ID to translate from and to on disk and network. An Effect +// instance may be created by creating a struct instance in this package like +// effect.regeneration{}. +func Register(id int, e Type) { + effects[id] = e + effectIds[e] = id +} + +// init registers all implemented effects. +func init() { + Register(1, Speed) + Register(2, Slowness) + Register(3, Haste) + Register(4, MiningFatigue) + Register(5, Strength) + Register(6, InstantHealth) + Register(7, InstantDamage) + Register(8, JumpBoost) + Register(9, Nausea) + Register(10, Regeneration) + Register(11, Resistance) + Register(12, FireResistance) + Register(13, WaterBreathing) + Register(14, Invisibility) + Register(15, Blindness) + Register(16, NightVision) + Register(17, Hunger) + Register(18, Weakness) + Register(19, Poison) + Register(20, Wither) + Register(21, HealthBoost) + Register(22, Absorption) + Register(23, Saturation) + Register(24, Levitation) + Register(25, FatalPoison) + Register(26, ConduitPower) + Register(27, SlowFalling) + // TODO: (28) Bad omen. (Requires villages ...) + // TODO: (29) Hero of the village. (Requires villages ...) + Register(30, Darkness) +} + +var ( + effects = map[int]Type{} + effectIds = map[Type]int{} +) + +// ByID attempts to return an effect by the ID it was registered with. If found, the effect found +// is returned and the bool true. +func ByID(id int) (Type, bool) { + effect, ok := effects[id] + return effect, ok +} + +// ID attempts to return the ID an effect was registered with. If found, the id is returned and +// the bool true. +func ID(e Type) (int, bool) { + id, ok := effectIds[e] + return id, ok +} diff --git a/server/entity/effect/resistance.go b/server/entity/effect/resistance.go new file mode 100644 index 0000000..131326f --- /dev/null +++ b/server/entity/effect/resistance.go @@ -0,0 +1,30 @@ +package effect + +import ( + "github.com/df-mc/dragonfly/server/world" + "image/color" +) + +// Resistance is a lasting effect that reduces the damage taken from any +// sources except for void damage or custom damage. +var Resistance resistance + +type resistance struct { + nopLasting +} + +// Multiplier returns a damage multiplier for the damage source passed. +func (resistance) Multiplier(e world.DamageSource, lvl int) float64 { + if !e.ReducedByResistance() { + return 1 + } + if v := 1 - 0.2*float64(lvl); v >= 0 { + return v + } + return 0 +} + +// RGBA ... +func (resistance) RGBA() color.RGBA { + return color.RGBA{R: 0x91, G: 0x46, B: 0xf0, A: 0xff} +} diff --git a/server/entity/effect/saturation.go b/server/entity/effect/saturation.go new file mode 100644 index 0000000..de774bf --- /dev/null +++ b/server/entity/effect/saturation.go @@ -0,0 +1,28 @@ +package effect + +import ( + "github.com/df-mc/dragonfly/server/world" + "image/color" +) + +// Saturation is a lasting effect that causes the affected player to regain +// food and saturation. +var Saturation saturation + +type saturation struct { + nopLasting +} + +// Apply ... +func (saturation) Apply(e world.Entity, eff Effect) { + if i, ok := e.(interface { + Saturate(food int, saturation float64) + }); ok { + i.Saturate(eff.Level(), 2*float64(eff.Level())) + } +} + +// RGBA ... +func (saturation) RGBA() color.RGBA { + return color.RGBA{R: 0xf8, G: 0x24, B: 0x23, A: 0xff} +} diff --git a/server/entity/effect/slow_falling.go b/server/entity/effect/slow_falling.go new file mode 100644 index 0000000..07f3309 --- /dev/null +++ b/server/entity/effect/slow_falling.go @@ -0,0 +1,18 @@ +package effect + +import ( + "image/color" +) + +// SlowFalling is a lasting effect that causes the affected entity to fall very +// slowly. +var SlowFalling slowFalling + +type slowFalling struct { + nopLasting +} + +// RGBA ... +func (slowFalling) RGBA() color.RGBA { + return color.RGBA{R: 0xf3, G: 0xcf, B: 0xb9, A: 0xff} +} diff --git a/server/entity/effect/slowness.go b/server/entity/effect/slowness.go new file mode 100644 index 0000000..46fde23 --- /dev/null +++ b/server/entity/effect/slowness.go @@ -0,0 +1,41 @@ +package effect + +import ( + "github.com/df-mc/dragonfly/server/world" + "image/color" +) + +// Slowness is a lasting effect that decreases the movement speed of a living +// entity by 15% for each level that the effect has. +var Slowness slowness + +type slowness struct { + nopLasting +} + +// Start ... +func (slowness) Start(e world.Entity, lvl int) { + slowness := 1 - float64(lvl)*0.15 + if slowness <= 0 { + slowness = 0.00001 + } + if l, ok := e.(living); ok { + l.SetSpeed(l.Speed() * slowness) + } +} + +// End ... +func (slowness) End(e world.Entity, lvl int) { + slowness := 1 - float64(lvl)*0.15 + if slowness <= 0 { + slowness = 0.00001 + } + if l, ok := e.(living); ok { + l.SetSpeed(l.Speed() / slowness) + } +} + +// RGBA ... +func (slowness) RGBA() color.RGBA { + return color.RGBA{R: 0x8b, G: 0xaf, B: 0xe0, A: 0xff} +} diff --git a/server/entity/effect/speed.go b/server/entity/effect/speed.go new file mode 100644 index 0000000..3dd3122 --- /dev/null +++ b/server/entity/effect/speed.go @@ -0,0 +1,35 @@ +package effect + +import ( + "github.com/df-mc/dragonfly/server/world" + "image/color" +) + +// Speed is a lasting effect that increases the speed of an entity by 20% for +// each level that the effect has. +var Speed speed + +type speed struct { + nopLasting +} + +// Start ... +func (speed) Start(e world.Entity, lvl int) { + speed := 1 + float64(lvl)*0.2 + if l, ok := e.(living); ok { + l.SetSpeed(l.Speed() * speed) + } +} + +// End ... +func (speed) End(e world.Entity, lvl int) { + speed := 1 + float64(lvl)*0.2 + if l, ok := e.(living); ok { + l.SetSpeed(l.Speed() / speed) + } +} + +// RGBA ... +func (speed) RGBA() color.RGBA { + return color.RGBA{R: 0x33, G: 0xeb, B: 0xff, A: 0xff} +} diff --git a/server/entity/effect/strength.go b/server/entity/effect/strength.go new file mode 100644 index 0000000..8cddb7a --- /dev/null +++ b/server/entity/effect/strength.go @@ -0,0 +1,23 @@ +package effect + +import ( + "image/color" +) + +// Strength is a lasting effect that increases the damage dealt with melee +// attacks when applied to an entity. +var Strength strength + +type strength struct { + nopLasting +} + +// Multiplier returns the damage multiplier of the effect. +func (strength) Multiplier(lvl int) float64 { + return 0.3 * float64(lvl) +} + +// RGBA ... +func (strength) RGBA() color.RGBA { + return color.RGBA{R: 0xff, G: 0xc7, B: 0x00, A: 0xff} +} diff --git a/server/entity/effect/water_breathing.go b/server/entity/effect/water_breathing.go new file mode 100644 index 0000000..4d0e670 --- /dev/null +++ b/server/entity/effect/water_breathing.go @@ -0,0 +1,18 @@ +package effect + +import ( + "image/color" +) + +// WaterBreathing is a lasting effect that allows the affected entity to breath +// underwater until the effect expires. +var WaterBreathing waterBreathing + +type waterBreathing struct { + nopLasting +} + +// RGBA ... +func (waterBreathing) RGBA() color.RGBA { + return color.RGBA{R: 0x98, G: 0xda, B: 0xc0, A: 0xff} +} diff --git a/server/entity/effect/weakness.go b/server/entity/effect/weakness.go new file mode 100644 index 0000000..f83593b --- /dev/null +++ b/server/entity/effect/weakness.go @@ -0,0 +1,27 @@ +package effect + +import ( + "image/color" +) + +// Weakness is a lasting effect that reduces the damage dealt to other entities +// with melee attacks. +var Weakness weakness + +type weakness struct { + nopLasting +} + +// Multiplier returns the damage multiplier of the effect. +func (weakness) Multiplier(lvl int) float64 { + v := 0.2 * float64(lvl) + if v > 1 { + v = 1 + } + return v +} + +// RGBA ... +func (weakness) RGBA() color.RGBA { + return color.RGBA{R: 0x48, G: 0x4d, B: 0x48, A: 0xff} +} diff --git a/server/entity/effect/wither.go b/server/entity/effect/wither.go new file mode 100644 index 0000000..7c61ba0 --- /dev/null +++ b/server/entity/effect/wither.go @@ -0,0 +1,38 @@ +package effect + +import ( + "github.com/df-mc/dragonfly/server/world" + "image/color" +) + +// Wither is a lasting effect that causes an entity to take continuous damage +// that is capable of killing an entity. +var Wither wither + +type wither struct { + nopLasting +} + +// Apply ... +func (wither) Apply(e world.Entity, eff Effect) { + interval := max(80>>eff.Level(), 1) + if eff.Tick()%interval == 0 { + if l, ok := e.(living); ok { + l.Hurt(1, WitherDamageSource{}) + } + } +} + +// RGBA ... +func (wither) RGBA() color.RGBA { + return color.RGBA{R: 0x73, G: 0x61, B: 0x56, A: 0xff} +} + +// WitherDamageSource is used for damage caused by an effect.wither applied +// to an entity. +type WitherDamageSource struct{} + +func (WitherDamageSource) ReducedByResistance() bool { return true } +func (WitherDamageSource) ReducedByArmour() bool { return false } +func (WitherDamageSource) Fire() bool { return false } +func (WitherDamageSource) IgnoreTotem() bool { return false } diff --git a/server/entity/egg.go b/server/entity/egg.go new file mode 100644 index 0000000..ae64b59 --- /dev/null +++ b/server/entity/egg.go @@ -0,0 +1,40 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/particle" +) + +// NewEgg creates an Egg entity. Egg is as a throwable entity that can be used +// to spawn chicks. +func NewEgg(opts world.EntitySpawnOpts, owner world.Entity) *world.EntityHandle { + conf := eggConf + conf.Owner = owner.H() + return opts.New(EggType, conf) +} + +// TODO: Spawn chicken(e) 12.5% of the time. +var eggConf = ProjectileBehaviourConfig{ + Gravity: 0.03, + Drag: 0.01, + Particle: particle.EggSmash{}, + ParticleCount: 6, +} + +// EggType is a world.EntityType implementation for Egg. +var EggType eggType + +type eggType struct{} + +func (t eggType) Open(tx *world.Tx, handle *world.EntityHandle, data *world.EntityData) world.Entity { + return &Ent{tx: tx, handle: handle, data: data} +} + +func (eggType) EncodeEntity() string { return "minecraft:egg" } +func (eggType) BBox(world.Entity) cube.BBox { + return cube.Box(-0.125, 0, -0.125, 0.125, 0.25, 0.125) +} + +func (eggType) DecodeNBT(_ map[string]any, data *world.EntityData) { data.Data = eggConf.New() } +func (eggType) EncodeNBT(_ *world.EntityData) map[string]any { return nil } diff --git a/server/entity/ender_pearl.go b/server/entity/ender_pearl.go new file mode 100644 index 0000000..5fd44e3 --- /dev/null +++ b/server/entity/ender_pearl.go @@ -0,0 +1,61 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/block/cube/trace" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/particle" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" +) + +// NewEnderPearl creates an EnderPearl entity. EnderPearl is a smooth, greenish- +// blue item used to teleport. +func NewEnderPearl(opts world.EntitySpawnOpts, owner world.Entity) *world.EntityHandle { + conf := enderPearlConf + conf.Owner = owner.H() + return opts.New(EnderPearlType, conf) +} + +var enderPearlConf = ProjectileBehaviourConfig{ + Gravity: 0.03, + Drag: 0.01, + Particle: particle.EndermanTeleport{}, + Sound: sound.Teleport{}, + Hit: teleport, +} + +// teleporter represents a living entity that can teleport. +type teleporter interface { + // Teleport teleports the entity to the position given. + Teleport(pos mgl64.Vec3) + Living +} + +// 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) + if user, ok := owner.(teleporter); ok { + tx.PlaySound(user.Position(), sound.Teleport{}) + user.Teleport(target.Position()) + user.Hurt(5, FallDamageSource{}) + } +} + +// EnderPearlType is a world.EntityType implementation for EnderPearl. +var EnderPearlType enderPearlType + +type enderPearlType struct{} + +func (t enderPearlType) Open(tx *world.Tx, handle *world.EntityHandle, data *world.EntityData) world.Entity { + return &Ent{tx: tx, handle: handle, data: data} +} + +func (enderPearlType) EncodeEntity() string { return "minecraft:ender_pearl" } +func (enderPearlType) BBox(world.Entity) cube.BBox { + return cube.Box(-0.125, 0, -0.125, 0.125, 0.25, 0.125) +} +func (enderPearlType) DecodeNBT(_ map[string]any, data *world.EntityData) { + data.Data = enderPearlConf.New() +} +func (enderPearlType) EncodeNBT(*world.EntityData) map[string]any { return nil } diff --git a/server/entity/ent.go b/server/entity/ent.go new file mode 100644 index 0000000..e3bb889 --- /dev/null +++ b/server/entity/ent.go @@ -0,0 +1,141 @@ +package entity + +import ( + "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. +type Behaviour interface { + // Tick ticks the Ent using the Behaviour. A Movement is returned that + // specifies the movement of the entity over the tick. Nil may be returned + // if the entity did not move. + Tick(e *Ent, tx *world.Tx) *Movement +} + +// Ent is a world.Entity implementation that allows entity implementations to +// 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 +} + +// Open converts a world.EntityHandle to an Ent in a world.Tx. +func Open(tx *world.Tx, handle *world.EntityHandle, data *world.EntityData) *Ent { + return &Ent{tx: tx, handle: handle, data: data} +} + +func (e *Ent) H() *world.EntityHandle { + return e.handle +} + +func (e *Ent) Behaviour() Behaviour { + return e.data.Data.(Behaviour) +} + +// Explode propagates the explosion behaviour of the underlying Behaviour. +func (e *Ent) Explode(src mgl64.Vec3, impact float64, conf block.ExplosionConfig) { + if expl, ok := e.Behaviour().(interface { + Explode(e *Ent, src mgl64.Vec3, impact float64, conf block.ExplosionConfig) + }); ok { + expl.Explode(e, src, impact, conf) + } +} + +// Position returns the current position of the entity. +func (e *Ent) Position() mgl64.Vec3 { + return e.data.Pos +} + +// Velocity returns the current velocity of the entity. The values in the Vec3 returned represent the speed on +// that axis in blocks/tick. +func (e *Ent) Velocity() mgl64.Vec3 { + return e.data.Vel +} + +// SetVelocity sets the velocity of the entity. The values in the Vec3 passed represent the speed on +// that axis in blocks/tick. +func (e *Ent) SetVelocity(v mgl64.Vec3) { + e.data.Vel = v +} + +// Rotation returns the rotation of the entity. +func (e *Ent) Rotation() cube.Rotation { + return e.data.Rot +} + +// Age returns the total time lived of this entity. It increases by +// time.Second/20 for every time Tick is called. +func (e *Ent) Age() time.Duration { + return e.data.Age +} + +// OnFireDuration ... +func (e *Ent) OnFireDuration() time.Duration { + return e.data.FireDuration +} + +// SetOnFire ... +func (e *Ent) SetOnFire(duration time.Duration) { + duration = max(duration, 0) + stateChanged := (e.data.FireDuration > 0) != (duration > 0) + + e.data.FireDuration = duration + if stateChanged { + for _, v := range e.tx.Viewers(e.data.Pos) { + v.ViewEntityState(e) + } + } +} + +// Extinguish ... +func (e *Ent) Extinguish() { + e.SetOnFire(0) +} + +// NameTag returns the name tag of the entity. An empty string is returned if +// no name tag was set. +func (e *Ent) NameTag() string { + return e.data.Name +} + +// SetNameTag changes the name tag of an entity. The name tag is removed if an +// empty string is passed. +func (e *Ent) SetNameTag(s string) { + e.data.Name = s + for _, v := range e.tx.Viewers(e.Position()) { + v.ViewEntityState(e) + } +} + +// 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) { + y := e.data.Pos[1] + if y < float64(tx.Range()[0]) && current%10 == 0 { + _ = e.Close() + return + } + e.SetOnFire(e.OnFireDuration() - time.Second/20) + + if m := e.Behaviour().Tick(e, tx); m != nil { + m.Send() + } + e.data.Age += time.Second / 20 +} + +// Close closes the Ent and removes the associated entity from the world. +func (e *Ent) Close() error { + e.once.Do(func() { + e.tx.RemoveEntity(e) + _ = e.handle.Close() + }) + return nil +} diff --git a/server/entity/experience.go b/server/entity/experience.go new file mode 100644 index 0000000..8823f38 --- /dev/null +++ b/server/entity/experience.go @@ -0,0 +1,117 @@ +package entity + +import ( + "fmt" + "math" +) + +// ExperienceManager manages experience and levels for entities, and provides functions to add, remove, and calculate +// experience needed for upcoming levels. +type ExperienceManager struct { + experience int + dec float64 +} + +// NewExperienceManager returns a new ExperienceManager with no experience. +func NewExperienceManager() *ExperienceManager { + return &ExperienceManager{} +} + +// Experience returns the amount of experience the manager currently has. +func (e *ExperienceManager) Experience() int { + return e.experience +} + +// Add adds experience to the total experience and recalculates the level and progress if necessary. Passing a negative +// value is valid. If the new experience would otherwise drop below 0, it is set to 0. +func (e *ExperienceManager) Add(amount int) (level int, progress float64) { + if e.experience += amount; e.experience < 0 { + e.Reset() + } + return progressFromExperience(e.total()) +} + +// total returns the total amount of experience including the extra decimals provided for more accuracy. +func (e *ExperienceManager) total() float64 { + return float64(e.experience) + e.dec +} + +// Level returns the current experience level. +func (e *ExperienceManager) Level() int { + level, _ := progressFromExperience(e.total()) + return level +} + +// SetLevel sets the level of the manager. +func (e *ExperienceManager) SetLevel(level int) { + if level < 0 || level > math.MaxInt32 { + panic(fmt.Sprintf("level must be between 0 and 2,147,483,647, got %v", level)) + } + _, progress := progressFromExperience(e.total()) + e.experience = experienceForLevels(level) + int(float64(experienceForLevel(level))*progress) +} + +// Progress returns the progress towards the next level. +func (e *ExperienceManager) Progress() float64 { + _, progress := progressFromExperience(e.total()) + return progress +} + +// SetProgress sets the progress of the manager. +func (e *ExperienceManager) SetProgress(progress float64) { + if progress < 0 || progress > 1 { + panic(fmt.Sprintf("progress must be between 0 and 1, got %f", progress)) + } + currentLevel, _ := progressFromExperience(e.total()) + progressExp := float64(experienceForLevel(currentLevel)) * progress + e.experience = experienceForLevels(currentLevel) + int(progressExp) + e.dec = progressExp - math.Trunc(progressExp) +} + +// Reset resets the total experience, level, and progress of the manager to zero. +func (e *ExperienceManager) Reset() { + e.experience, e.dec = 0, 0 +} + +// progressFromExperience returns the level and progress from the total experience given. +func progressFromExperience(experience float64) (level int, progress float64) { + var a, b, c float64 + + switch { + case experience <= float64(experienceForLevels(16)): + a, b = 1.0, 6.0 + case experience <= float64(experienceForLevels(31)): + a, b, c = 2.5, -40.5, 360.0 + default: + a, b, c = 4.5, -162.5, 2220.0 + } + + var sol float64 + if d := b*b - 4*a*(c-experience); d > 0 { + s := math.Sqrt(d) + sol = math.Max((-b+s)/(2*a), (-b-s)/(2*a)) + } else if d == 0 { + sol = -b / (2 * a) + } + return int(sol), sol - math.Trunc(sol) +} + +// experienceForLevels calculates the amount of experience needed in total to reach a certain level. +func experienceForLevels(level int) int { + if level <= 16 { + return level*level + level*6 + } else if level <= 31 { + return int(float64(level*level)*2.5 - 40.5*float64(level) + 360) + } + return int(float64(level*level)*4.5 - 162.5*float64(level) + 2220) +} + +// experienceForLevel returns the amount experience needed to reach level + 1. +func experienceForLevel(level int) int { + if level <= 15 { + return 2*level + 7 + } else if level <= 30 { + return 5*level - 38 + } + return 9*level - 158 +} diff --git a/server/entity/experience_orb.go b/server/entity/experience_orb.go new file mode 100644 index 0000000..21b1788 --- /dev/null +++ b/server/entity/experience_orb.go @@ -0,0 +1,66 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" + "math/rand/v2" + "slices" +) + +// orbSplitSizes contains split sizes used for dropping experience orbs. +var orbSplitSizes = []int{2477, 1237, 617, 307, 149, 73, 37, 17, 7, 3, 1} + +// NewExperienceOrbs takes in a position and an amount and automatically splits the amount into multiple orbs, returning +// a slice of the created orbs. +func NewExperienceOrbs(pos mgl64.Vec3, amount int) (orbs []*world.EntityHandle) { + for amount > 0 { + size := orbSplitSizes[slices.IndexFunc(orbSplitSizes, func(value int) bool { + return amount >= value + })] + + orbs = append(orbs, NewExperienceOrb(world.EntitySpawnOpts{Position: pos}, size)) + amount -= size + } + return +} + +// NewExperienceOrb creates a new experience orb and returns it. +func NewExperienceOrb(opts world.EntitySpawnOpts, xp int) *world.EntityHandle { + conf := experienceOrbConf + conf.Experience = xp + if opts.Velocity.Len() == 0 { + opts.Velocity = mgl64.Vec3{(rand.Float64()*0.2 - 0.1) * 2, rand.Float64() * 0.4, (rand.Float64()*0.2 - 0.1) * 2} + } + return opts.New(ExperienceOrbType, conf) +} + +var experienceOrbConf = ExperienceOrbBehaviourConfig{ + Gravity: 0.04, + Drag: 0.02, +} + +// ExperienceOrbType is a world.EntityType implementation for ExperienceOrb. +var ExperienceOrbType experienceOrbType + +type experienceOrbType struct{} + +func (t experienceOrbType) Open(tx *world.Tx, handle *world.EntityHandle, data *world.EntityData) world.Entity { + return &Ent{tx: tx, handle: handle, data: data} +} + +func (experienceOrbType) EncodeEntity() string { return "minecraft:xp_orb" } +func (experienceOrbType) BBox(world.Entity) cube.BBox { + return cube.Box(-0.125, 0, -0.125, 0.125, 0.25, 0.125) +} + +func (experienceOrbType) DecodeNBT(m map[string]any, data *world.EntityData) { + conf := experienceOrbConf + conf.Experience = int(nbtconv.Int32(m, "Value")) + data.Data = conf.New() +} + +func (experienceOrbType) EncodeNBT(data *world.EntityData) map[string]any { + return map[string]any{"Value": int32(data.Data.(*ExperienceOrbBehaviour).Experience())} +} diff --git a/server/entity/experience_orb_behaviour.go b/server/entity/experience_orb_behaviour.go new file mode 100644 index 0000000..f90453e --- /dev/null +++ b/server/entity/experience_orb_behaviour.go @@ -0,0 +1,124 @@ +package entity + +import ( + "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 +// an ExperienceOrbBehaviour. +type ExperienceOrbBehaviourConfig struct { + // Gravity is the amount of Y velocity subtracted every tick. + Gravity float64 + // Drag is used to reduce all axes of the velocity every tick. Velocity is + // multiplied with (1-Drag) every tick. + Drag float64 + // ExistenceDuration specifies how long the experience orb should last. The + // default is time.Minute * 5. + ExistenceDuration time.Duration + // Experience is the amount of experience held by the orb. Default is 1. + Experience int +} + +func (conf ExperienceOrbBehaviourConfig) Apply(data *world.EntityData) { + data.Data = conf.New() +} + +// New creates an ExperienceOrbBehaviour using the parameters in conf. +func (conf ExperienceOrbBehaviourConfig) New() *ExperienceOrbBehaviour { + if conf.Experience == 0 { + conf.Experience = 1 + } + if conf.ExistenceDuration == 0 { + conf.ExistenceDuration = time.Minute * 5 + } + b := &ExperienceOrbBehaviour{conf: conf, lastSearch: time.Now()} + b.passive = PassiveBehaviourConfig{ + Gravity: conf.Gravity, + Drag: conf.Drag, + ExistenceDuration: conf.ExistenceDuration, + Tick: b.tick, + }.New() + return b +} + +// ExperienceOrbBehaviour implements Behaviour for an experience orb entity. +type ExperienceOrbBehaviour struct { + conf ExperienceOrbBehaviourConfig + + passive *PassiveBehaviour + + lastSearch time.Time + target *world.EntityHandle +} + +// Experience returns the amount of experience the orb carries. +func (exp *ExperienceOrbBehaviour) Experience() int { + return exp.conf.Experience +} + +// Tick finds a target for the experience orb and moves the orb towards it. +func (exp *ExperienceOrbBehaviour) Tick(e *Ent, tx *world.Tx) *Movement { + return exp.passive.Tick(e, tx) +} + +// followBox is the bounding box used to search for collectors to follow for experience orbs. +var followBox = cube.Box(-8, -8, -8, 8, 8, 8) + +// tick finds a target for the experience orb and moves the orb towards it. +func (exp *ExperienceOrbBehaviour) tick(e *Ent, tx *world.Tx) { + targetEnt, ok := exp.target.Entity(tx) + target, _ := targetEnt.(experienceCollector) + + pos := e.Position() + hasTarget := ok && !target.Dead() && pos.Sub(target.Position()).Len() <= 8 + if !hasTarget && time.Since(exp.lastSearch) >= time.Second { + exp.findTarget(tx, pos) + } else if hasTarget { + exp.moveToTarget(e, target) + } +} + +// findTarget attempts to find a target for an experience orb in w around pos. +func (exp *ExperienceOrbBehaviour) findTarget(tx *world.Tx, pos mgl64.Vec3) { + exp.target = nil + for o := range tx.EntitiesWithin(followBox.Translate(pos)) { + if ec, ok := o.(experienceCollector); ok && ec.CanCollectExperience() { + exp.target = o.H() + break + } + } + exp.lastSearch = time.Now() +} + +// moveToTarget applies velocity to the experience orb so that it moves towards +// its current target. If it intersects with the target, the orb is collected. +func (exp *ExperienceOrbBehaviour) moveToTarget(e *Ent, target experienceCollector) { + pos, dst := e.Position(), target.Position() + if o, ok := target.(Eyed); ok { + dst[1] += o.EyeHeight() / 2 + } + diff := dst.Sub(pos).Mul(0.125) + if dist := diff.LenSqr(); dist < 1 { + e.SetVelocity(e.Velocity().Add(diff.Normalize().Mul(0.2 * math.Pow(1-math.Sqrt(dist), 2)))) + } + + if e.H().Type().BBox(e).Translate(pos).IntersectsWith(target.H().Type().BBox(target).Translate(target.Position())) && target.CollectExperience(exp.conf.Experience) { + _ = e.Close() + } +} + +// experienceCollector represents an entity that can collect experience orbs. +type experienceCollector interface { + Living + // CollectExperience makes the player collect the experience points passed, + // adding it to the experience manager. A bool is returned indicating + // whether the player was able to collect the experience, or not, due to the + // 100ms delay between experience collection. + CollectExperience(value int) bool + // CanCollectExperience returns whether the player can collect experience or not. + CanCollectExperience() bool +} diff --git a/server/entity/falling_block.go b/server/entity/falling_block.go new file mode 100644 index 0000000..38f93dc --- /dev/null +++ b/server/entity/falling_block.go @@ -0,0 +1,45 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/world" +) + +// NewFallingBlock creates a new FallingBlock entity. +func NewFallingBlock(opts world.EntitySpawnOpts, block world.Block) *world.EntityHandle { + conf := fallingBlockConf + conf.Block = block + return opts.New(FallingBlockType, conf) +} + +var fallingBlockConf = FallingBlockBehaviourConfig{ + Gravity: 0.04, + Drag: 0.02, +} + +// FallingBlockType is a world.EntityType implementation for FallingBlock. +var FallingBlockType fallingBlockType + +type fallingBlockType struct{} + +func (t fallingBlockType) Open(tx *world.Tx, handle *world.EntityHandle, data *world.EntityData) world.Entity { + return &Ent{tx: tx, handle: handle, data: data} +} +func (fallingBlockType) EncodeEntity() string { return "minecraft:falling_block" } +func (fallingBlockType) NetworkOffset() float64 { return 0.49 } +func (fallingBlockType) BBox(world.Entity) cube.BBox { + return cube.Box(-0.49, 0, -0.49, 0.49, 0.98, 0.49) +} + +func (fallingBlockType) DecodeNBT(m map[string]any, data *world.EntityData) { + conf := fallingBlockConf + conf.Block = nbtconv.Block(m, "FallingBlock") + conf.DistanceFallen = nbtconv.Float64(m, "FallDistance") + data.Data = conf.New() +} + +func (fallingBlockType) EncodeNBT(data *world.EntityData) map[string]any { + b := data.Data.(*FallingBlockBehaviour) + return map[string]any{"FallDistance": b.passive.fallDistance, "FallingBlock": nbtconv.WriteBlock(b.block)} +} diff --git a/server/entity/falling_block_behaviour.go b/server/entity/falling_block_behaviour.go new file mode 100644 index 0000000..7ea9ea8 --- /dev/null +++ b/server/entity/falling_block_behaviour.go @@ -0,0 +1,137 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/block" + "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" +) + +// FallingBlockBehaviourConfig holds optional parameters for +// FallingBlockBehaviour. +type FallingBlockBehaviourConfig struct { + Block world.Block + // Gravity is the amount of Y velocity subtracted every tick. + Gravity float64 + // Drag is used to reduce all axes of the velocity every tick. Velocity is + // multiplied with (1-Drag) every tick. + Drag float64 + // DistanceFallen specifies how far the falling block has already fallen. + // Blocks that damage entities on impact, like anvils, deal increased damage + // based on the distance fallen. + DistanceFallen float64 +} + +func (conf FallingBlockBehaviourConfig) Apply(data *world.EntityData) { + data.Data = conf.New() +} + +// New creates a FallingBlockBehaviour using the optional parameters in conf and +// a block type. +func (conf FallingBlockBehaviourConfig) New() *FallingBlockBehaviour { + behaviour := &FallingBlockBehaviour{block: conf.Block} + behaviour.passive = PassiveBehaviourConfig{ + Gravity: conf.Gravity, + Drag: conf.Drag, + Tick: behaviour.tick, + }.New() + behaviour.passive.fallDistance = conf.DistanceFallen + return behaviour +} + +// FallingBlockBehaviour implements the behaviour for falling block entities. +type FallingBlockBehaviour struct { + passive *PassiveBehaviour + block world.Block +} + +// Block returns the world.Block of the entity. +func (f *FallingBlockBehaviour) Block() world.Block { + return f.block +} + +// Tick implements the movement and solidification behaviour of falling blocks. +func (f *FallingBlockBehaviour) Tick(e *Ent, tx *world.Tx) *Movement { + return f.passive.Tick(e, tx) +} + +// tick checks if the falling block should solidify. +func (f *FallingBlockBehaviour) tick(e *Ent, tx *world.Tx) { + pos := e.Position() + bpos := cube.PosFromVec3(pos) + if a, ok := f.block.(Solidifiable); (ok && a.Solidifies(bpos, tx)) || f.passive.mc.OnGround() { + f.solidify(e, pos, tx) + } +} + +// solidify attempts to solidify the falling block at the position passed. It +// also deals damage to any entities standing at that position. If the block at +// the position could not be replaced by the falling block, the block will drop +// as an item. +func (f *FallingBlockBehaviour) solidify(e *Ent, pos mgl64.Vec3, tx *world.Tx) { + bpos := cube.PosFromVec3(pos) + + if d, ok := f.block.(damager); ok { + f.damageEntities(e, d, pos, tx) + } + if l, ok := f.block.(landable); ok { + l.Landed(tx, bpos) + } + f.passive.close = true + + if r, ok := tx.Block(bpos).(replaceable); ok && r.ReplaceableBy(f.block) { + tx.SetBlock(bpos, f.block, nil) + } else if i, ok := f.block.(world.Item); ok { + opts := world.EntitySpawnOpts{Position: bpos.Vec3Middle()} + tx.AddEntity(NewItem(opts, item.NewStack(i, 1))) + } +} + +// damageEntities attempts to damage any entities standing below the falling +// block. This functionality is used by falling anvils. +func (f *FallingBlockBehaviour) damageEntities(e *Ent, d damager, pos mgl64.Vec3, tx *world.Tx) { + damagePerBlock, maxDamage := d.Damage() + dist := math.Ceil(f.passive.fallDistance - 1.0) + if dist <= 0 { + return + } + dmg := math.Min(math.Floor(dist*damagePerBlock), maxDamage) + src := block.DamageSource{Block: f.block} + + for e := range filterLiving(tx.EntitiesWithin(e.H().Type().BBox(e).Translate(pos).Grow(0.05))) { + e.(Living).Hurt(dmg, src) + } + if b, ok := f.block.(breakable); ok && dmg > 0.0 && rand.Float64() < (dist+1)*0.05 { + f.block = b.Break() + } +} + +// Solidifiable represents a block that can solidify by specific adjacent blocks. An example is concrete +// powder, which can turn into concrete by touching water. +type Solidifiable interface { + // Solidifies returns whether the falling block can solidify at the position it is currently in. If so, + // the block will immediately stop falling. + Solidifies(pos cube.Pos, tx *world.Tx) bool +} + +type replaceable interface { + ReplaceableBy(b world.Block) bool +} + +// damager ... +type damager interface { + Damage() (damagePerBlock, maxDamage float64) +} + +// breakable ... +type breakable interface { + Break() world.Block +} + +// landable ... +type landable interface { + Landed(tx *world.Tx, pos cube.Pos) +} diff --git a/server/entity/firework.go b/server/entity/firework.go new file mode 100644 index 0000000..db13532 --- /dev/null +++ b/server/entity/firework.go @@ -0,0 +1,60 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// NewFirework creates a firework entity. Firework is an item (and entity) used +// for creating decorative explosions, boosting when flying with elytra, and +// loading into a crossbow as ammunition. +func NewFirework(opts world.EntitySpawnOpts, firework item.Firework) *world.EntityHandle { + return newFirework(opts, firework, nil, 1.15, 0.04, false) +} + +// NewFireworkAttached creates a firework entity with an owner that the firework +// may be attached to. +func NewFireworkAttached(opts world.EntitySpawnOpts, firework item.Firework, owner world.Entity) *world.EntityHandle { + return newFirework(opts, firework, owner, 0, 0, true) +} + +func newFirework(opts world.EntitySpawnOpts, firework item.Firework, owner world.Entity, sidewaysVelocityMultiplier, upwardsAcceleration float64, attached bool) *world.EntityHandle { + conf := fireworkConf + conf.SidewaysVelocityMultiplier = sidewaysVelocityMultiplier + conf.UpwardsAcceleration = upwardsAcceleration + conf.Firework = firework + conf.ExistenceDuration = firework.RandomisedDuration() + conf.Attached = attached + if attached { + conf.Owner = owner.H() + } + return opts.New(FireworkType, conf) +} + +var fireworkConf = FireworkBehaviourConfig{} + +// FireworkType is a world.EntityType implementation for Firework. +var FireworkType fireworkType + +type fireworkType struct{} + +func (t fireworkType) Open(tx *world.Tx, handle *world.EntityHandle, data *world.EntityData) world.Entity { + return &Ent{tx: tx, handle: handle, data: data} +} + +func (fireworkType) EncodeEntity() string { return "minecraft:fireworks_rocket" } +func (fireworkType) BBox(world.Entity) cube.BBox { return cube.BBox{} } + +func (fireworkType) DecodeNBT(m map[string]any, data *world.EntityData) { + conf := fireworkConf + conf.Firework = nbtconv.MapItem(m, "Item").Item().(item.Firework) + conf.ExistenceDuration = conf.Firework.RandomisedDuration() + + data.Data = conf.New() +} + +func (fireworkType) EncodeNBT(data *world.EntityData) map[string]any { + return map[string]any{"Item": nbtconv.WriteItem(item.NewStack(data.Data.(*FireworkBehaviour).Firework(), 1), true)} +} diff --git a/server/entity/firework_behaviour.go b/server/entity/firework_behaviour.go new file mode 100644 index 0000000..a09a09f --- /dev/null +++ b/server/entity/firework_behaviour.go @@ -0,0 +1,146 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/block/cube/trace" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "iter" + "math" + "time" +) + +// FireworkBehaviourConfig holds optional parameters for a FireworkBehaviour. +type FireworkBehaviourConfig struct { + Firework item.Firework + Owner *world.EntityHandle + // ExistenceDuration is the duration that an entity with this behaviour + // should last. Once this time expires, the entity is closed. If + // ExistenceDuration is 0, the entity will never expire automatically. + ExistenceDuration time.Duration + // SidewaysVelocityMultiplier is a value that the sideways velocity (X/Z) is + // multiplied with every tick. For normal fireworks this is 1.15. + SidewaysVelocityMultiplier float64 + // UpwardsAcceleration is a value added to the firework's velocity every + // tick. For normal fireworks, this is 0.04. + UpwardsAcceleration float64 + // Attached specifies if the firework is attached to its owner. If true, + // the firework will boost the speed of the owner while flying. + Attached bool +} + +func (conf FireworkBehaviourConfig) Apply(data *world.EntityData) { + data.Data = conf.New() +} + +// New creates a FireworkBehaviour for an fw and owner using the optional +// parameters in conf. +func (conf FireworkBehaviourConfig) New() *FireworkBehaviour { + b := &FireworkBehaviour{conf: conf} + b.passive = PassiveBehaviourConfig{ + ExistenceDuration: conf.ExistenceDuration, + Expire: b.explode, + Tick: b.tick, + }.New() + return b +} + +// FireworkBehaviour implements Behaviour for a firework entity. +type FireworkBehaviour struct { + conf FireworkBehaviourConfig + passive *PassiveBehaviour +} + +// Firework returns the underlying item.Firework of the FireworkBehaviour. +func (f *FireworkBehaviour) Firework() item.Firework { + return f.conf.Firework +} + +// Attached specifies if the firework is attached to its owner. +func (f *FireworkBehaviour) Attached() bool { + return f.conf.Attached +} + +// Owner returns the world.Entity that launched the firework. +func (f *FireworkBehaviour) Owner() *world.EntityHandle { + return f.conf.Owner +} + +// Tick moves the firework and makes it explode when it reaches its maximum +// duration. +func (f *FireworkBehaviour) Tick(e *Ent, tx *world.Tx) *Movement { + return f.passive.Tick(e, tx) +} + +// tick ticks the entity, updating its velocity either with a constant factor +// or based on the owner's position and velocity if attached. +func (f *FireworkBehaviour) tick(e *Ent, tx *world.Tx) { + owner, ok := f.conf.Owner.Entity(tx) + if f.conf.Attached && ok { + // The client will propel itself to match the firework's velocity since + // we set the appropriate metadata. + e.data.Pos = owner.Position() + } else { + e.data.Vel[0] *= f.conf.SidewaysVelocityMultiplier + e.data.Vel[1] += f.conf.UpwardsAcceleration + e.data.Vel[2] *= f.conf.SidewaysVelocityMultiplier + } +} + +// explode causes an explosion at the position of the firework, spawning +// particles and damaging nearby entities. +func (f *FireworkBehaviour) explode(e *Ent, tx *world.Tx) { + owner, _ := f.conf.Owner.Entity(tx) + pos, explosions := e.Position(), f.conf.Firework.Explosions + + for _, v := range tx.Viewers(pos) { + v.ViewEntityAction(e, FireworkExplosionAction{}) + } + for _, explosion := range explosions { + if explosion.Shape == item.FireworkShapeHugeSphere() { + tx.PlaySound(pos, sound.FireworkHugeBlast{}) + } else { + tx.PlaySound(pos, sound.FireworkBlast{}) + } + if explosion.Twinkle { + tx.PlaySound(pos, sound.FireworkTwinkle{}) + } + } + + if len(explosions) == 0 { + return + } + + force := float64(len(explosions)*2) + 5.0 + for victim := range filterLiving(tx.EntitiesWithin(e.H().Type().BBox(e).Translate(pos).Grow(5.25))) { + tpos := victim.Position() + dist := pos.Sub(tpos).Len() + if dist > 5.0 { + // The maximum distance allowed is 5.0 blocks. + continue + } + dmg := force * math.Sqrt((5.0-dist)/5.0) + src := ProjectileDamageSource{Owner: owner, Projectile: e} + + if pos == tpos { + victim.(Living).Hurt(dmg, src) + continue + } + if _, ok := trace.Perform(pos, tpos, tx, victim.H().Type().BBox(victim).Grow(0.3), nil); ok { + victim.(Living).Hurt(dmg, src) + } + } +} + +func filterLiving(seq iter.Seq[world.Entity]) iter.Seq[world.Entity] { + return func(yield func(world.Entity) bool) { + for e := range seq { + if _, living := e.(Living); !living { + continue + } + if !yield(e) { + return + } + } + } +} diff --git a/server/entity/flammable.go b/server/entity/flammable.go new file mode 100644 index 0000000..7df72c5 --- /dev/null +++ b/server/entity/flammable.go @@ -0,0 +1,13 @@ +package entity + +import "time" + +// Flammable is an interface for entities that can be set on fire. +type Flammable interface { + // OnFireDuration returns duration of fire in ticks. + OnFireDuration() time.Duration + // SetOnFire sets the entity on fire for the specified duration. + SetOnFire(duration time.Duration) + // Extinguish extinguishes the entity. + Extinguish() +} diff --git a/server/entity/healing.go b/server/entity/healing.go new file mode 100644 index 0000000..388963f --- /dev/null +++ b/server/entity/healing.go @@ -0,0 +1,9 @@ +package entity + +type ( + // FoodHealingSource is a healing source used for when an entity regenerates health automatically when their food + // bar is at least 90% filled. + FoodHealingSource struct{} +) + +func (FoodHealingSource) HealingSource() {} diff --git a/server/entity/health.go b/server/entity/health.go new file mode 100644 index 0000000..afd54c5 --- /dev/null +++ b/server/entity/health.go @@ -0,0 +1,42 @@ +package entity + +// HealthManager handles the health of an entity. +type HealthManager struct { + health float64 + max float64 +} + +// NewHealthManager returns a new health manager with the health and max health provided. +func NewHealthManager(health, max float64) *HealthManager { + if health > max { + health = max + } + return &HealthManager{health: health, max: max} +} + +// Health returns the current health of an entity. +func (m *HealthManager) Health() float64 { + return m.health +} + +// AddHealth adds a given amount of health points to the player. If the health added to the current health +// exceeds the max, health will be set to the max. If the health is instead negative and results in a health +// lower than 0, the final health will be 0. +func (m *HealthManager) AddHealth(health float64) { + m.health = max(min(m.health+health, m.max), 0) +} + +// MaxHealth returns the maximum health of the entity. +func (m *HealthManager) MaxHealth() float64 { + return m.max +} + +// SetMaxHealth changes the max health of an entity to the maximum passed. If the maximum is set to 0 or +// lower, SetMaxHealth will default to a value of 1. +func (m *HealthManager) SetMaxHealth(max float64) { + if max <= 0 { + max = 1 + } + m.max = max + m.health = min(m.health, max) +} diff --git a/server/entity/item.go b/server/entity/item.go new file mode 100644 index 0000000..165cac0 --- /dev/null +++ b/server/entity/item.go @@ -0,0 +1,66 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "time" +) + +// NewItem creates a new item entity using the item stack passed. The item +// entity will be positioned at the position passed. If the stack's count +// exceeds its max count, the count of the stack will be changed to the +// maximum. +func NewItem(opts world.EntitySpawnOpts, i item.Stack) *world.EntityHandle { + conf := itemConf + conf.Item = i + return opts.New(ItemType, conf) +} + +// NewItemPickupDelay creates a new item entity containing item stack i. A +// delay may be specified which defines for how long the item stack cannot be +// picked up from the ground. +func NewItemPickupDelay(opts world.EntitySpawnOpts, i item.Stack, delay time.Duration) *world.EntityHandle { + conf := itemConf + conf.Item = i + conf.PickupDelay = delay + return opts.New(ItemType, conf) +} + +var itemConf = ItemBehaviourConfig{ + Gravity: 0.04, + Drag: 0.02, +} + +// ItemType is a world.EntityType implementation for Item. +var ItemType itemType + +type itemType struct{} + +func (t itemType) Open(tx *world.Tx, handle *world.EntityHandle, data *world.EntityData) world.Entity { + return &Ent{tx: tx, handle: handle, data: data} +} + +func (itemType) EncodeEntity() string { return "minecraft:item" } +func (itemType) NetworkOffset() float64 { return 0.125 } +func (itemType) BBox(world.Entity) cube.BBox { + return cube.Box(-0.125, 0, -0.125, 0.125, 0.25, 0.125) +} + +func (itemType) DecodeNBT(m map[string]any, data *world.EntityData) { + conf := itemConf + conf.Item = nbtconv.MapItem(m, "Item") + conf.PickupDelay = time.Duration(nbtconv.Int64(m, "PickupDelay")) * (time.Second / 20) + + data.Data = conf.New() +} + +func (itemType) EncodeNBT(data *world.EntityData) map[string]any { + b := data.Data.(*ItemBehaviour) + return map[string]any{ + "Health": int16(5), + "PickupDelay": int64(b.pickupDelay / (time.Second * 20)), + "Item": nbtconv.WriteItem(b.Item(), true), + } +} diff --git a/server/entity/item_behaviour.go b/server/entity/item_behaviour.go new file mode 100644 index 0000000..8075064 --- /dev/null +++ b/server/entity/item_behaviour.go @@ -0,0 +1,198 @@ +package entity + +import ( + "math" + "time" + + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// ItemBehaviourConfig holds optional parameters for an ItemBehaviour. +type ItemBehaviourConfig struct { + Item item.Stack + // Gravity is the amount of Y velocity subtracted every tick. + Gravity float64 + // Drag is used to reduce all axes of the velocity every tick. Velocity is + // multiplied with (1-Drag) every tick. + Drag float64 + // ExistenceDuration specifies how long the item stack should last. The + // default is time.Minute * 5. + ExistenceDuration time.Duration + // PickupDelay specifies how much time must expire before the item can be + // picked up by collectors. The default is time.Second / 2. + PickupDelay time.Duration +} + +func (conf ItemBehaviourConfig) Apply(data *world.EntityData) { + data.Data = conf.New() +} + +// New creates an ItemBehaviour using i and the optional parameters in conf. +func (conf ItemBehaviourConfig) New() *ItemBehaviour { + i := conf.Item + if i.Count() > i.MaxCount() { + i = i.Grow(i.MaxCount() - i.Count()) + } + i = nbtconv.Item(nbtconv.WriteItem(i, true), nil) + + if conf.PickupDelay == 0 { + conf.PickupDelay = time.Second / 2 + } + if conf.ExistenceDuration == 0 { + conf.ExistenceDuration = time.Minute * 5 + } + + b := &ItemBehaviour{conf: conf, i: i, pickupDelay: conf.PickupDelay} + b.passive = PassiveBehaviourConfig{ + Gravity: conf.Gravity, + Drag: conf.Drag, + ExistenceDuration: conf.ExistenceDuration, + Tick: b.tick, + }.New() + return b +} + +// ItemBehaviour implements the behaviour of item entities. +type ItemBehaviour struct { + conf ItemBehaviourConfig + passive *PassiveBehaviour + i item.Stack + + pickupDelay time.Duration +} + +// Item returns the item.Stack held by the entity. +func (i *ItemBehaviour) Item() item.Stack { + return i.i +} + +// Tick moves the entity, checks if it should be picked up by a nearby collector +// or if it should merge with nearby item entities. +func (i *ItemBehaviour) Tick(e *Ent, tx *world.Tx) *Movement { + pos := cube.PosFromVec3(e.Position()) + blockPos := pos.Side(cube.FaceDown) + + bl, ok := tx.Block(blockPos).(block.Hopper) + if ok && !bl.Powered && bl.CollectCooldown <= 0 { + addedCount, err := bl.Inventory(tx, blockPos).AddItem(i.i) + if err != nil { + if addedCount == 0 { + return i.passive.Tick(e, tx) + } + + // This is only reached if part of the item stack was collected into the hopper. + opts := world.EntitySpawnOpts{Position: pos.Vec3Centre()} + tx.AddEntity(NewItem(opts, i.Item().Grow(-addedCount))) + } + + _ = e.Close() + bl.CollectCooldown = 8 + tx.SetBlock(blockPos, bl, nil) + } + return i.passive.Tick(e, tx) +} + +// Explode reacts to explosions. The item entity is destroyed, unless the item +// type is blast proof. +func (i *ItemBehaviour) Explode(e *Ent, src mgl64.Vec3, impact float64, conf block.ExplosionConfig) { + if impact > 0 { + if expl, ok := i.Item().Item().(interface{ BlastProof() bool }); ok && expl.BlastProof() { + return + } + _ = e.Close() + } +} + +// tick checks if the item can be picked up or merged with nearby item stacks. +func (i *ItemBehaviour) tick(e *Ent, tx *world.Tx) { + if i.pickupDelay == 0 { + i.checkNearby(e, tx) + } else if i.pickupDelay < math.MaxInt16*(time.Second/20) { + i.pickupDelay -= time.Second / 20 + } +} + +// checkNearby checks the nearby entities for item collectors and other item +// stacks. If a collector is found in range, the item will be picked up. If +// another item stack with the same item type is found in range, the item +// stacks will merge. +func (i *ItemBehaviour) checkNearby(e *Ent, tx *world.Tx) { + pos := e.Position() + bbox := e.H().Type().BBox(e) + grown := bbox.GrowVec3(mgl64.Vec3{1, 0.5, 1}).Translate(pos) + + for other := range tx.EntitiesWithin(bbox.Translate(pos).Grow(2)) { + if e.H() == other.H() || !other.H().Type().BBox(other).Translate(other.Position()).IntersectsWith(grown) { + continue + } + if collector, ok := other.(Collector); ok { + // A collector was within range to pick up the entity. + i.collect(e, collector, tx) + return + } else if other.H().Type() == ItemType { + // Another item entity was in range to merge with. + if i.merge(e, other.(*Ent), tx) { + return + } + } + } +} + +// merge merges the item entity with another item entity. +func (i *ItemBehaviour) merge(e *Ent, other *Ent, tx *world.Tx) bool { + pos := e.Position() + otherBehaviour := other.Behaviour().(*ItemBehaviour) + if otherBehaviour.i.Count() == otherBehaviour.i.MaxCount() || i.i.Count() == i.i.MaxCount() || !i.i.Comparable(otherBehaviour.i) { + // Either stack is already filled up to the maximum, meaning we can't + // change anything any way, other the stack types weren't comparable. + return false + } + a, b := otherBehaviour.i.AddStack(i.i) + + tx.AddEntity(NewItem(world.EntitySpawnOpts{Position: other.Position(), Velocity: other.Velocity()}, a)) + if !b.Empty() { + tx.AddEntity(NewItem(world.EntitySpawnOpts{Position: pos, Velocity: e.Velocity()}, b)) + } + _ = e.Close() + _ = other.Close() + return true +} + +// collect makes a collector collect the item (or at least part of it). +func (i *ItemBehaviour) collect(e *Ent, collector Collector, tx *world.Tx) { + pos := e.Position() + n, _ := collector.Collect(i.i) + if n == 0 { + return + } + for _, viewer := range tx.Viewers(pos) { + viewer.ViewEntityAction(e, PickedUpAction{Collector: collector}) + } + + if n == i.i.Count() { + // The collector picked up the entire stack. + _ = e.Close() + return + } + // Create a new item entity and shrink it by the amount of items that the + // collector collected. + tx.AddEntity(NewItem(world.EntitySpawnOpts{Position: pos}, i.i.Grow(-n))) + _ = e.Close() +} + +// Collector represents an entity in the world that is able to collect an item, typically an entity such as +// a player or a zombie. +type Collector interface { + world.Entity + // Collect collects the stack passed. It is called if the Collector is standing near an item entity that + // may be picked up. + // The count of items collected from the stack n is returned, along with a + // bool that indicates if the Collector was in a state where it could + // collect any items in the first place. + Collect(stack item.Stack) (n int, ok bool) +} diff --git a/server/entity/lightning.go b/server/entity/lightning.go new file mode 100644 index 0000000..46c3b6a --- /dev/null +++ b/server/entity/lightning.go @@ -0,0 +1,125 @@ +package entity + +import ( + "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" + "math/rand/v2" + "time" +) + +// NewLightning creates a lightning entity. The lightning entity will be +// positioned at the position passed. Lightning is a lethal element to +// thunderstorms. Lightning momentarily increases the skylight's brightness to +// slightly greater than full daylight. +func NewLightning(opts world.EntitySpawnOpts) *world.EntityHandle { + return NewLightningWithDamage(opts, 5, true, time.Second*8) +} + +// NewLightningWithDamage creates a new lightning entities using the damage and +// fire properties passed. +func NewLightningWithDamage(opts world.EntitySpawnOpts, dmg float64, blockFire bool, entityFireDuration time.Duration) *world.EntityHandle { + conf := lightningConf + conf.Tick = (&lightningState{ + Damage: dmg, + EntityFireDuration: entityFireDuration, + BlockFire: blockFire, + state: 2, + lifetime: rand.IntN(4) + 1, + }).tick + return opts.New(LightningType, conf) +} + +var lightningConf = StationaryBehaviourConfig{SpawnSounds: []world.Sound{sound.Explosion{}, sound.Thunder{}}, ExistenceDuration: time.Second} + +// lightningState holds the state of a lightning entity. +type lightningState struct { + Damage float64 + EntityFireDuration time.Duration + BlockFire bool + state, lifetime int +} + +// tick carries out lightning logic, dealing damage and setting blocks/entities +// on fire when appropriate. +func (s *lightningState) tick(e *Ent, tx *world.Tx) { + pos := e.Position() + + if s.state--; s.state < 0 { + if s.lifetime == 0 { + _ = e.Close() + } else if s.state < -rand.IntN(10) { + s.lifetime-- + s.state = 1 + + if s.BlockFire && tx.World().Difficulty().FireSpreadIncrease() >= 10 { + s.spreadFire(tx, cube.PosFromVec3(pos)) + } + } + } + if s.state > 0 { + s.dealDamage(e, tx) + } +} + +// dealDamage deals damage to all entities around the lightning and sets them +// on fire. +func (s *lightningState) dealDamage(e *Ent, tx *world.Tx) { + pos := e.Position() + bb := e.H().Type().BBox(e).GrowVec3(mgl64.Vec3{3, 6, 3}).Translate(pos.Add(mgl64.Vec3{0, 3})) + for e := range tx.EntitiesWithin(bb) { + // Only damage entities that weren't already dead. + if l, ok := e.(Living); ok && l.Health() > 0 { + if s.Damage > 0 { + l.Hurt(s.Damage, LightningDamageSource{}) + } + if f, ok := e.(Flammable); ok && f.OnFireDuration() < s.EntityFireDuration { + f.SetOnFire(s.EntityFireDuration) + } + } + } +} + +// spreadFire attempts to place fire at the position of the lightning and does +// 4 additional attempts to spread it around that position. +func (s *lightningState) spreadFire(tx *world.Tx, pos cube.Pos) { + s.fire().Start(tx, pos) + for i := 0; i < 4; i++ { + pos.Add(cube.Pos{rand.IntN(3) - 1, rand.IntN(3) - 1, rand.IntN(3) - 1}) + s.fire().Start(tx, pos) + } +} + +// fire returns a fire block. +func (s *lightningState) fire() interface { + Start(tx *world.Tx, pos cube.Pos) +} { + return fire().(interface { + Start(tx *world.Tx, pos cube.Pos) + }) +} + +// fire returns a fire block. +func fire() world.Block { + f, ok := world.BlockByName("minecraft:fire", map[string]any{"age": int32(0)}) + if !ok { + panic("could not find fire block") + } + return f +} + +// LightningType is a world.EntityType implementation for Lightning. +var LightningType lightningType + +type lightningType struct{} + +func (t lightningType) Open(tx *world.Tx, handle *world.EntityHandle, data *world.EntityData) world.Entity { + return &Ent{tx: tx, handle: handle, data: data} +} +func (t lightningType) DecodeNBT(_ map[string]any, data *world.EntityData) { + data.Data = lightningConf.New() +} +func (t lightningType) EncodeNBT(*world.EntityData) map[string]any { return nil } +func (lightningType) EncodeEntity() string { return "minecraft:lightning_bolt" } +func (lightningType) BBox(world.Entity) cube.BBox { return cube.BBox{} } diff --git a/server/entity/lingering_potion.go b/server/entity/lingering_potion.go new file mode 100644 index 0000000..fea905a --- /dev/null +++ b/server/entity/lingering_potion.go @@ -0,0 +1,54 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/item/potion" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/particle" +) + +// NewLingeringPotion creates a new lingering potion. LingeringPotion is a +// variant of a splash potion that can be thrown to leave clouds with status +// effects that linger on the ground in an area. +func NewLingeringPotion(opts world.EntitySpawnOpts, t potion.Potion, owner world.Entity) *world.EntityHandle { + colour, _ := effect.ResultingColour(t.Effects()) + + conf := splashPotionConf + conf.Potion = t + conf.Particle = particle.Splash{Colour: colour} + conf.Hit = potionSplash(0.25, t, true) + conf.Owner = owner.H() + return opts.New(LingeringPotionType, conf) +} + +// LingeringPotionType is a world.EntityType implementation for LingeringPotion. +var LingeringPotionType lingeringPotionType + +type lingeringPotionType struct{} + +func (t lingeringPotionType) Open(tx *world.Tx, handle *world.EntityHandle, data *world.EntityData) world.Entity { + return &Ent{tx: tx, handle: handle, data: data} +} + +func (lingeringPotionType) EncodeEntity() string { + return "minecraft:lingering_potion" +} +func (lingeringPotionType) BBox(world.Entity) cube.BBox { + return cube.Box(-0.125, 0, -0.125, 0.125, 0.25, 0.125) +} + +func (lingeringPotionType) DecodeNBT(m map[string]any, data *world.EntityData) { + conf := splashPotionConf + conf.Potion = potion.From(nbtconv.Int32(m, "PotionId")) + colour, _ := effect.ResultingColour(conf.Potion.Effects()) + conf.Particle = particle.Splash{Colour: colour} + conf.Hit = potionSplash(0.25, conf.Potion, true) + + data.Data = conf.New() +} + +func (lingeringPotionType) EncodeNBT(data *world.EntityData) map[string]any { + return map[string]any{"PotionId": int32(data.Data.(*ProjectileBehaviour).conf.Potion.Uint8())} +} diff --git a/server/entity/living.go b/server/entity/living.go new file mode 100644 index 0000000..0c2e30e --- /dev/null +++ b/server/entity/living.go @@ -0,0 +1,54 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// Living represents an entity that is alive and that has health. It is able to take damage and will die upon +// taking fatal damage. +type Living interface { + world.Entity + // Health returns the health of the entity. + Health() float64 + // MaxHealth returns the maximum health of the entity. + MaxHealth() float64 + // SetMaxHealth changes the maximum health of the entity to the value passed. + SetMaxHealth(v float64) + // Dead checks if the entity is considered dead. True is returned if the health of the entity is equal to or + // lower than 0. + Dead() bool + // Hurt hurts the entity for a given amount of damage. The source passed represents the cause of the + // damage, for example AttackDamageSource if the entity is attacked by another entity. + // If the final damage exceeds the health that the entity currently has, the entity is killed. + // Hurt returns the final amount of damage dealt to the Living entity and returns whether the Living entity + // was vulnerable to the damage at all. + Hurt(damage float64, src world.DamageSource) (n float64, vulnerable bool) + // Heal heals the entity for a given amount of health. The source passed represents the cause of the + // healing, for example FoodHealingSource if the entity healed by having a full food bar. If the health + // added to the original health exceeds the entity's max health, Heal may not add the full amount. + Heal(health float64, src world.HealingSource) + // KnockBack knocks the entity back with a given force and height. A source is passed which indicates the + // source of the velocity, typically the position of an attacking entity. The source is used to calculate + // the direction which the entity should be knocked back in. + KnockBack(src mgl64.Vec3, force, height float64) + // Velocity returns the players current velocity. + Velocity() mgl64.Vec3 + // SetVelocity updates the entity's velocity. + SetVelocity(velocity mgl64.Vec3) + // AddEffect adds an entity.Effect to the entity. If the effect is instant, it is applied to the entity + // immediately. If not, the effect is applied to the entity every time the Tick method is called. + // AddEffect will overwrite any effects present if the level of the effect is higher than the existing one, or + // if the effects' levels are equal and the new effect has a longer duration. + AddEffect(e effect.Effect) + // RemoveEffect removes any effect that might currently be active on the entity. + RemoveEffect(e effect.Type) + // Effects returns any effect currently applied to the entity. The returned effects are guaranteed not to have + // expired when returned. + Effects() []effect.Effect + // Speed returns the current speed of the living entity. The default value is different for each entity. + Speed() float64 + // SetSpeed sets the speed of an entity to a new value. + SetSpeed(float64) +} diff --git a/server/entity/movement.go b/server/entity/movement.go new file mode 100644 index 0000000..c68940c --- /dev/null +++ b/server/entity/movement.go @@ -0,0 +1,193 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" + "math" +) + +// MovementComputer is used to compute movement of an entity. When constructed, the Gravity of the entity +// the movement is computed for must be passed. +type MovementComputer struct { + Gravity, Drag float64 + DragBeforeGravity bool + + onGround bool +} + +// Movement represents the movement of a world.Entity as a result of a call to MovementComputer.TickMovement. The +// resulting position and velocity can be obtained by calling Position and Velocity. These can be sent to viewers by +// calling Send. +type Movement struct { + v []world.Viewer + e world.Entity + pos, vel, dpos, dvel mgl64.Vec3 + rot cube.Rotation + onGround bool +} + +// Send sends the Movement to any viewers watching the entity at the time of the movement. If the position/velocity +// changes were negligible, nothing is sent. +func (m *Movement) Send() { + posChanged := !m.dpos.ApproxEqualThreshold(zeroVec3, epsilon) + velChanged := !m.dvel.ApproxEqualThreshold(zeroVec3, epsilon) + + for _, v := range m.v { + if posChanged { + v.ViewEntityMovement(m.e, m.pos, m.rot, m.onGround) + } + if velChanged { + v.ViewEntityVelocity(m.e, m.vel) + } + } +} + +// Position returns the position as a result of the Movement as an mgl64.Vec3. +func (m *Movement) Position() mgl64.Vec3 { + return m.pos +} + +// Velocity returns the velocity after the Movement as an mgl64.Vec3. +func (m *Movement) Velocity() mgl64.Vec3 { + return m.vel +} + +// Rotation returns the rotation, yaw and pitch, of the entity after the Movement. +func (m *Movement) Rotation() cube.Rotation { + return m.rot +} + +// TickMovement performs a movement tick on an entity. Velocity is applied and changed according to the values +// of its Drag and Gravity. +// The new position of the entity after movement is returned. +// The resulting Movement can be sent to viewers by calling Movement.Send. +func (c *MovementComputer) TickMovement(e world.Entity, pos, vel mgl64.Vec3, rot cube.Rotation, tx *world.Tx) *Movement { + viewers := tx.Viewers(pos) + + velBefore := vel + vel = c.applyHorizontalForces(tx, pos, c.applyVerticalForces(vel)) + dPos, vel := c.checkCollision(tx, e, pos, vel) + + return &Movement{v: viewers, e: e, + pos: pos.Add(dPos), vel: vel, dpos: dPos, dvel: vel.Sub(velBefore), + rot: rot, onGround: c.onGround, + } +} + +// OnGround checks if the entity that this computer calculates is currently on the ground. +func (c *MovementComputer) OnGround() bool { + return c.onGround +} + +// zeroVec3 is a mgl64.Vec3 with zero values. +var zeroVec3 mgl64.Vec3 + +// epsilon is the epsilon used for thresholds for change used for change in position and velocity. +const epsilon = 0.001 + +// applyVerticalForces applies gravity and drag on the Y axis, based on the Gravity and Drag values set. +func (c *MovementComputer) applyVerticalForces(vel mgl64.Vec3) mgl64.Vec3 { + if c.DragBeforeGravity { + vel[1] *= 1 - c.Drag + } + vel[1] -= c.Gravity + if !c.DragBeforeGravity { + vel[1] *= 1 - c.Drag + } + return vel +} + +// applyHorizontalForces applies friction to the velocity based on the Drag value, reducing it on the X and Z axes. +func (c *MovementComputer) applyHorizontalForces(tx *world.Tx, pos, vel mgl64.Vec3) mgl64.Vec3 { + friction := 1 - c.Drag + if c.onGround { + if f, ok := tx.Block(cube.PosFromVec3(pos).Side(cube.FaceDown)).(interface { + Friction() float64 + }); ok { + friction *= f.Friction() + } else { + friction *= 0.6 + } + } + vel[0] *= friction + vel[2] *= friction + return vel +} + +// checkCollision handles the collision of the entity with blocks, adapting the velocity of the entity if it +// happens to collide with a block. +// The final velocity and the Vec3 that the entity should move is returned. +func (c *MovementComputer) checkCollision(tx *world.Tx, e world.Entity, pos, vel mgl64.Vec3) (mgl64.Vec3, mgl64.Vec3) { + // TODO: Implement collision with other entities. + deltaX, deltaY, deltaZ := vel[0], vel[1], vel[2] + + // Entities only ever have a single bounding box. + entityBBox := e.H().Type().BBox(e).Translate(pos) + blocks := blockBBoxsAround(tx, entityBBox.Extend(vel)) + + if !mgl64.FloatEqualThreshold(deltaY, 0, epsilon) { + // First we move the entity BBox on the Y axis. + for _, blockBBox := range blocks { + deltaY = entityBBox.YOffset(blockBBox, deltaY) + } + entityBBox = entityBBox.Translate(mgl64.Vec3{0, deltaY}) + } + if !mgl64.FloatEqualThreshold(deltaX, 0, epsilon) { + // Then on the X axis. + for _, blockBBox := range blocks { + deltaX = entityBBox.XOffset(blockBBox, deltaX) + } + entityBBox = entityBBox.Translate(mgl64.Vec3{deltaX}) + } + if !mgl64.FloatEqualThreshold(deltaZ, 0, epsilon) { + // And finally on the Z axis. + for _, blockBBox := range blocks { + deltaZ = entityBBox.ZOffset(blockBBox, deltaZ) + } + } + if !mgl64.FloatEqual(vel[1], 0) { + // The Y velocity of the entity is currently not 0, meaning it is moving either up or down. We can + // then assume the entity is not currently on the ground. + c.onGround = false + } + if !mgl64.FloatEqual(deltaX, vel[0]) { + vel[0] = 0 + } + if !mgl64.FloatEqual(deltaY, vel[1]) { + // The entity either hit the ground or hit the ceiling. + if vel[1] < 0 { + // The entity was going down, so we can assume it is now on the ground. + c.onGround = true + } + vel[1] = 0 + } + if !mgl64.FloatEqual(deltaZ, vel[2]) { + vel[2] = 0 + } + return mgl64.Vec3{deltaX, deltaY, deltaZ}, vel +} + +// blockBBoxsAround returns all blocks around the entity passed, using the BBox passed to make a prediction of +// what blocks need to have their BBox returned. +func blockBBoxsAround(tx *world.Tx, box cube.BBox) []cube.BBox { + grown := box.Grow(0.25) + min, max := grown.Min(), grown.Max() + minX, minY, minZ := int(math.Floor(min[0])), int(math.Floor(min[1])), int(math.Floor(min[2])) + maxX, maxY, maxZ := int(math.Ceil(max[0])), int(math.Ceil(max[1])), int(math.Ceil(max[2])) + + // A prediction of one BBox per block, plus an additional 2, in case + blockBBoxs := make([]cube.BBox, 0, (maxX-minX)*(maxY-minY)*(maxZ-minZ)+2) + for y := minY; y <= maxY; y++ { + for x := minX; x <= maxX; x++ { + for z := minZ; z <= maxZ; z++ { + pos := cube.Pos{x, y, z} + boxes := tx.Block(pos).Model().BBox(pos, tx) + for _, box := range boxes { + blockBBoxs = append(blockBBoxs, box.Translate(mgl64.Vec3{float64(x), float64(y), float64(z)})) + } + } + } + } + return blockBBoxs +} diff --git a/server/entity/passive.go b/server/entity/passive.go new file mode 100644 index 0000000..d1d8b50 --- /dev/null +++ b/server/entity/passive.go @@ -0,0 +1,104 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" + "math" + "time" +) + +// PassiveBehaviourConfig holds optional parameters for a PassiveBehaviour. +type PassiveBehaviourConfig struct { + // Gravity is the amount of Y velocity subtracted every tick. + Gravity float64 + // Drag is used to reduce all axes of the velocity every tick. Velocity is + // multiplied with (1-Drag) every tick. + Drag float64 + // ExistenceDuration is the duration that an entity with this behaviour + // should last. Once this time expires, the entity is closed. If + // ExistenceDuration is 0, the entity will never expire automatically. + ExistenceDuration time.Duration + // Expire is called when the entity expires due to its age reaching the + // ExistenceDuration. + Expire func(e *Ent, tx *world.Tx) + // Tick is called for every tick that the entity is alive. Tick is called + // after the entity moves on a tick. + Tick func(e *Ent, tx *world.Tx) +} + +func (conf PassiveBehaviourConfig) Apply(data *world.EntityData) { + data.Data = conf.New() +} + +// New creates a PassiveBehaviour using the parameters in conf. +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, + }} +} + +// 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 { + conf PassiveBehaviourConfig + mc *MovementComputer + + close bool + fallDistance float64 + fuse time.Duration +} + +// Explode adds velocity to a passive entity to blast it away from the +// explosion's source. +func (p *PassiveBehaviour) Explode(e *Ent, src mgl64.Vec3, impact float64, _ block.ExplosionConfig) { + e.data.Vel = e.data.Vel.Add(e.data.Pos.Sub(src).Normalize().Mul(impact)) +} + +// Fuse returns the leftover time until PassiveBehaviourConfig.Expire is called, +// or -1 if this function is not set. +func (p *PassiveBehaviour) Fuse() time.Duration { + if p.conf.Expire != nil { + return p.fuse + } + return -1 +} + +// Tick implements the behaviour for a passive entity. It performs movement and +// updates its state. +func (p *PassiveBehaviour) Tick(e *Ent, tx *world.Tx) *Movement { + if p.close { + _ = e.Close() + return nil + } + + m := p.mc.TickMovement(e, e.data.Pos, e.data.Vel, e.data.Rot, tx) + e.data.Pos, e.data.Vel = m.pos, m.vel + p.fallDistance = math.Max(p.fallDistance-m.dvel[1], 0) + + p.fuse = p.conf.ExistenceDuration - e.Age() + + if p.conf.Tick != nil { + p.conf.Tick(e, tx) + } + + if p.Fuse()%(time.Second/4) == 0 { + for _, v := range tx.Viewers(m.pos) { + v.ViewEntityState(e) + } + } + + if e.Age() > p.conf.ExistenceDuration { + p.close = true + if p.conf.Expire != nil { + p.conf.Expire(e, tx) + } + } + return m +} diff --git a/server/entity/projectile.go b/server/entity/projectile.go new file mode 100644 index 0000000..8c8a247 --- /dev/null +++ b/server/entity/projectile.go @@ -0,0 +1,361 @@ +package entity + +import ( + "iter" + "math" + "math/rand/v2" + "slices" + "time" + + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/block/cube/trace" + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/potion" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// ProjectileBehaviourConfig allows the configuration of projectiles. Calling +// ProjectileBehaviourConfig.New() creates a ProjectileBehaviour using these +// settings. +type ProjectileBehaviourConfig struct { + Owner *world.EntityHandle + // Gravity is the amount of Y velocity subtracted every tick. + Gravity float64 + // Drag is used to reduce all axes of the velocity every tick. Velocity is + // multiplied with (1-Drag) every tick. + Drag float64 + // Damage specifies the base damage dealt by the Projectile. If set to a + // negative number, entities hit are not hurt at all and are not knocked + // back. The base damage is multiplied with the velocity of the projectile + // to calculate the final damage of the projectile. + Damage float64 + // Potion is the potion effect that is applied to an entity when the + // projectile hits it. + Potion potion.Potion + // KnockBackForceAddend is the additional horizontal velocity that is + // applied to an entity when it is hit by the projectile. + KnockBackForceAddend float64 + // KnockBackHeightAddend is the additional vertical velocity that is applied + // to an entity when it is hit by the projectile. + KnockBackHeightAddend float64 + // Particle is a particle that is spawned when the projectile hits a + // target, either a block or an entity. No particle is spawned if left nil. + Particle world.Particle + // ParticleCount is the amount of particles that should be spawned if + // Particle is not nil. ParticleCount will be set to 1 if Particle is not + // nil and ParticleCount is 0. + ParticleCount int + // Sound is a sound that is played when the projectile hits a target, either + // a block or an entity. No sound is played if left nil. + Sound world.Sound + // Critical specifies if the projectile is critical. This spawns critical + // hit particles behind the projectile and causes it to deal up to 50% more + // damage. + Critical bool + // Hit is a function that is called when the projectile Ent hits a target + // (the trace.Result). The target is either of the type trace.EntityResult + // or trace.BlockResult. Hit may be set to run additional behaviour when a + // projectile hits a target. + Hit func(e *Ent, tx *world.Tx, target trace.Result) + // SurviveBlockCollision specifies if a projectile with this + // ProjectileBehaviour should survive collision with a block. If set to + // false, the projectile will break when hitting a block (like a snowball). + // If set to true, the projectile will survive like an arrow does. + SurviveBlockCollision bool + // BlockCollisionVelocityMultiplier is the multiplier used to modify the + // velocity of a projectile that has SurviveBlockCollision set to true. The + // default, 0, will cause the projectile to lose its velocity completely. A + // multiplier such as 0.5 will reduce the projectile's velocity, but retain + // half of it after inverting the axis on which the projectile collided. + BlockCollisionVelocityMultiplier float64 + // DisablePickup specifies if picking up the projectile should be disabled, + // which is relevant in the case SurviveBlockCollision is set to true. Some + // projectiles, such as arrows, cannot be picked up if they are shot by + // monsters like skeletons. + DisablePickup bool + // PickupItem is the item that is given to a player when it picks up this + // projectile. If left as an empty item.Stack, no item is given upon pickup. + PickupItem item.Stack + // CollisionPosition specifies the position that the projectile is stuck + // in. If non-empty, the entity will not move. + CollisionPosition cube.Pos + // PiercingLevel is the crossbow Piercing enchantment level. The projectile + // passes through PiercingLevel entities and damages PiercingLevel+1 in + // total. A value of 0 means no piercing. + PiercingLevel int +} + +func (conf ProjectileBehaviourConfig) Apply(data *world.EntityData) { + data.Data = conf.New() +} + +// New creates a new ProjectileBehaviour using conf. The owner passed may be nil +// if the projectile does not have one. +func (conf ProjectileBehaviourConfig) New() *ProjectileBehaviour { + if conf.ParticleCount == 0 && conf.Particle != nil { + conf.ParticleCount = 1 + } + return &ProjectileBehaviour{conf: conf, collided: conf.CollisionPosition != cube.Pos{}, collisionPos: conf.CollisionPosition, mc: &MovementComputer{ + Gravity: conf.Gravity, + Drag: conf.Drag, + DragBeforeGravity: true, + }} +} + +// ProjectileBehaviour implements the behaviour of projectiles. Its specifics +// may be configured using ProjectileBehaviourConfig. +type ProjectileBehaviour struct { + conf ProjectileBehaviourConfig + mc *MovementComputer + ageCollided int + close bool + + collisionPos cube.Pos + collided bool + + collidedEntities []*world.EntityHandle +} + +// Owner returns the owner of the projectile. +func (lt *ProjectileBehaviour) Owner() *world.EntityHandle { + return lt.conf.Owner +} + +// Explode adds velocity to a projectile to blast it away from the explosion's +// source. +func (lt *ProjectileBehaviour) Explode(e *Ent, src mgl64.Vec3, impact float64, _ block.ExplosionConfig) { + e.data.Vel = e.Velocity().Add(e.Position().Sub(src).Normalize().Mul(impact)) +} + +// Potion returns the potion.Potion that is applied to an entity if hit by the +// projectile. +func (lt *ProjectileBehaviour) Potion() potion.Potion { + return lt.conf.Potion +} + +// Critical returns true if ProjectileBehaviourConfig.Critical was set to true +// and if the projectile has not collided. +func (lt *ProjectileBehaviour) Critical() bool { + return lt.conf.Critical && !lt.collided +} + +// Tick runs the tick-based behaviour of a ProjectileBehaviour and returns the +// Movement within the tick. Tick handles the movement, collision and hitting +// of a projectile. +func (lt *ProjectileBehaviour) Tick(e *Ent, tx *world.Tx) *Movement { + if lt.close { + _ = e.Close() + return nil + } + + if lt.collided && lt.tickAttached(e, tx) { + if lt.ageCollided > 1200 { + lt.close = true + } + return nil + } + vel := e.Velocity() + m, result := lt.tickMovement(e, tx) + e.data.Pos, e.data.Vel = m.pos, m.vel + + lt.collisionPos, lt.collided, lt.ageCollided = cube.Pos{}, false, 0 + + if result == nil { + return m + } + + for i := 0; i < lt.conf.ParticleCount; i++ { + tx.AddParticle(result.Position(), lt.conf.Particle) + } + if lt.conf.Sound != nil { + tx.PlaySound(result.Position(), lt.conf.Sound) + } + + switch r := result.(type) { + case trace.EntityResult: + if l, ok := r.Entity().(Living); ok { + if lt.conf.Damage >= 0 { + lt.hitEntity(l, e, vel) + } + lt.collidedEntities = append(lt.collidedEntities, l.H()) + } + case trace.BlockResult: + bpos := r.BlockPosition() + if h, ok := tx.Block(bpos).(block.ProjectileHitter); ok { + h.ProjectileHit(bpos, tx, e, r.Face()) + } + if lt.conf.SurviveBlockCollision { + lt.hitBlockSurviving(e, r, m, tx) + return m + } + lt.close = true + } + if lt.conf.Hit != nil { + lt.conf.Hit(e, tx, result) + } + + if len(lt.collidedEntities) > lt.conf.PiercingLevel { + lt.close = true + } + return m +} + +// tickAttached performs the attached logic for a projectile. It checks if the +// projectile is still attached to a block and if it can be picked up. +func (lt *ProjectileBehaviour) tickAttached(e *Ent, tx *world.Tx) bool { + boxes := tx.Block(lt.collisionPos).Model().BBox(lt.collisionPos, tx) + box := e.H().Type().BBox(e).Translate(e.Position()) + + for _, bb := range boxes { + if box.IntersectsWith(bb.Translate(lt.collisionPos.Vec3()).Grow(0.05)) { + if lt.ageCollided > 5 && !lt.conf.DisablePickup { + lt.tryPickup(e, tx) + } + lt.ageCollided++ + return true + } + } + return false +} + +// tryPickup checks for nearby projectile collectors and closes the entity if +// one was found. +func (lt *ProjectileBehaviour) tryPickup(e *Ent, tx *world.Tx) { + translated := e.H().Type().BBox(e).Translate(e.Position()) + grown := translated.GrowVec3(mgl64.Vec3{1, 0.5, 1}) + for other := range tx.EntitiesWithin(translated.Grow(2)) { + if !other.H().Type().BBox(other).Translate(other.Position()).IntersectsWith(grown) { + continue + } + collector, ok := other.(Collector) + if !ok { + continue + } + if _, ok := collector.Collect(lt.conf.PickupItem); !ok { + continue + } + + // A collector was within range and able to pick up the entity. + lt.close = true + for _, viewer := range tx.Viewers(e.Position()) { + viewer.ViewEntityAction(e, PickedUpAction{Collector: collector}) + } + } +} + +// hitBlockSurviving is called if +// ProjectileBehaviourConfig.SurviveBlockCollision is set to true and the +// projectile collides with a block. If the resulting velocity is roughly 0, +// it sets the projectile as having collided with the block. +func (lt *ProjectileBehaviour) hitBlockSurviving(e *Ent, r trace.BlockResult, m *Movement, tx *world.Tx) { + // Create an epsilon for deciding if the projectile has slowed down enough + // for us to consider it as having collided for the final time. We take the + // square root because FloatEqualThreshold squares it, which is not what we + // want. + eps := math.Sqrt(0.1 * (1 - lt.conf.BlockCollisionVelocityMultiplier)) + if mgl64.FloatEqualThreshold(e.Velocity().Len(), 0, eps) { + e.SetVelocity(mgl64.Vec3{}) + lt.collisionPos, lt.collided = r.BlockPosition(), true + + for _, v := range tx.Viewers(m.pos) { + v.ViewEntityAction(e, ArrowShakeAction{Duration: time.Millisecond * 350}) + v.ViewEntityState(e) + } + return + } +} + +// hitEntity is called when a projectile hits a Living. It deals damage to the +// entity and knocks it back. Additionally, it applies any potion effects and +// fire if applicable. +func (lt *ProjectileBehaviour) hitEntity(l Living, e *Ent, vel mgl64.Vec3) { + owner, _ := lt.conf.Owner.Entity(e.tx) + src := ProjectileDamageSource{Projectile: e, Owner: owner} + dmg := math.Ceil(lt.conf.Damage * vel.Len()) + if lt.conf.Critical { + dmg += rand.Float64() * dmg / 2 + } + // TODO: Piercing arrows should bypass shield blocking when shields are implemented. + if _, vulnerable := l.Hurt(dmg, src); vulnerable { + l.KnockBack(l.Position().Sub(vel), 0.45+lt.conf.KnockBackForceAddend, 0.3608+lt.conf.KnockBackHeightAddend) + + for _, eff := range lt.conf.Potion.Effects() { + if lasting, ok := eff.Type().(effect.LastingType); ok { + l.AddEffect(effect.New(lasting, eff.Level(), time.Duration(float64(eff.Duration())/8))) + continue + } + l.AddEffect(eff) + } + if flammable, ok := l.(Flammable); ok && e.OnFireDuration() > 0 { + flammable.SetOnFire(time.Second * 5) + } + } +} + +// tickMovement ticks the movement of a projectile. It updates the position and +// rotation of the projectile based on its velocity and updates the velocity +// based on gravity and drag. +func (lt *ProjectileBehaviour) tickMovement(e *Ent, tx *world.Tx) (*Movement, trace.Result) { + pos, vel := e.Position(), e.Velocity() + viewers := tx.Viewers(pos) + + velBefore := vel + vel = lt.mc.applyHorizontalForces(tx, pos, lt.mc.applyVerticalForces(vel)) + rot := cube.Rotation{ + mgl64.RadToDeg(math.Atan2(vel[0], vel[2])), + mgl64.RadToDeg(math.Atan2(vel[1], math.Hypot(vel[0], vel[2]))), + } + + var ( + end = pos.Add(vel) + hit trace.Result + ok bool + ) + if !mgl64.FloatEqual(end.Sub(pos).LenSqr(), 0) { + if hit, ok = trace.Perform(pos, end, tx, e.H().Type().BBox(e).Grow(1.0), lt.ignores(e)); ok { + if _, ok := hit.(trace.BlockResult); ok { + // Undo the gravity because the velocity as a result of gravity + // at the point of collision should be 0. + vel[1] = (vel[1] + lt.mc.Gravity) / (1 - lt.mc.Drag) + x, y, z := vel.Mul(lt.conf.BlockCollisionVelocityMultiplier).Elem() + // Calculate multipliers for all coordinates: 1 for the ones that + // weren't on the same axis as the one collided with, -1 for the one + // that was on that axis to deflect the projectile. + mx, my, mz := hit.Face().Axis().Vec3().Mul(-2).Add(mgl64.Vec3{1, 1, 1}).Elem() + + vel = mgl64.Vec3{x * mx, y * my, z * mz} + } else if lt.conf.PiercingLevel == 0 { + vel = zeroVec3 + } + end = hit.Position() + } + } + return &Movement{v: viewers, e: e, pos: end, vel: vel, dpos: end.Sub(pos), dvel: vel.Sub(velBefore), rot: rot}, hit +} + +// ignores returns a function to ignore entities in trace.Perform that are +// either a spectator, not living, the entity itself, its owner in the first +// 5 ticks, or an entity it already collided with. +func (lt *ProjectileBehaviour) ignores(e *Ent) trace.EntityFilter { + return func(seq iter.Seq[world.Entity]) iter.Seq[world.Entity] { + return func(yield func(world.Entity) bool) { + for other := range seq { + g, ok := other.(interface{ GameMode() world.GameMode }) + spectator := ok && !g.GameMode().HasCollision() + itself := e.H() == other.H() + _, living := other.(Living) + owner := e.data.Age < time.Second/4 && lt.conf.Owner == other.H() + collidedEntity := slices.Contains(lt.collidedEntities, other.H()) + if spectator || itself || !living || owner || collidedEntity { + continue + } + if !yield(other) { + return + } + } + } + } +} diff --git a/server/entity/register.go b/server/entity/register.go new file mode 100644 index 0000000..7155ec9 --- /dev/null +++ b/server/entity/register.go @@ -0,0 +1,63 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/enchantment" + "github.com/df-mc/dragonfly/server/item/potion" + "github.com/df-mc/dragonfly/server/world" +) + +// DefaultRegistry is a world.EntityRegistry that registers all default entities +// implemented by Dragonfly. +var DefaultRegistry = conf.New([]world.EntityType{ + AreaEffectCloudType, + ArrowType, + BottleOfEnchantingType, + EggType, + EnderPearlType, + ExperienceOrbType, + FallingBlockType, + FireworkType, + ItemType, + LightningType, + LingeringPotionType, + SnowballType, + SplashPotionType, + TNTType, + TextType, +}) + +var conf = world.EntityRegistryConfig{ + TNT: NewTNT, + Egg: NewEgg, + Snowball: NewSnowball, + BottleOfEnchanting: NewBottleOfEnchanting, + EnderPearl: NewEnderPearl, + FallingBlock: NewFallingBlock, + Lightning: NewLightning, + Firework: func(opts world.EntitySpawnOpts, firework world.Item, owner world.Entity, sidewaysVelocityMultiplier, upwardsAcceleration float64, attached bool) *world.EntityHandle { + return newFirework(opts, firework.(item.Firework), owner, sidewaysVelocityMultiplier, upwardsAcceleration, attached) + }, + Item: func(opts world.EntitySpawnOpts, it any) *world.EntityHandle { + return NewItem(opts, it.(item.Stack)) + }, + LingeringPotion: func(opts world.EntitySpawnOpts, t any, owner world.Entity) *world.EntityHandle { + return NewLingeringPotion(opts, t.(potion.Potion), owner) + }, + SplashPotion: func(opts world.EntitySpawnOpts, t any, owner world.Entity) *world.EntityHandle { + return NewSplashPotion(opts, t.(potion.Potion), owner) + }, + Arrow: func(opts world.EntitySpawnOpts, arrow world.ArrowSpawnConfig) *world.EntityHandle { + tip := arrow.Tip.(potion.Potion) + conf := arrowConf + conf.Damage, conf.Potion, conf.Owner = arrow.Damage, tip, arrow.Owner.H() + conf.KnockBackForceAddend = float64(arrow.PunchLevel) * enchantment.Punch.KnockBackMultiplier() + conf.DisablePickup = arrow.DisablePickup + if arrow.ObtainArrowOnPickup { + conf.PickupItem = item.NewStack(item.Arrow{Tip: tip}, 1) + } + conf.Critical = arrow.Critical + conf.PiercingLevel = arrow.PiercingLevel + return opts.New(ArrowType, conf) + }, +} diff --git a/server/entity/snowball.go b/server/entity/snowball.go new file mode 100644 index 0000000..5733377 --- /dev/null +++ b/server/entity/snowball.go @@ -0,0 +1,40 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/particle" +) + +// NewSnowball creates a snowball entity at a position with an owner entity. +func NewSnowball(opts world.EntitySpawnOpts, owner world.Entity) *world.EntityHandle { + conf := snowballConf + conf.Owner = owner.H() + return opts.New(SnowballType, conf) +} + +var snowballConf = ProjectileBehaviourConfig{ + Gravity: 0.03, + Drag: 0.01, + Particle: particle.SnowballPoof{}, + ParticleCount: 6, +} + +// SnowballType is a world.EntityType implementation for snowballs. +var SnowballType snowballType + +type snowballType struct{} + +func (t snowballType) Open(tx *world.Tx, handle *world.EntityHandle, data *world.EntityData) world.Entity { + return &Ent{tx: tx, handle: handle, data: data} +} + +func (snowballType) EncodeEntity() string { return "minecraft:snowball" } +func (snowballType) BBox(world.Entity) cube.BBox { + return cube.Box(-0.125, 0, -0.125, 0.125, 0.25, 0.125) +} + +func (snowballType) DecodeNBT(_ map[string]any, data *world.EntityData) { + data.Data = snowballConf.New() +} +func (snowballType) EncodeNBT(*world.EntityData) map[string]any { return nil } diff --git a/server/entity/splash_potion.go b/server/entity/splash_potion.go new file mode 100644 index 0000000..aad54c4 --- /dev/null +++ b/server/entity/splash_potion.go @@ -0,0 +1,60 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/item/potion" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/particle" + "github.com/df-mc/dragonfly/server/world/sound" +) + +// NewSplashPotion creates a splash potion. SplashPotion is an item that grants +// effects when thrown. +func NewSplashPotion(opts world.EntitySpawnOpts, t potion.Potion, owner world.Entity) *world.EntityHandle { + colour, _ := effect.ResultingColour(t.Effects()) + + conf := splashPotionConf + conf.Potion = t + conf.Particle = particle.Splash{Colour: colour} + conf.Hit = potionSplash(1, t, false) + conf.Owner = owner.H() + + return opts.New(SplashPotionType, conf) +} + +var splashPotionConf = ProjectileBehaviourConfig{ + Gravity: 0.05, + Drag: 0.01, + Damage: -1, + Sound: sound.GlassBreak{}, +} + +// SplashPotionType is a world.EntityType implementation for SplashPotion. +var SplashPotionType splashPotionType + +type splashPotionType struct{} + +func (t splashPotionType) Open(tx *world.Tx, handle *world.EntityHandle, data *world.EntityData) world.Entity { + return &Ent{tx: tx, handle: handle, data: data} +} + +func (splashPotionType) EncodeEntity() string { return "minecraft:splash_potion" } +func (splashPotionType) BBox(world.Entity) cube.BBox { + return cube.Box(-0.125, 0, -0.125, 0.125, 0.25, 0.125) +} + +func (splashPotionType) DecodeNBT(m map[string]any, data *world.EntityData) { + conf := splashPotionConf + conf.Potion = potion.From(nbtconv.Int32(m, "PotionId")) + colour, _ := effect.ResultingColour(conf.Potion.Effects()) + conf.Particle = particle.Splash{Colour: colour} + conf.Hit = potionSplash(1, conf.Potion, false) + + data.Data = conf.New() +} + +func (splashPotionType) EncodeNBT(data *world.EntityData) map[string]any { + return map[string]any{"PotionId": int32(data.Data.(*ProjectileBehaviour).conf.Potion.Uint8())} +} diff --git a/server/entity/splashable.go b/server/entity/splashable.go new file mode 100644 index 0000000..c9d1810 --- /dev/null +++ b/server/entity/splashable.go @@ -0,0 +1,102 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/block/cube/trace" + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/item/potion" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" + "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(tx *world.Tx, 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(tx *world.Tx, 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, tx *world.Tx, res trace.Result) { + return func(e *Ent, tx *world.Tx, res trace.Result) { + pos := e.Position() + effects := pot.Effects() + box := e.H().Type().BBox(e).Translate(pos) + + if len(effects) > 0 { + for otherE := range filterLiving(tx.EntitiesWithin(box.GrowVec3(mgl64.Vec3{8.25, 4.25, 8.25}))) { + otherPos := otherE.Position() + if !otherE.H().Type().BBox(otherE).Translate(otherPos).IntersectsWith(box.GrowVec3(mgl64.Vec3{4.125, 2.125, 4.125})) { + continue + } + + dist := otherPos.Sub(pos).Len() + if dist > 4 { + continue + } + + f := 1 - dist/4 + if entityResult, ok := res.(trace.EntityResult); ok && entityResult.Entity().H() == otherE.H() { + f = 1 + } + + splashed := otherE.(Living) + for _, eff := range effects { + if _, ok := eff.Type().(effect.LastingType); !ok { + splashed.AddEffect(effect.NewInstantWithPotency(eff.Type(), eff.Level(), f)) + continue + } + + dur := time.Duration(float64(eff.Duration()) * durMul * f) + if dur < time.Second { + continue + } + splashed.AddEffect(effect.New(eff.Type().(effect.LastingType), eff.Level(), dur)) + } + } + } else if pot == potion.Water() { + switch result := res.(type) { + case trace.BlockResult: + blockPos := result.BlockPosition().Side(result.Face()) + if tx.Block(blockPos) == fire() { + tx.SetBlock(blockPos, nil, nil) + } + + for _, f := range cube.HorizontalFaces() { + if h := blockPos.Side(f); tx.Block(h) == fire() { + tx.SetBlock(h, nil, nil) + } + + if b, ok := tx.Block(blockPos.Side(f)).(SplashableBlock); ok { + b.Splash(tx, blockPos.Side(f)) + } + } + + resultPos := result.BlockPosition() + if b, ok := tx.Block(resultPos).(SplashableBlock); ok { + b.Splash(tx, resultPos) + } + case trace.EntityResult: + // TODO: Damage endermen, blazes, striders and snow golems when implemented and rehydrate axolotls. + } + + for otherE := range filterLiving(tx.EntitiesWithin(box.GrowVec3(mgl64.Vec3{8.25, 4.25, 8.25}))) { + if splashE, ok := otherE.(SplashableEntity); ok { + splashE.Splash(tx, otherE.Position()) + } + } + } + if linger { + tx.AddEntity(NewAreaEffectCloud(world.EntitySpawnOpts{Position: pos}, pot)) + } + } +} diff --git a/server/entity/stationary.go b/server/entity/stationary.go new file mode 100644 index 0000000..3aaecd1 --- /dev/null +++ b/server/entity/stationary.go @@ -0,0 +1,72 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/world" + "math" + "time" +) + +// StationaryBehaviourConfig holds settings that influence the way +// StationaryBehaviour operates. StationaryBehaviourConfig.New() may be called +// to create a new behaviour with this config. +type StationaryBehaviourConfig struct { + // ExistenceDuration is the duration that an entity with this behaviour + // should last. Once this time expires, the entity is closed. If + // ExistenceDuration is 0, the entity will never expire automatically. + ExistenceDuration time.Duration + // SpawnSounds is a slice of sounds to be played upon the spawning of the + // entity. + SpawnSounds []world.Sound + // Tick is a function called every world tick. It may be used to implement + // additional behaviour for stationary entities. + Tick func(e *Ent, tx *world.Tx) +} + +func (conf StationaryBehaviourConfig) Apply(data *world.EntityData) { + data.Data = conf.New() +} + +// New creates a StationaryBehaviour using the settings provided in conf. +func (conf StationaryBehaviourConfig) New() *StationaryBehaviour { + if conf.ExistenceDuration == 0 { + conf.ExistenceDuration = math.MaxInt64 + } + return &StationaryBehaviour{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 { + conf StationaryBehaviourConfig + close bool +} + +// Tick checks if the entity should be closed and runs whatever additional +// behaviour the entity might require. +func (s *StationaryBehaviour) Tick(e *Ent, tx *world.Tx) *Movement { + if s.close { + _ = e.Close() + return nil + } + + if e.Age() == 0 { + for _, ss := range s.conf.SpawnSounds { + tx.PlaySound(e.Position(), ss) + } + } + if s.conf.Tick != nil { + s.conf.Tick(e, tx) + } + + if e.Age() > s.conf.ExistenceDuration { + s.close = true + } + // Stationary entities never move. Always return nil here. + return nil +} + +// Immobile always returns true. +func (s *StationaryBehaviour) Immobile() bool { + return true +} diff --git a/server/entity/text.go b/server/entity/text.go new file mode 100644 index 0000000..bd919e7 --- /dev/null +++ b/server/entity/text.go @@ -0,0 +1,29 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// NewText creates and returns a new Text entity with the text and position provided. +func NewText(text string, pos mgl64.Vec3) *world.EntityHandle { + return world.EntitySpawnOpts{Position: pos, NameTag: text}.New(TextType, textConf) +} + +var textConf StationaryBehaviourConfig + +// TextType is a world.EntityType implementation for Text. +var TextType textType + +type textType struct{} + +func (t textType) Open(tx *world.Tx, handle *world.EntityHandle, data *world.EntityData) world.Entity { + return &Ent{tx: tx, handle: handle, data: data} +} +func (textType) EncodeEntity() string { return "dragonfly:text" } +func (textType) BBox(world.Entity) cube.BBox { return cube.BBox{} } +func (textType) NetworkEncodeEntity() string { return "minecraft:falling_block" } + +func (textType) DecodeNBT(_ map[string]any, data *world.EntityData) { data.Data = textConf.New() } +func (textType) EncodeNBT(_ *world.EntityData) map[string]any { return nil } diff --git a/server/entity/tnt.go b/server/entity/tnt.go new file mode 100644 index 0000000..b151dd1 --- /dev/null +++ b/server/entity/tnt.go @@ -0,0 +1,59 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" + "math" + "math/rand/v2" + "time" +) + +// NewTNT creates a new primed TNT entity. +func NewTNT(opts world.EntitySpawnOpts, fuse time.Duration) *world.EntityHandle { + conf := tntConf + conf.ExistenceDuration = fuse + if opts.Velocity.Len() == 0 { + angle := rand.Float64() * math.Pi * 2 + opts.Velocity = mgl64.Vec3{-math.Sin(angle) * 0.02, 0.1, -math.Cos(angle) * 0.02} + } + return opts.New(TNTType, conf) +} + +var tntConf = PassiveBehaviourConfig{ + Gravity: 0.04, + Drag: 0.02, + Expire: explodeTNT, +} + +// explodeTNT creates an explosion at the position of e. +func explodeTNT(e *Ent, tx *world.Tx) { + block.ExplosionConfig{ItemDropChance: 1}.Explode(tx, e.Position()) +} + +// TNTType is a world.EntityType implementation for TNT. +var TNTType tntType + +type tntType struct{} + +func (t tntType) Open(tx *world.Tx, handle *world.EntityHandle, data *world.EntityData) world.Entity { + return &Ent{tx: tx, handle: handle, data: data} +} + +func (tntType) EncodeEntity() string { return "minecraft:tnt" } +func (tntType) NetworkOffset() float64 { return 0.49 } +func (tntType) BBox(world.Entity) cube.BBox { + return cube.Box(-0.49, 0, -0.49, 0.49, 0.98, 0.49) +} + +func (t tntType) DecodeNBT(m map[string]any, data *world.EntityData) { + conf := tntConf + conf.ExistenceDuration = nbtconv.TickDuration[uint8](m, "Fuse") + data.Data = conf.New() +} + +func (tntType) EncodeNBT(data *world.EntityData) map[string]any { + return map[string]any{"Fuse": uint8(data.Data.(*PassiveBehaviour).Fuse().Milliseconds() / 50)} +} diff --git a/server/event/context.go b/server/event/context.go new file mode 100644 index 0000000..6d63586 --- /dev/null +++ b/server/event/context.go @@ -0,0 +1,28 @@ +package event + +// Context represents the context of an event. Handlers of an event may call methods on the context to change +// the result of the event. +type Context[T any] struct { + cancel bool + val T +} + +// C returns a new event context. +func C[T any](v T) *Context[T] { + return &Context[T]{val: v} +} + +// Val returns the subject of the Context. +func (ctx *Context[T]) Val() T { + return ctx.val +} + +// Cancelled returns whether the context has been cancelled. +func (ctx *Context[T]) Cancelled() bool { + return ctx.cancel +} + +// Cancel cancels the context. +func (ctx *Context[T]) Cancel() { + ctx.cancel = true +} diff --git a/server/event/doc.go b/server/event/doc.go new file mode 100644 index 0000000..f64957e --- /dev/null +++ b/server/event/doc.go @@ -0,0 +1,6 @@ +// Package event exposes a single exported `Context` type that may be used to influence the execution flow of events +// that occur on a server. +// Generally, the caller of `event.C()` calls `Context.Cancelled()` to check if the `Context` was cancelled (using +// `Context.Cancel()`) by whatever code it was passed to. +// who is then able to cancel it by calling `Context.Cancel()`. +package event diff --git a/server/internal/blockinternal/builder.go b/server/internal/blockinternal/builder.go new file mode 100644 index 0000000..ea4c72d --- /dev/null +++ b/server/internal/blockinternal/builder.go @@ -0,0 +1,103 @@ +package blockinternal + +import ( + "github.com/df-mc/dragonfly/server/item/category" + "maps" + "slices" +) + +// ComponentBuilder represents a builder that can be used to construct a block components map to be sent to a client. +type ComponentBuilder struct { + permutations map[string]map[string]any + properties []map[string]any + components map[string]any + blockID int32 + + identifier string + menuCategory category.Category +} + +// NewComponentBuilder returns a new component builder with the provided block data, using the provided components map +// as a base. +func NewComponentBuilder(identifier string, components map[string]any, blockID int32) *ComponentBuilder { + if components == nil { + components = map[string]any{} + } + return &ComponentBuilder{ + permutations: make(map[string]map[string]any), + components: components, + blockID: blockID, + + identifier: identifier, + menuCategory: category.Construction(), + } +} + +// AddProperty adds the provided block property to the builder. +func (builder *ComponentBuilder) AddProperty(name string, values []any) { + builder.properties = append(builder.properties, map[string]any{ + "name": name, + "enum": values, + }) +} + +// AddComponent adds the provided component to the builder. If the component already exists, it will be overwritten. +func (builder *ComponentBuilder) AddComponent(name string, value any) { + builder.components[name] = value +} + +// AddPermutation adds a permutation to the builder. If there is already an existing permutation for the provided +// condition, the new components will be added to the existing permutation. +func (builder *ComponentBuilder) AddPermutation(condition string, components map[string]any) { + if len(builder.permutations) == 0 { + // This trigger really does not matter at all, the component just needs to be set for custom block placements to + // function as expected client-side, when permutations are applied. + builder.AddComponent("minecraft:on_player_placing", map[string]any{ + "triggerType": "placement_trigger", + }) + } + if builder.permutations[condition] == nil { + builder.permutations[condition] = map[string]any{} + } + for key, value := range components { + builder.permutations[condition][key] = value + } +} + +// SetMenuCategory sets the creative category for the current block. +func (builder *ComponentBuilder) SetMenuCategory(category category.Category) { + builder.menuCategory = category +} + +// Construct constructs the final block components map that is ready to be sent to the client. +func (builder *ComponentBuilder) Construct() map[string]any { + properties := slices.Clone(builder.properties) + components := maps.Clone(builder.components) + + result := map[string]any{ + "components": components, + "molangVersion": int32(10), + "menu_category": map[string]any{ + "category": builder.menuCategory.String(), + "group": builder.menuCategory.Group(), + }, + "vanilla_block_data": map[string]any{ + "block_id": builder.blockID, + }, + } + if len(properties) > 0 { + result["properties"] = properties + } + + permutations := maps.Clone(builder.permutations) + if len(permutations) > 0 { + result["permutations"] = []map[string]any{} + for condition, values := range permutations { + result["permutations"] = append(result["permutations"].([]map[string]any), map[string]any{ + "condition": condition, + "components": values, + }) + } + } + return result +} diff --git a/server/internal/blockinternal/components.go b/server/internal/blockinternal/components.go new file mode 100644 index 0000000..86a7557 --- /dev/null +++ b/server/internal/blockinternal/components.go @@ -0,0 +1,136 @@ +package blockinternal + +import ( + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/block/customblock" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// Components returns all the components for the custom block, including permutations and properties. +func Components(identifier string, b world.CustomBlock, blockID int32) map[string]any { + components := componentsFromProperties(b.Properties()) + builder := NewComponentBuilder(identifier, components, blockID) + if emitter, ok := b.(block.LightEmitter); ok { + builder.AddComponent("minecraft:block_light_emission", map[string]any{ + "emission": float32(emitter.LightEmissionLevel() / 15), + }) + } + if diffuser, ok := b.(block.LightDiffuser); ok { + builder.AddComponent("minecraft:block_light_filter", map[string]any{ + "lightLevel": int32(diffuser.LightDiffusionLevel()), + }) + } + if breakable, ok := b.(block.Breakable); ok { + info := breakable.BreakInfo() + builder.AddComponent("minecraft:destructible_by_mining", map[string]any{"value": float32(info.Hardness)}) + } + if frictional, ok := b.(block.Frictional); ok { + builder.AddComponent("minecraft:friction", map[string]any{"value": float32(frictional.Friction())}) + } + if flammable, ok := b.(block.Flammable); ok { + info := flammable.FlammabilityInfo() + builder.AddComponent("minecraft:flammable", map[string]any{ + "flame_odds": int32(info.Encouragement), + "burn_odds": int32(info.Flammability), + }) + } + if permutable, ok := b.(block.Permutable); ok { + for name, values := range permutable.States() { + builder.AddProperty(name, values) + } + for _, permutation := range permutable.Permutations() { + builder.AddPermutation(permutation.Condition, componentsFromProperties(permutation.Properties)) + } + } + if item, ok := b.(world.CustomItem); ok { + builder.SetMenuCategory(item.Category()) + } + return builder.Construct() +} + +// componentsFromProperties builds a base components map that includes all the common data between a regular block and +// a custom permutation. +func componentsFromProperties(props customblock.Properties) map[string]any { + components := make(map[string]any) + if props.CollisionBox != (cube.BBox{}) { + components["minecraft:collision_box"] = collisionBoxComponent(props.CollisionBox) + } + if props.SelectionBox != (cube.BBox{}) { + components["minecraft:selection_box"] = selectionBoxComponent(props.SelectionBox) + } + if props.Geometry != "" { + components["minecraft:geometry"] = map[string]any{"identifier": props.Geometry} + } else if props.Cube { + components["minecraft:unit_cube"] = map[string]any{} + } + if props.MapColour != "" { + components["minecraft:map_color"] = map[string]any{"value": props.MapColour} + } + if props.Textures != nil { + materials := map[string]any{} + for target, material := range props.Textures { + materials[target] = material.Encode() + } + components["minecraft:material_instances"] = map[string]any{ + "mappings": map[string]any{}, + "materials": materials, + } + } + transformation := make(map[string]any) + if props.Rotation != (cube.Pos{}) { + transformation["RX"] = int32(props.Rotation.X()) + transformation["RY"] = int32(props.Rotation.Y()) + transformation["RZ"] = int32(props.Rotation.Z()) + } + if props.Translation != (mgl64.Vec3{}) { + transformation["TX"] = float32(props.Translation.X()) + transformation["TY"] = float32(props.Translation.Y()) + transformation["TZ"] = float32(props.Translation.Z()) + } + if props.Scale != (mgl64.Vec3{}) { + transformation["SX"] = float32(props.Scale.X()) + transformation["SY"] = float32(props.Scale.Y()) + transformation["SZ"] = float32(props.Scale.Z()) + } else if len(transformation) > 0 { + transformation["SX"] = float32(1.0) + transformation["SY"] = float32(1.0) + transformation["SZ"] = float32(1.0) + } + if len(transformation) > 0 { + components["minecraft:transformation"] = transformation + } + return components +} + +// collisionBoxComponent returns the component data for a collision box, using absolute min/max coordinates in pixels. +func collisionBoxComponent(box cube.BBox) map[string]any { + min, max := box.Min(), box.Max() + return map[string]any{ + "enabled": true, + "boxes": []map[string]any{ + { + "minX": float32(min.X() * 16), + "minY": float32(min.Y() * 16), + "minZ": float32(min.Z() * 16), + "maxX": float32(max.X() * 16), + "maxY": float32(max.Y() * 16), + "maxZ": float32(max.Z() * 16), + }, + }, + } +} + +// selectionBoxComponent returns the component data for a selection box, translating coordinates to the origin/size +// format the client expects. +func selectionBoxComponent(box cube.BBox) map[string]any { + min, max := box.Min(), box.Max() + originX, originY, originZ := min.X()*16, min.Y()*16, min.Z()*16 + sizeX, sizeY, sizeZ := (max.X()-min.X())*16, (max.Y()-min.Y())*16, (max.Z()-min.Z())*16 + return map[string]any{ + "enabled": true, + "origin": []float32{float32(originX) - 8, float32(originY), float32(originZ) - 8}, + "size": []float32{float32(sizeX), float32(sizeY), float32(sizeZ)}, + } +} diff --git a/server/internal/iteminternal/builder.go b/server/internal/iteminternal/builder.go new file mode 100644 index 0000000..afffef0 --- /dev/null +++ b/server/internal/iteminternal/builder.go @@ -0,0 +1,72 @@ +package iteminternal + +import ( + "github.com/df-mc/dragonfly/server/item/category" + "maps" +) + +// ComponentBuilder represents a builder that can be used to construct an item components map to be sent to a client. +type ComponentBuilder struct { + name string + identifier string + category category.Category + + properties map[string]any + components map[string]any +} + +// NewComponentBuilder returns a new component builder with the provided item data. +func NewComponentBuilder(name, identifier string, category category.Category) *ComponentBuilder { + return &ComponentBuilder{ + name: name, + identifier: identifier, + category: category, + + properties: make(map[string]any), + components: make(map[string]any), + } +} + +// AddProperty adds the provided property to the builder. +func (builder *ComponentBuilder) AddProperty(name string, value any) { + builder.properties[name] = value +} + +// AddComponent adds the provided component to the builder. +func (builder *ComponentBuilder) AddComponent(name string, value any) { + builder.components[name] = value +} + +// Construct constructs the final item components map and returns it. It also applies the default properties required +// for the item to work without modifying the original maps in the builder. +func (builder *ComponentBuilder) Construct() map[string]any { + properties := maps.Clone(builder.properties) + components := maps.Clone(builder.components) + builder.applyDefaultProperties(properties) + builder.applyDefaultComponents(components, properties) + return map[string]any{"components": components} +} + +// applyDefaultProperties applies the default properties to the provided map. It is important that this method does +// not modify the builder's properties map directly otherwise Empty() will return false in future use of the builder. +func (builder *ComponentBuilder) applyDefaultProperties(x map[string]any) { + x["minecraft:icon"] = map[string]any{ + "textures": map[string]any{ + "default": builder.identifier, + }, + } + x["creative_group"] = builder.category.Group() + x["creative_category"] = int32(builder.category.Uint8()) + if _, ok := x["max_stack_size"]; !ok { + x["max_stack_size"] = int32(64) + } +} + +// applyDefaultComponents applies the default components to the provided map. It is important that this method does not +// modify the builder's components map directly otherwise Empty() will return false in future use of the builder. +func (builder *ComponentBuilder) applyDefaultComponents(x, properties map[string]any) { + x["item_properties"] = properties + x["minecraft:display_name"] = map[string]any{ + "value": builder.name, + } +} diff --git a/server/internal/iteminternal/components.go b/server/internal/iteminternal/components.go new file mode 100644 index 0000000..ed6f64d --- /dev/null +++ b/server/internal/iteminternal/components.go @@ -0,0 +1,82 @@ +package iteminternal + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "strings" +) + +// Components returns all the components of the given custom item. If the item has no components, a nil map and false +// are returned. +func Components(it world.CustomItem) map[string]any { + category := it.Category() + identifier, _ := it.EncodeItem() + name := strings.Split(identifier, ":")[1] + + builder := NewComponentBuilder(it.Name(), identifier, category) + + if x, ok := it.(item.Armour); ok { + builder.AddComponent("minecraft:armor", map[string]any{ + "protection": int32(x.DefencePoints()), + }) + + var slot string + switch it.(type) { + case item.HelmetType: + slot = "slot.armor.head" + case item.ChestplateType: + slot = "slot.armor.chest" + case item.LeggingsType: + slot = "slot.armor.legs" + case item.BootsType: + slot = "slot.armor.feet" + } + builder.AddComponent("minecraft:wearable", map[string]any{ + "slot": slot, + }) + } + if x, ok := it.(item.Consumable); ok { + builder.AddProperty("use_duration", int32(x.ConsumeDuration().Seconds()*20)) + builder.AddComponent("minecraft:food", map[string]any{ + "can_always_eat": x.AlwaysConsumable(), + }) + + if y, ok := it.(item.Drinkable); ok && y.Drinkable() { + builder.AddProperty("use_animation", int32(2)) + } else { + builder.AddProperty("use_animation", int32(1)) + } + } + if x, ok := it.(item.Cooldown); ok { + builder.AddComponent("minecraft:cooldown", map[string]any{ + "category": name, + "duration": float32(x.Cooldown().Seconds()), + }) + } + if x, ok := it.(item.Durable); ok { + builder.AddComponent("minecraft:durability", map[string]any{ + "max_durability": int32(x.DurabilityInfo().MaxDurability), + }) + } + if x, ok := it.(item.MaxCounter); ok { + builder.AddProperty("max_stack_size", int32(x.MaxCount())) + } + if x, ok := it.(item.OffHand); ok { + builder.AddProperty("allow_off_hand", x.OffHand()) + } + if x, ok := it.(item.Throwable); ok { + // The data in minecraft:projectile is only used by vanilla server-side, but we must send at least an empty map + // so the client will play the throwing animation. + builder.AddComponent("minecraft:projectile", map[string]any{}) + builder.AddComponent("minecraft:throwable", map[string]any{ + "do_swing_animation": x.SwingAnimation(), + }) + } + if x, ok := it.(item.Glinted); ok { + builder.AddProperty("foil", x.Glinted()) + } + if x, ok := it.(item.HandEquipped); ok { + builder.AddProperty("hand_equipped", x.HandEquipped()) + } + return builder.Construct() +} diff --git a/server/internal/nbtconv/colour.go b/server/internal/nbtconv/colour.go new file mode 100644 index 0000000..6a0cb1b --- /dev/null +++ b/server/internal/nbtconv/colour.go @@ -0,0 +1,24 @@ +package nbtconv + +import ( + "encoding/binary" + "image/color" +) + +// Int32FromRGBA converts a color.RGBA into an int32. These int32s are present in, for example, signs. +func Int32FromRGBA(x color.RGBA) int32 { + if x.R == 0 && x.G == 0 && x.B == 0 { + // Default to black colour. The default (0x000000) is a transparent colour. Text with this colour will not show + // up on the sign. + return int32(-0x1000000) + } + return int32(binary.BigEndian.Uint32([]byte{x.A, x.R, x.G, x.B})) +} + +// RGBAFromInt32 converts an int32 into a color.RGBA. These int32s are present in, for example, signs. +func RGBAFromInt32(x int32) color.RGBA { + b := make([]byte, 4) + binary.BigEndian.PutUint32(b, uint32(x)) + + return color.RGBA{A: b[0], R: b[1], G: b[2], B: b[3]} +} diff --git a/server/internal/nbtconv/item.go b/server/internal/nbtconv/item.go new file mode 100644 index 0000000..1443ddc --- /dev/null +++ b/server/internal/nbtconv/item.go @@ -0,0 +1,31 @@ +package nbtconv + +import ( + "github.com/df-mc/dragonfly/server/item/inventory" +) + +// InvFromNBT decodes the data of an NBT slice into the inventory passed. +func InvFromNBT(inv *inventory.Inventory, items []any) { + for _, itemData := range items { + data, _ := itemData.(map[string]any) + it := Item(data, nil) + if it.Empty() { + continue + } + _ = inv.SetItem(int(Uint8(data, "Slot")), it) + } +} + +// InvToNBT encodes an inventory to a data slice which may be encoded as NBT. +func InvToNBT(inv *inventory.Inventory) []map[string]any { + var items []map[string]any + for index, i := range inv.Slots() { + if i.Empty() { + continue + } + data := WriteItem(i, true) + data["Slot"] = byte(index) + items = append(items, data) + } + return items +} diff --git a/server/internal/nbtconv/read.go b/server/internal/nbtconv/read.go new file mode 100644 index 0000000..142d0ec --- /dev/null +++ b/server/internal/nbtconv/read.go @@ -0,0 +1,283 @@ +package nbtconv + +import ( + "bytes" + "encoding/gob" + "time" + + "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" + "golang.org/x/exp/constraints" +) + +// Bool reads a uint8 value from a map at key k and returns true if it equals 1. +func Bool(m map[string]any, k string) bool { + return Uint8(m, k) == 1 +} + +// Uint8 reads a uint8 value from a map at key k. +func Uint8(m map[string]any, k string) uint8 { + v, _ := m[k].(uint8) + return v +} + +// String reads a string value from a map at key k. +func String(m map[string]any, k string) string { + v, _ := m[k].(string) + return v +} + +// Int16 reads an int16 value from a map at key k. +func Int16(m map[string]any, k string) int16 { + v, _ := m[k].(int16) + return v +} + +// Int32 reads an int32 value from a map at key k. +func Int32(m map[string]any, k string) int32 { + v, _ := m[k].(int32) + return v +} + +// Int64 reads an int16 value from a map at key k. +func Int64(m map[string]any, k string) int64 { + v, _ := m[k].(int64) + return v +} + +// TickDuration reads a uint8/int16/in32 value from a map at key k and converts +// it from ticks to a time.Duration. +func TickDuration[T constraints.Integer](m map[string]any, k string) time.Duration { + var v time.Duration + switch any(*new(T)).(type) { + case uint8: + v = time.Duration(Uint8(m, k)) + case int16: + v = time.Duration(Int16(m, k)) + case int32: + v = time.Duration(Int32(m, k)) + default: + panic("invalid tick duration value type") + } + return v * time.Millisecond * 50 +} + +// Float32 reads a float32 value from a map at key k. +func Float32(m map[string]any, k string) float32 { + v, _ := m[k].(float32) + return v +} + +// Rotation reads a cube.Rotation from the map passed. +func Rotation(m map[string]any) cube.Rotation { + return cube.Rotation{float64(Float32(m, "Yaw")), float64(Float32(m, "Pitch"))} +} + +// Float64 reads a float64 value from a map at key k. +func Float64(m map[string]any, k string) float64 { + v, _ := m[k].(float64) + return v +} + +// Slice reads a []any value from a map at key k. +func Slice(m map[string]any, k string) []any { + v, _ := m[k].([]any) + return v +} + +// Vec3 converts x, y and z values in an NBT map to an mgl64.Vec3. +func Vec3(x map[string]any, k string) mgl64.Vec3 { + if i, ok := x[k].([]any); ok { + if len(i) != 3 { + return mgl64.Vec3{} + } + var v mgl64.Vec3 + for index, f := range i { + f32, _ := f.(float32) + v[index] = float64(f32) + } + return v + } else if i, ok := x[k].([]float32); ok { + if len(i) != 3 { + return mgl64.Vec3{} + } + return mgl64.Vec3{float64(i[0]), float64(i[1]), float64(i[2])} + } + return mgl64.Vec3{} +} + +// Vec3ToFloat32Slice converts an mgl64.Vec3 to a []float32 with 3 elements. +func Vec3ToFloat32Slice(x mgl64.Vec3) []float32 { + return []float32{float32(x[0]), float32(x[1]), float32(x[2])} +} + +// Pos converts x, y and z values in an NBT map to a cube.Pos. +func Pos(x map[string]any, k string) cube.Pos { + if i, ok := x[k].([]any); ok { + if len(i) != 3 { + return cube.Pos{} + } + var v cube.Pos + for index, f := range i { + f32, _ := f.(int32) + v[index] = int(f32) + } + return v + } else if i, ok := x[k].([]int32); ok { + if len(i) != 3 { + return cube.Pos{} + } + return cube.Pos{int(i[0]), int(i[1]), int(i[2])} + } + return cube.Pos{} +} + +// PosToInt32Slice converts a cube.Pos to a []int32 with 3 elements. +func PosToInt32Slice(x cube.Pos) []int32 { + return []int32{int32(x[0]), int32(x[1]), int32(x[2])} +} + +// MapItem converts an item's name, count, damage (and properties when it is a block) in a map obtained by decoding NBT +// to a world.Item. +func MapItem(x map[string]any, k string) item.Stack { + if m, ok := x[k].(map[string]any); ok { + return Item(m, nil) + } + return item.Stack{} +} + +// Item decodes the data of an item into an item stack. +func Item(data map[string]any, s *item.Stack) item.Stack { + disk, tag := s == nil, data + if disk { + t, ok := data["tag"].(map[string]any) + if !ok { + t = map[string]any{} + } + tag = t + + a := readItemStack(data, tag) + s = &a + } + + readAnvilCost(tag, s) + readDamage(tag, s, disk) + readDisplay(tag, s) + readDragonflyData(tag, s) + readEnchantments(tag, s) + readUnbreakable(tag, s) + return *s +} + +// Block decodes the data of a block into a world.Block. +func Block(m map[string]any, k string) world.Block { + if mk, ok := m[k].(map[string]any); ok { + name, _ := mk["name"].(string) + properties, _ := mk["states"].(map[string]any) + b, _ := world.BlockByName(name, properties) + return b + } + return nil +} + +// readItemStack reads an item.Stack from the NBT in the map passed. +func readItemStack(m, t map[string]any) item.Stack { + var it world.Item + if blockItem, ok := Block(m, "Block").(world.Item); ok { + it = blockItem + } + if v, ok := world.ItemByName(String(m, "Name"), Int16(m, "Damage")); ok { + it = v + } + if it == nil { + return item.Stack{} + } + if n, ok := it.(world.NBTer); ok { + it = n.DecodeNBT(t).(world.Item) + } + return item.NewStack(it, int(Uint8(m, "Count"))) +} + +// readDamage reads the damage value stored in the NBT with the Damage tag and saves it to the item.Stack passed. +func readDamage(m map[string]any, s *item.Stack, disk bool) { + if disk { + *s = s.Damage(int(Int16(m, "Damage"))) + return + } + *s = s.Damage(int(Int32(m, "Damage"))) +} + +// readAnvilCost ... +func readAnvilCost(m map[string]any, s *item.Stack) { + *s = s.WithAnvilCost(int(Int32(m, "RepairCost"))) +} + +// readEnchantments reads the enchantments stored in the ench tag of the NBT passed and stores it into an item.Stack. +func readEnchantments(m map[string]any, s *item.Stack) { + enchantments, ok := m["ench"].([]map[string]any) + if !ok { + for _, e := range Slice(m, "ench") { + if v, ok := e.(map[string]any); ok { + enchantments = append(enchantments, v) + } + } + } + for _, ench := range enchantments { + if t, ok := item.EnchantmentByID(int(Int16(ench, "id"))); ok { + *s = s.WithForcedEnchantments(item.NewEnchantment(t, int(Int16(ench, "lvl")))) + } + } +} + +// readDisplay reads the display data present in the display field in the NBT. It includes a custom name of the item +// and the lore. +func readDisplay(m map[string]any, s *item.Stack) { + if display, ok := m["display"].(map[string]any); ok { + if name, ok := display["Name"].(string); ok { + // Only add the custom name if actually set. + *s = s.WithCustomName(name) + } + if lore, ok := display["Lore"].([]string); ok { + *s = s.WithLore(lore...) + } else if lore, ok := display["Lore"].([]any); ok { + loreLines := make([]string, 0, len(lore)) + for _, l := range lore { + loreLines = append(loreLines, l.(string)) + } + *s = s.WithLore(loreLines...) + } + } +} + +// readDragonflyData reads data written to the dragonflyData field in the NBT of an item and adds it to the item.Stack +// passed. +func readDragonflyData(m map[string]any, s *item.Stack) { + if customData, ok := m["dragonflyData"]; ok { + d, ok := customData.([]byte) + if !ok { + if itf, ok := customData.([]any); ok { + for _, v := range itf { + b, _ := v.(byte) + d = append(d, b) + } + } + } + var values []mapValue + if err := gob.NewDecoder(bytes.NewBuffer(d)).Decode(&values); err != nil { + panic("error decoding item user data: " + err.Error()) + } + for _, val := range values { + *s = s.WithValue(val.K, val.V) + } + } +} + +// readUnbreakable reads the unbreakable value stored in the NBT with the Unbreakable tag and saves it to the item.Stack +// passed. +func readUnbreakable(m map[string]any, s *item.Stack) { + if Bool(m, "Unbreakable") { + *s = s.AsUnbreakable() + } +} diff --git a/server/internal/nbtconv/write.go b/server/internal/nbtconv/write.go new file mode 100644 index 0000000..508e70b --- /dev/null +++ b/server/internal/nbtconv/write.go @@ -0,0 +1,159 @@ +package nbtconv + +import ( + "bytes" + "encoding/gob" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/chunk" + "sort" +) + +// WriteItem encodes an item stack into a map that can be encoded using NBT. +func WriteItem(s item.Stack, disk bool) map[string]any { + tag := make(map[string]any) + if s.Empty() { + return tag + } + if nbt, ok := s.Item().(world.NBTer); ok { + for k, v := range nbt.EncodeNBT() { + tag[k] = v + } + } + writeAnvilCost(tag, s) + writeDamage(tag, s, disk) + writeDisplay(tag, s) + writeDragonflyData(tag, s) + writeEnchantments(tag, s) + writeUnbreakable(tag, s) + + data := make(map[string]any) + if disk { + writeItemStack(data, tag, s) + } else { + for k, v := range tag { + data[k] = v + } + } + return data +} + +// WriteBlock encodes a world.Block into a map that can be encoded using NBT. +func WriteBlock(b world.Block) map[string]any { + name, properties := b.EncodeBlock() + return map[string]any{ + "name": name, + "states": properties, + "version": chunk.CurrentBlockVersion, + } +} + +// writeItemStack writes the name, metadata value, count and NBT of an item to a map ready for NBT encoding. +func writeItemStack(m, t map[string]any, s item.Stack) { + m["Name"], m["Damage"] = s.Item().EncodeItem() + if b, ok := s.Item().(world.Block); ok { + v := map[string]any{} + writeBlock(v, b) + m["Block"] = v + } + m["Count"] = byte(s.Count()) + if len(t) > 0 { + m["tag"] = t + } +} + +// writeBlock writes the name, properties and version of a block to a map ready for NBT encoding. +func writeBlock(m map[string]any, b world.Block) { + m["name"], m["states"] = b.EncodeBlock() + m["version"] = chunk.CurrentBlockVersion +} + +// writeDragonflyData writes additional data associated with an item.Stack to a map for NBT encoding. +func writeDragonflyData(m map[string]any, s item.Stack) { + if v := s.Values(); len(v) != 0 { + buf := new(bytes.Buffer) + if err := gob.NewEncoder(buf).Encode(mapToSlice(v)); err != nil { + panic("error encoding item user data: " + err.Error()) + } + m["dragonflyData"] = buf.Bytes() + } +} + +// mapToSlice converts a map to a slice of the type mapValue and orders the slice by the keys in the map to ensure a +// deterministic order. +func mapToSlice(m map[string]any) []mapValue { + values := make([]mapValue, 0, len(m)) + for k, v := range m { + values = append(values, mapValue{K: k, V: v}) + } + sort.Slice(values, func(i, j int) bool { + return values[i].K < values[j].K + }) + return values +} + +// mapValue represents a value in a map. It is used to convert maps to a slice and order the slice before encoding to +// NBT to ensure a deterministic output. +type mapValue struct { + K string + V any +} + +// writeEnchantments writes the enchantments of an item to a map for NBT encoding. +func writeEnchantments(m map[string]any, s item.Stack) { + if len(s.Enchantments()) != 0 { + var enchantments []map[string]any + for _, e := range s.Enchantments() { + if eType, ok := item.EnchantmentID(e.Type()); ok { + enchantments = append(enchantments, map[string]any{ + "id": int16(eType), + "lvl": int16(e.Level()), + }) + } + } + m["ench"] = enchantments + } +} + +// writeDisplay writes the display name and lore of an item to a map for NBT encoding. +func writeDisplay(m map[string]any, s item.Stack) { + name, lore := s.CustomName(), s.Lore() + v := map[string]any{} + if name != "" { + v["Name"] = name + } + if len(lore) != 0 { + v["Lore"] = lore + } + if len(v) != 0 { + m["display"] = v + } +} + +// writeDamage writes the damage to an item.Stack (either an int16 for disk or int32 for network) to a map for NBT +// encoding. +func writeDamage(m map[string]any, s item.Stack, disk bool) { + if v, ok := m["Damage"]; !ok || v.(int16) == 0 { + if _, ok := s.Item().(item.Durable); ok { + if disk { + m["Damage"] = int16(s.MaxDurability() - s.Durability()) + } else { + m["Damage"] = int32(s.MaxDurability() - s.Durability()) + } + } + } +} + +// writeAnvilCost ... +func writeAnvilCost(m map[string]any, s item.Stack) { + if cost := s.AnvilCost(); cost > 0 { + m["RepairCost"] = int32(cost) + } +} + +// writeUnbreakable writes the unbreakable tag to an item stack if it is unbreakable. +func writeUnbreakable(m map[string]any, s item.Stack) { + if s.Unbreakable() { + m["Unbreakable"] = byte(1) + } +} diff --git a/server/internal/packbuilder/blocks.go b/server/internal/packbuilder/blocks.go new file mode 100644 index 0000000..020084f --- /dev/null +++ b/server/internal/packbuilder/blocks.go @@ -0,0 +1,81 @@ +package packbuilder + +import ( + "encoding/json" + "fmt" + "image" + "image/png" + "os" + "path/filepath" + "strings" + _ "unsafe" // Imported for compiler directives. + + "github.com/df-mc/dragonfly/server/world" +) + +// buildBlocks builds all the block-related files for the resource pack. This includes textures, geometries, language +// entries and terrain texture atlas. +func buildBlocks(reg world.BlockRegistry, dir string) (count int, lang []string) { + if err := os.MkdirAll(filepath.Join(dir, "models/blocks"), os.ModePerm); err != nil { + panic(err) + } + if err := os.MkdirAll(filepath.Join(dir, "textures/blocks"), os.ModePerm); err != nil { + panic(err) + } + + textureData := make(map[string]any) + for identifier, blk := range reg.CustomBlocks() { + b, ok := blk.(world.CustomBlockBuildable) + if !ok { + continue + } + + name := strings.Split(identifier, ":")[1] + lang = append(lang, fmt.Sprintf("tile.%s.name=%s", identifier, b.Name())) + for name, texture := range b.Textures() { + textureData[name] = map[string]string{"textures": "textures/blocks/" + name} + buildBlockTexture(dir, name, texture) + } + if b.Geometry() != nil { + if err := os.WriteFile(filepath.Join(dir, "models/blocks", fmt.Sprintf("%s.geo.json", name)), b.Geometry(), 0666); err != nil { + panic(err) + } + } + count++ + } + + buildBlockAtlas(dir, map[string]any{ + "resource_pack_name": "vanilla", + "texture_name": "atlas.terrain", + "padding": 8, + "num_mip_levels": 4, + "texture_data": textureData, + }) + return +} + +// buildBlockTexture creates a PNG file for the block from the provided image and name and writes it to the pack. +func buildBlockTexture(dir, name string, img image.Image) { + texture, err := os.Create(filepath.Join(dir, fmt.Sprintf("textures/blocks/%s.png", name))) + if err != nil { + panic(err) + } + if err := png.Encode(texture, img); err != nil { + _ = texture.Close() + panic(err) + } + if err := texture.Close(); err != nil { + panic(err) + } +} + +// buildBlockAtlas creates the identifier to texture mapping and writes it to the pack. +func buildBlockAtlas(dir string, atlas map[string]any) { + b, err := json.Marshal(atlas) + if err != nil { + panic(err) + } + if err := os.WriteFile(filepath.Join(dir, "textures/terrain_texture.json"), b, 0666); err != nil { + panic(err) + } +} diff --git a/server/internal/packbuilder/items.go b/server/internal/packbuilder/items.go new file mode 100644 index 0000000..c277650 --- /dev/null +++ b/server/internal/packbuilder/items.go @@ -0,0 +1,70 @@ +package packbuilder + +import ( + "encoding/json" + "fmt" + "github.com/df-mc/dragonfly/server/world" + "image" + "image/png" + "os" + "path/filepath" + "strings" + _ "unsafe" // Imported for compiler directives. +) + +// buildItems builds all the item-related files for the resource pack. This includes textures, language +// entries and item atlas. +func buildItems(dir string) (count int, lang []string) { + if err := os.Mkdir(filepath.Join(dir, "items"), os.ModePerm); err != nil { + panic(err) + } + if err := os.MkdirAll(filepath.Join(dir, "textures/items"), os.ModePerm); err != nil { + panic(err) + } + + textureData := make(map[string]any) + for _, item := range world.CustomItems() { + identifier, _ := item.EncodeItem() + lang = append(lang, fmt.Sprintf("item.%s.name=%s", identifier, item.Name())) + + name := strings.Split(identifier, ":")[1] + textureData[identifier] = map[string]string{"textures": fmt.Sprintf("textures/items/%s.png", name)} + + buildItemTexture(dir, name, item.Texture()) + + count++ + } + + buildItemAtlas(dir, map[string]any{ + "resource_pack_name": "vanilla", + "texture_name": "atlas.items", + "texture_data": textureData, + }) + return +} + +// buildItemTexture creates a PNG file for the item from the provided image and name and writes it to the pack. +func buildItemTexture(dir, name string, img image.Image) { + texture, err := os.Create(filepath.Join(dir, "textures/items", name+".png")) + if err != nil { + panic(err) + } + if err := png.Encode(texture, img); err != nil { + _ = texture.Close() + panic(err) + } + if err := texture.Close(); err != nil { + panic(err) + } +} + +// buildItemAtlas creates the identifier to texture mapping and writes it to the pack. +func buildItemAtlas(dir string, atlas map[string]any) { + b, err := json.Marshal(atlas) + if err != nil { + panic(err) + } + if err := os.WriteFile(filepath.Join(dir, "textures/item_texture.json"), b, 0666); err != nil { + panic(err) + } +} diff --git a/server/internal/packbuilder/lang.go b/server/internal/packbuilder/lang.go new file mode 100644 index 0000000..d47e586 --- /dev/null +++ b/server/internal/packbuilder/lang.go @@ -0,0 +1,17 @@ +package packbuilder + +import ( + "os" + "path/filepath" + "strings" +) + +// buildLanguageFile creates a lang file and writes all of the language entries to the pack. +func buildLanguageFile(dir string, lang []string) { + if err := os.Mkdir(filepath.Join(dir, "texts"), os.ModePerm); err != nil { + panic(err) + } + if err := os.WriteFile(filepath.Join(dir, "texts/en_US.lang"), []byte(strings.Join(lang, "\n")), 0666); err != nil { + panic(err) + } +} diff --git a/server/internal/packbuilder/manifest.go b/server/internal/packbuilder/manifest.go new file mode 100644 index 0000000..785cc48 --- /dev/null +++ b/server/internal/packbuilder/manifest.go @@ -0,0 +1,53 @@ +package packbuilder + +import ( + "encoding/json" + "github.com/google/uuid" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/resource" + "os" + "path/filepath" + "strconv" + "strings" +) + +// buildManifest creates a JSON manifest file for the client to be able to read the resource pack. It creates +// basic information and writes it to the pack. +func buildManifest(dir string, headerUUID, moduleUUID uuid.UUID) { + m, err := json.Marshal(resource.Manifest{ + FormatVersion: 2, + Header: resource.Header{ + Name: "dragonfly auto-generated resource pack", + Description: "This resource pack contains auto-generated content from dragonfly", + UUID: headerUUID, + Version: [3]int{0, 0, 1}, + MinimumGameVersion: parseVersion(protocol.CurrentVersion), + }, + Modules: []resource.Module{ + { + UUID: moduleUUID.String(), + Description: "This resource pack contains auto-generated content from dragonfly", + Type: "resources", + Version: [3]int{0, 0, 1}, + }, + }, + }) + if err != nil { + panic(err) + } + if err := os.WriteFile(filepath.Join(dir, "manifest.json"), m, 0666); err != nil { + panic(err) + } +} + +// parseVersion parses the version passed in the format of a.b.c as a [3]int. +func parseVersion(ver string) [3]int { + frag := strings.Split(ver, ".") + if len(frag) != 3 { + panic("invalid version number " + ver) + } + a, _ := strconv.ParseInt(frag[0], 10, 64) + b, _ := strconv.ParseInt(frag[1], 10, 64) + c, _ := strconv.ParseInt(frag[2], 10, 64) + return [3]int{int(a), int(b), int(c)} +} diff --git a/server/internal/packbuilder/pack_icon.png b/server/internal/packbuilder/pack_icon.png new file mode 100644 index 0000000..a7723d9 Binary files /dev/null and b/server/internal/packbuilder/pack_icon.png differ diff --git a/server/internal/packbuilder/resource_pack.go b/server/internal/packbuilder/resource_pack.go new file mode 100644 index 0000000..e1bd1d0 --- /dev/null +++ b/server/internal/packbuilder/resource_pack.go @@ -0,0 +1,52 @@ +package packbuilder + +import ( + _ "embed" + "os" + + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/resource" + "golang.org/x/mod/sumdb/dirhash" +) + +//go:embed pack_icon.png +var packIcon []byte + +// BuildResourcePack builds a resource pack based on custom features that have been registered to the server. +// It creates a UUID based on the hash of the directory so the client will only be prompted to download it +// once it is changed. +func BuildResourcePack(reg world.BlockRegistry) (*resource.Pack, bool) { + dir, err := os.MkdirTemp("", "dragonfly_resource_pack-") + if err != nil { + panic(err) + } + defer os.RemoveAll(dir) + + var assets int + var lang []string + + itemCount, itemLang := buildItems(dir) + assets += itemCount + lang = append(lang, itemLang...) + + blockCount, blockLang := buildBlocks(reg, dir) + assets += blockCount + lang = append(lang, blockLang...) + + if assets > 0 { + buildLanguageFile(dir, lang) + if err := os.WriteFile(dir+"/pack_icon.png", packIcon, 0666); err != nil { + panic(err) + } + hash, err := dirhash.HashDir(dir, "", dirhash.Hash1) + if err != nil { + panic(err) + } + var header, module [16]byte + copy(header[:], hash) + copy(module[:], hash[16:]) + buildManifest(dir, header, module) + return resource.MustReadPath(dir), true + } + return nil, false +} diff --git a/server/internal/sliceutil/sliceutil.go b/server/internal/sliceutil/sliceutil.go new file mode 100644 index 0000000..c2726eb --- /dev/null +++ b/server/internal/sliceutil/sliceutil.go @@ -0,0 +1,49 @@ +package sliceutil + +import "slices" + +// Convert converts a slice of type B to a slice of type A. Convert panics if B +// cannot be type asserted to type A. +func Convert[A, B any, S ~[]B](v S) []A { + a := make([]A, len(v)) + for i, b := range v { + a[i] = (any)(b).(A) + } + return a +} + +// SearchValue iterates through slice v, calling function f for every element. +// If true is returned in this function, the respective element is returned and +// ok is true. If the function f does not return true for any element, false is +// returned. +func SearchValue[A any, S ~[]A](v S, f func(a A) bool) (a A, ok bool) { + for _, val := range v { + if f(val) { + return val, true + } + } + return +} + +// Filter iterates over elements of collection, returning an array of all +// elements function c returns true for. +func Filter[E any](s []E, c func(E) bool) []E { + a := make([]E, 0, len(s)) + for _, e := range s { + if c(e) { + a = append(a, e) + } + } + return a +} + +// DeleteVal deletes the first occurrence of a value in a slice of the type E +// and returns a new slice without the value. +func DeleteVal[E any](s []E, v E) []E { + for i, vs := range s { + if (any)(v) == (any)(vs) { + return slices.Delete(s, i, i+1) + } + } + return s +} diff --git a/server/item/amethyst_shard.go b/server/item/amethyst_shard.go new file mode 100644 index 0000000..d1d26fe --- /dev/null +++ b/server/item/amethyst_shard.go @@ -0,0 +1,21 @@ +package item + +import "github.com/sandertv/gophertunnel/minecraft/text" + +// AmethystShard is a crystalline mineral obtained from mining a fully grown amethyst cluster. +type AmethystShard struct{} + +// EncodeItem ... +func (AmethystShard) EncodeItem() (name string, meta int16) { + return "minecraft:amethyst_shard", 0 +} + +// TrimMaterial ... +func (AmethystShard) TrimMaterial() string { + return "amethyst" +} + +// MaterialColour ... +func (AmethystShard) MaterialColour() string { + return text.Amethyst +} diff --git a/server/item/apple.go b/server/item/apple.go new file mode 100644 index 0000000..defce89 --- /dev/null +++ b/server/item/apple.go @@ -0,0 +1,26 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/world" +) + +// Apple is a food item that can be eaten by the player. +type Apple struct { + defaultFood +} + +// Consume ... +func (a Apple) Consume(_ *world.Tx, c Consumer) Stack { + c.Saturate(4, 2.4) + return Stack{} +} + +// CompostChance ... +func (Apple) CompostChance() float64 { + return 0.65 +} + +// EncodeItem ... +func (a Apple) EncodeItem() (name string, meta int16) { + return "minecraft:apple", 0 +} diff --git a/server/item/armour.go b/server/item/armour.go new file mode 100644 index 0000000..935c453 --- /dev/null +++ b/server/item/armour.go @@ -0,0 +1,149 @@ +package item + +import "image/color" + +type ( + // Armour represents an item that may be worn as armour. Generally, these items provide armour points, which + // reduce damage taken. Some pieces of armour also provide toughness, which negates damage proportional to + // the total damage dealt. + Armour interface { + // DefencePoints returns the defence points that the armour provides when worn. + DefencePoints() float64 + // Toughness returns the toughness that the armour provides when worn. The toughness reduces defence reduction + // caused by increased damage. + Toughness() float64 + // KnockBackResistance returns a number from 0-1 that decides the amount of knock back force that is + // resisted upon being attacked. 1 knock back resistance point client-side translates to 10% knock back + // reduction. + KnockBackResistance() float64 + } + // ArmourTier represents the tier, or material, that a piece of armour is made of. + ArmourTier interface { + // BaseDurability is the base durability of armour with this tier. This is otherwise the durability of + // the helmet with this tier. + BaseDurability() float64 + // Toughness reduces the defence reduction caused by damage increases. + Toughness() float64 + // KnockBackResistance is a number from 0-1 that decides the amount of knock back force that is resisted + // upon being attacked. 1 knock back resistance point client-side translates to 10% knock back reduction. + KnockBackResistance() float64 + // EnchantmentValue is the enchantment value of the armour used when selecting pseudo-random enchantments for + // enchanting tables. When this value is high, the enchantments that are selected are more likely to be good. + EnchantmentValue() int + // Name is the name of the tier. + Name() string + } + // HelmetType is an Armour item that can be worn in the helmet slot. + HelmetType interface { + Armour + Helmet() bool + } + // ChestplateType is an Armour item that can be worn in the chestplate slot. + ChestplateType interface { + Armour + Chestplate() bool + } + // LeggingsType are an Armour item that can be worn in the leggings slot. + LeggingsType interface { + Armour + Leggings() bool + } + // BootsType are an Armour item that can be worn in the boots slot. + BootsType interface { + Armour + Boots() bool + } +) + +// ArmourTierLeather is the ArmourTier of leather armour +type ArmourTierLeather struct { + // Colour is the dyed colour of the armour. + Colour color.RGBA +} + +func (ArmourTierLeather) BaseDurability() float64 { return 55 } +func (ArmourTierLeather) Toughness() float64 { return 0 } +func (ArmourTierLeather) KnockBackResistance() float64 { return 0 } +func (ArmourTierLeather) EnchantmentValue() int { return 15 } +func (ArmourTierLeather) Name() string { return "leather" } + +// ArmourTierCopper is the ArmourTier of copper armour. +type ArmourTierCopper struct{} + +func (ArmourTierCopper) BaseDurability() float64 { return 121 } +func (ArmourTierCopper) Toughness() float64 { return 0 } +func (ArmourTierCopper) KnockBackResistance() float64 { return 0 } +func (ArmourTierCopper) EnchantmentValue() int { return 8 } +func (ArmourTierCopper) Name() string { return "copper" } + +// ArmourTierGold is the ArmourTier of gold armour. +type ArmourTierGold struct{} + +func (ArmourTierGold) BaseDurability() float64 { return 77 } +func (ArmourTierGold) Toughness() float64 { return 0 } +func (ArmourTierGold) KnockBackResistance() float64 { return 0 } +func (ArmourTierGold) EnchantmentValue() int { return 25 } +func (ArmourTierGold) Name() string { return "golden" } + +// ArmourTierChain is the ArmourTier of chain armour. +type ArmourTierChain struct{} + +func (ArmourTierChain) BaseDurability() float64 { return 166 } +func (ArmourTierChain) Toughness() float64 { return 0 } +func (ArmourTierChain) KnockBackResistance() float64 { return 0 } +func (ArmourTierChain) EnchantmentValue() int { return 12 } +func (ArmourTierChain) Name() string { return "chainmail" } + +// ArmourTierIron is the ArmourTier of iron armour. +type ArmourTierIron struct{} + +func (ArmourTierIron) BaseDurability() float64 { return 165 } +func (ArmourTierIron) Toughness() float64 { return 0 } +func (ArmourTierIron) KnockBackResistance() float64 { return 0 } +func (ArmourTierIron) EnchantmentValue() int { return 9 } +func (ArmourTierIron) Name() string { return "iron" } + +// ArmourTierDiamond is the ArmourTier of diamond armour. +type ArmourTierDiamond struct{} + +func (ArmourTierDiamond) BaseDurability() float64 { return 363 } +func (ArmourTierDiamond) Toughness() float64 { return 2 } +func (ArmourTierDiamond) KnockBackResistance() float64 { return 0 } +func (ArmourTierDiamond) EnchantmentValue() int { return 10 } +func (ArmourTierDiamond) Name() string { return "diamond" } + +// ArmourTierNetherite is the ArmourTier of netherite armour. +type ArmourTierNetherite struct{} + +func (ArmourTierNetherite) BaseDurability() float64 { return 408 } +func (ArmourTierNetherite) Toughness() float64 { return 3 } +func (ArmourTierNetherite) KnockBackResistance() float64 { return 0.1 } +func (ArmourTierNetherite) EnchantmentValue() int { return 15 } +func (ArmourTierNetherite) Name() string { return "netherite" } + +// ArmourTiers returns a list of all armour tiers. +func ArmourTiers() []ArmourTier { + return []ArmourTier{ArmourTierLeather{}, ArmourTierCopper{}, ArmourTierGold{}, ArmourTierChain{}, ArmourTierIron{}, ArmourTierDiamond{}, ArmourTierNetherite{}} +} + +// armourTierRepairable returns true if the ArmourTier passed is repairable. +func armourTierRepairable(tier ArmourTier) func(Stack) bool { + return func(stack Stack) bool { + var ok bool + switch tier.(type) { + case ArmourTierLeather: + _, ok = stack.Item().(Leather) + case ArmourTierCopper: + _, ok = stack.Item().(CopperIngot) + case ArmourTierGold: + _, ok = stack.Item().(GoldIngot) + case ArmourTierChain, ArmourTierIron: + _, ok = stack.Item().(IronIngot) + case ArmourTierDiamond: + _, ok = stack.Item().(Diamond) + case ArmourTierNetherite: + _, ok = stack.Item().(NetheriteIngot) + } + return ok + } +} diff --git a/server/item/armour_trim.go b/server/item/armour_trim.go new file mode 100644 index 0000000..21e7a85 --- /dev/null +++ b/server/item/armour_trim.go @@ -0,0 +1,77 @@ +package item + +import "github.com/df-mc/dragonfly/server/world" + +// ArmourTrim is a decorative addition to an armour piece. It consists of a +// template that specifies the pattern and a material that specifies the colour. +type ArmourTrim struct { + Template SmithingTemplateType + Material ArmourTrimMaterial +} + +// Zero checks if an ArmourTrim is considered zero: Either its material is nil +// or its template TemplateNetheriteUpgrade. +func (trim ArmourTrim) Zero() bool { + return trim.Material == nil || trim.Template == TemplateNetheriteUpgrade() +} + +// ArmourTrimMaterial is the material of an ArmourTrim, such as an IronIngot, +// that modifies the colour of an ArmourTrim. +type ArmourTrimMaterial interface { + // TrimMaterial returns the material name used for reading and writing trim data. + TrimMaterial() string + // MaterialColour returns the colour code used for internal text formatting. + MaterialColour() string +} + +// trimMaterialFromString returns a TrimMaterial from a string. +func trimMaterialFromString(name string) (ArmourTrimMaterial, bool) { + switch name { + case "amethyst": + return AmethystShard{}, true + case "copper": + return CopperIngot{}, true + case "diamond": + return Diamond{}, true + case "emerald": + return Emerald{}, true + case "gold": + return GoldIngot{}, true + case "iron": + return IronIngot{}, true + case "lapis": + return LapisLazuli{}, true + case "netherite": + return NetheriteIngot{}, true + case "quartz": + return NetherQuartz{}, true + case "resin": + return ResinBrick{}, true + case "redstone": + return RedstoneWire{}, true + } + return nil, false +} + +// ArmourTrimMaterials returns all the items that can be trim materials. +func ArmourTrimMaterials() []world.Item { + return []world.Item{ + AmethystShard{}, + CopperIngot{}, + Diamond{}, + Emerald{}, + GoldIngot{}, + IronIngot{}, + LapisLazuli{}, + NetheriteIngot{}, + NetherQuartz{}, + ResinBrick{}, + RedstoneWire{}, + } +} + +// Trimmable represents an item, generally Armour, that can have an ArmourTrim +// applied to it in a smithing table. +type Trimmable interface { + WithTrim(trim ArmourTrim) world.Item +} diff --git a/server/item/arrow.go b/server/item/arrow.go new file mode 100644 index 0000000..2243a69 --- /dev/null +++ b/server/item/arrow.go @@ -0,0 +1,23 @@ +package item + +import "github.com/df-mc/dragonfly/server/item/potion" + +// Arrow is used as ammunition for bows, crossbows, and dispensers. Arrows can be modified to +// imbue status effects on players and mobs. +type Arrow struct { + // Tip is the potion effect that is tipped on the arrow. + Tip potion.Potion +} + +// EncodeItem ... +func (a Arrow) EncodeItem() (name string, meta int16) { + if tip := a.Tip.Uint8(); tip > 4 { + return "minecraft:arrow", int16(tip + 1) + } + return "minecraft:arrow", 0 +} + +// OffHand ... +func (Arrow) OffHand() bool { + return true +} diff --git a/server/item/axe.go b/server/item/axe.go new file mode 100644 index 0000000..ffd142f --- /dev/null +++ b/server/item/axe.go @@ -0,0 +1,113 @@ +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/sound" + "github.com/go-gl/mathgl/mgl64" + "time" +) + +// Axe is a tool generally used for mining wood-like blocks. It may also be used to break some plant-like +// blocks at a faster pace such as pumpkins. +type Axe struct { + // Tier is the tier of the axe. + Tier ToolTier +} + +// UseOnBlock handles the stripping of logs when a player clicks a log with an axe. +func (a Axe) UseOnBlock(pos cube.Pos, _ cube.Face, _ mgl64.Vec3, tx *world.Tx, _ User, ctx *UseContext) bool { + if s, ok := tx.Block(pos).(Strippable); ok { + if res, so, ok := s.Strip(); ok { + tx.SetBlock(pos, res, nil) + tx.PlaySound(pos.Vec3(), sound.ItemUseOn{Block: res}) + if so != nil { + tx.PlaySound(pos.Vec3(), so) + } + + ctx.DamageItem(1) + return true + } + } + return false +} + +// Strippable represents a block that can be stripped by right-clicking it with +// an axe. +type Strippable interface { + // Strip returns a block that is the result of stripping it. Alternatively, + // the bool returned may be false to indicate the block couldn't be + // stripped. + Strip() (world.Block, world.Sound, bool) +} + +// MaxCount always returns 1. +func (a Axe) MaxCount() int { + return 1 +} + +// DurabilityInfo ... +func (a Axe) DurabilityInfo() DurabilityInfo { + return DurabilityInfo{ + MaxDurability: a.Tier.Durability, + BrokenItem: simpleItem(Stack{}), + AttackDurability: 2, + BreakDurability: 1, + } +} + +// SmeltInfo ... +func (a Axe) SmeltInfo() SmeltInfo { + switch a.Tier { + case ToolTierIron: + return newOreSmeltInfo(NewStack(IronNugget{}, 1), 0.1) + case ToolTierGold: + return newOreSmeltInfo(NewStack(GoldNugget{}, 1), 0.1) + case ToolTierCopper: + return newOreSmeltInfo(NewStack(CopperNugget{}, 1), 0.1) + } + return SmeltInfo{} +} + +// FuelInfo ... +func (a Axe) FuelInfo() FuelInfo { + if a.Tier == ToolTierWood { + return newFuelInfo(time.Second * 10) + } + return FuelInfo{} +} + +// AttackDamage ... +func (a Axe) AttackDamage() float64 { + return a.Tier.BaseAttackDamage + 2 +} + +// ToolType ... +func (a Axe) ToolType() ToolType { + return TypeAxe +} + +// HarvestLevel ... +func (a Axe) HarvestLevel() int { + return a.Tier.HarvestLevel +} + +// BaseMiningEfficiency ... +func (a Axe) BaseMiningEfficiency(world.Block) float64 { + return a.Tier.BaseMiningEfficiency +} + +// RepairableBy ... +func (a Axe) RepairableBy(i Stack) bool { + return toolTierRepairable(a.Tier)(i) +} + +// EnchantmentValue ... +func (a Axe) EnchantmentValue() int { + return a.Tier.EnchantmentValue +} + +// EncodeItem ... +func (a Axe) EncodeItem() (name string, meta int16) { + return "minecraft:" + a.Tier.Name + "_axe", 0 +} diff --git a/server/item/baked_potato.go b/server/item/baked_potato.go new file mode 100644 index 0000000..2a0e434 --- /dev/null +++ b/server/item/baked_potato.go @@ -0,0 +1,24 @@ +package item + +import "github.com/df-mc/dragonfly/server/world" + +// BakedPotato is a food item that can be eaten by the player. +type BakedPotato struct { + defaultFood +} + +// Consume ... +func (BakedPotato) Consume(_ *world.Tx, c Consumer) Stack { + c.Saturate(5, 6) + return Stack{} +} + +// CompostChance ... +func (BakedPotato) CompostChance() float64 { + return 0.85 +} + +// EncodeItem ... +func (BakedPotato) EncodeItem() (name string, meta int16) { + return "minecraft:baked_potato", 0 +} diff --git a/server/item/banner_pattern.go b/server/item/banner_pattern.go new file mode 100644 index 0000000..efd0f84 --- /dev/null +++ b/server/item/banner_pattern.go @@ -0,0 +1,18 @@ +package item + +// BannerPattern is an item used to customise banners inside looms. +type BannerPattern struct { + // Type represents the type of banner pattern. These types do not include all patterns that can be applied to a + // banner. + Type BannerPatternType +} + +// MaxCount always returns 1. +func (b BannerPattern) MaxCount() int { + return 1 +} + +// EncodeItem ... +func (b BannerPattern) EncodeItem() (name string, meta int16) { + return "minecraft:" + b.Type.String() + "_banner_pattern", 0 +} diff --git a/server/item/banner_pattern_type.go b/server/item/banner_pattern_type.go new file mode 100644 index 0000000..f98fd2e --- /dev/null +++ b/server/item/banner_pattern_type.go @@ -0,0 +1,106 @@ +package item + +// BannerPatternType represents a type of BannerPattern. +type BannerPatternType struct { + bannerPatternType +} + +// CreeperBannerPattern represents the 'Creeper' banner pattern type. +func CreeperBannerPattern() BannerPatternType { + return BannerPatternType{0} +} + +// SkullBannerPattern represents the 'Skull' banner pattern type. +func SkullBannerPattern() BannerPatternType { + return BannerPatternType{1} +} + +// FlowerBannerPattern represents the 'Flower' banner pattern type. +func FlowerBannerPattern() BannerPatternType { + return BannerPatternType{2} +} + +// MojangBannerPattern represents the 'Mojang' banner pattern type. +func MojangBannerPattern() BannerPatternType { + return BannerPatternType{3} +} + +// FieldMasonedBannerPattern represents the 'Field Masoned' banner pattern type. +func FieldMasonedBannerPattern() BannerPatternType { + return BannerPatternType{4} +} + +// BordureIndentedBannerPattern represents the 'Bordure Indented' banner pattern type. +func BordureIndentedBannerPattern() BannerPatternType { + return BannerPatternType{5} +} + +// PiglinBannerPattern represents the 'Piglin' banner pattern type. +func PiglinBannerPattern() BannerPatternType { + return BannerPatternType{6} +} + +// GlobeBannerPattern represents the 'Globe' banner pattern type. +func GlobeBannerPattern() BannerPatternType { + return BannerPatternType{7} +} + +// FlowBannerPattern represents the 'Flow' banner pattern type. +func FlowBannerPattern() BannerPatternType { + return BannerPatternType{8} +} + +// GusterBannerPattern represents the 'Guster' banner pattern type. +func GusterBannerPattern() BannerPatternType { + return BannerPatternType{9} +} + +// BannerPatterns returns all possible banner patterns. +func BannerPatterns() []BannerPatternType { + return []BannerPatternType{ + CreeperBannerPattern(), + SkullBannerPattern(), + FlowerBannerPattern(), + MojangBannerPattern(), + FieldMasonedBannerPattern(), + BordureIndentedBannerPattern(), + PiglinBannerPattern(), + GlobeBannerPattern(), + FlowBannerPattern(), + GusterBannerPattern(), + } +} + +type bannerPatternType uint8 + +// Uint8 returns the uint8 value of the banner pattern type. +func (b bannerPatternType) Uint8() uint8 { + return uint8(b) +} + +// String ... +func (b bannerPatternType) String() string { + switch b { + case 0: + return "creeper" + case 1: + return "skull" + case 2: + return "flower" + case 3: + return "mojang" + case 4: + return "field_masoned" + case 5: + return "bordure_indented" + case 6: + return "piglin" + case 7: + return "globe" + case 8: + return "flow" + case 9: + return "guster" + } + panic("should never happen") +} diff --git a/server/item/beef.go b/server/item/beef.go new file mode 100644 index 0000000..83370ac --- /dev/null +++ b/server/item/beef.go @@ -0,0 +1,37 @@ +package item + +import "github.com/df-mc/dragonfly/server/world" + +// Beef is a food item obtained from cows. It can be cooked in a furnace, smoker, or campfire. +type Beef struct { + defaultFood + + // Cooked is whether the beef is cooked. + Cooked bool +} + +// Consume ... +func (b Beef) Consume(_ *world.Tx, c Consumer) Stack { + if b.Cooked { + c.Saturate(8, 12.8) + } else { + c.Saturate(3, 1.8) + } + return Stack{} +} + +// SmeltInfo ... +func (b Beef) SmeltInfo() SmeltInfo { + if b.Cooked { + return SmeltInfo{} + } + return newFoodSmeltInfo(NewStack(Beef{Cooked: true}, 1), 0.35) +} + +// EncodeItem ... +func (b Beef) EncodeItem() (name string, meta int16) { + if b.Cooked { + return "minecraft:cooked_beef", 0 + } + return "minecraft:beef", 0 +} diff --git a/server/item/beetroot.go b/server/item/beetroot.go new file mode 100644 index 0000000..57aa4f2 --- /dev/null +++ b/server/item/beetroot.go @@ -0,0 +1,26 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/world" +) + +// Beetroot is a food and dye ingredient. +type Beetroot struct { + defaultFood +} + +// Consume ... +func (b Beetroot) Consume(_ *world.Tx, c Consumer) Stack { + c.Saturate(1, 1.2) + return Stack{} +} + +// CompostChance ... +func (Beetroot) CompostChance() float64 { + return 0.65 +} + +// EncodeItem ... +func (b Beetroot) EncodeItem() (name string, meta int16) { + return "minecraft:beetroot", 0 +} diff --git a/server/item/beetroot_soup.go b/server/item/beetroot_soup.go new file mode 100644 index 0000000..4cd7506 --- /dev/null +++ b/server/item/beetroot_soup.go @@ -0,0 +1,26 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/world" +) + +// BeetrootSoup is an unstackable food item. +type BeetrootSoup struct { + defaultFood +} + +// MaxCount ... +func (BeetrootSoup) MaxCount() int { + return 1 +} + +// Consume ... +func (BeetrootSoup) Consume(_ *world.Tx, c Consumer) Stack { + c.Saturate(6, 7.2) + return NewStack(Bowl{}, 1) +} + +// EncodeItem ... +func (BeetrootSoup) EncodeItem() (name string, meta int16) { + return "minecraft:beetroot_soup", 0 +} diff --git a/server/item/blaze_powder.go b/server/item/blaze_powder.go new file mode 100644 index 0000000..9505155 --- /dev/null +++ b/server/item/blaze_powder.go @@ -0,0 +1,9 @@ +package item + +// BlazePowder is an item made from a blaze rod obtained from blazes. +type BlazePowder struct{} + +// EncodeItem ... +func (BlazePowder) EncodeItem() (name string, meta int16) { + return "minecraft:blaze_powder", 0 +} diff --git a/server/item/blaze_rod.go b/server/item/blaze_rod.go new file mode 100644 index 0000000..0b80dd3 --- /dev/null +++ b/server/item/blaze_rod.go @@ -0,0 +1,16 @@ +package item + +import "time" + +// BlazeRod is an item exclusively obtained from blazes. +type BlazeRod struct{} + +// FuelInfo ... +func (BlazeRod) FuelInfo() FuelInfo { + return newFuelInfo(time.Second * 120) +} + +// EncodeItem ... +func (BlazeRod) EncodeItem() (name string, meta int16) { + return "minecraft:blaze_rod", 0 +} diff --git a/server/item/bone.go b/server/item/bone.go new file mode 100644 index 0000000..9f1790a --- /dev/null +++ b/server/item/bone.go @@ -0,0 +1,9 @@ +package item + +// Bone is an item primarily obtained as a drop from skeletons and their variants. +type Bone struct{} + +// EncodeItem ... +func (Bone) EncodeItem() (name string, meta int16) { + return "minecraft:bone", 0 +} diff --git a/server/item/bone_meal.go b/server/item/bone_meal.go new file mode 100644 index 0000000..9e7deea --- /dev/null +++ b/server/item/bone_meal.go @@ -0,0 +1,52 @@ +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/particle" + "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) 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 { + result := bm.BoneMeal(pos, tx) + if result == BoneMealResultNone { + return false + } + + ctx.SubtractFromCount(1) + tx.AddParticle(pos.Vec3(), particle.BoneMeal{ + Area: result == BoneMealResultArea, + }) + return true + } + return false +} + +// EncodeItem ... +func (b BoneMeal) EncodeItem() (name string, meta int16) { + return "minecraft:bone_meal", 0 +} diff --git a/server/item/book.go b/server/item/book.go new file mode 100644 index 0000000..5bbaef0 --- /dev/null +++ b/server/item/book.go @@ -0,0 +1,14 @@ +package item + +// Book is an item used in enchanting and crafting. +type Book struct{} + +// EnchantmentValue ... +func (b Book) EnchantmentValue() int { + return 1 +} + +// EncodeItem ... +func (Book) EncodeItem() (name string, meta int16) { + return "minecraft:book", 0 +} diff --git a/server/item/book_and_quill.go b/server/item/book_and_quill.go new file mode 100644 index 0000000..65f01f8 --- /dev/null +++ b/server/item/book_and_quill.go @@ -0,0 +1,116 @@ +package item + +import "slices" + +// BookAndQuill is an item used to write WrittenBook(s). +type BookAndQuill struct { + // Pages represents the pages within the book. + Pages []string +} + +// MaxCount always returns 1. +func (BookAndQuill) MaxCount() int { + return 1 +} + +// TotalPages returns the total number of pages in the book. +func (b BookAndQuill) TotalPages() int { + return len(b.Pages) +} + +// Page returns a specific page from the book and true when the page exists. It will otherwise return an empty string +// and false. +func (b BookAndQuill) Page(page int) (string, bool) { + if page < 0 || len(b.Pages) <= page { + return "", false + } + return b.Pages[page], true +} + +// DeletePage attempts to delete a page from the book. +func (b BookAndQuill) DeletePage(page int) BookAndQuill { + if page < 0 || page >= 50 { + panic("invalid page number") + } + if _, ok := b.Page(page); !ok { + panic("cannot delete nonexistent page") + } + b.Pages = slices.Delete(b.Pages, page, page+1) + return b +} + +// InsertPage attempts to insert a page within the book +func (b BookAndQuill) InsertPage(page int, text string) BookAndQuill { + if page < 0 || page >= 50 { + panic("invalid page number") + } + if len(text) > 256 { + panic("text longer then 256 bytes") + } + if page > len(b.Pages) { + panic("unable to insert page at invalid position") + } + b.Pages = slices.Insert(b.Pages, page, text) + return b +} + +// SetPage writes a page to the book, if the page doesn't exist it will be created. It will panic if the +// text is longer then 256 characters. It will return a new book representing this data. +func (b BookAndQuill) SetPage(page int, text string) BookAndQuill { + if page < 0 || page >= 50 { + panic("invalid page number") + } + if len(text) > 256 { + panic("text longer then 256 bytes") + } + if _, ok := b.Page(page); !ok { + pages := make([]string, page+1) + copy(pages, b.Pages) + b.Pages = pages + } + b.Pages[page] = text + return b +} + +// SwapPages swaps two different pages, it will panic if the largest of the two numbers doesn't exist. It will +// return the newly updated pages. +func (b BookAndQuill) SwapPages(pageOne, pageTwo int) BookAndQuill { + if pageOne < 0 || pageTwo < 0 { + panic("negative page number") + } + if _, ok := b.Page(max(pageOne, pageTwo)); !ok { + panic("invalid page number") + } + b.Pages[pageOne], b.Pages[pageTwo] = b.Pages[pageTwo], b.Pages[pageOne] + return b +} + +// DecodeNBT ... +func (b BookAndQuill) DecodeNBT(data map[string]any) any { + pages, _ := data["pages"].([]any) + for _, page := range pages { + if pageData, ok := page.(map[string]any); ok { + if text, ok := pageData["text"].(string); ok { + b.Pages = append(b.Pages, text) + } + } + } + return b +} + +// EncodeNBT ... +func (b BookAndQuill) EncodeNBT() map[string]any { + if len(b.Pages) == 0 { + return nil + } + pages := make([]any, 0, len(b.Pages)) + for _, page := range b.Pages { + pages = append(pages, map[string]any{"text": page}) + } + return map[string]any{"pages": pages} +} + +// EncodeItem ... +func (BookAndQuill) EncodeItem() (name string, meta int16) { + return "minecraft:writable_book", 0 +} diff --git a/server/item/boots.go b/server/item/boots.go new file mode 100644 index 0000000..55cfc0e --- /dev/null +++ b/server/item/boots.go @@ -0,0 +1,118 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/world" + "image/color" +) + +// Boots are a defensive item that may be equipped in the boots armour slot. They come in several tiers, like +// leather, gold, chain, iron and diamond. +type Boots struct { + // Tier is the tier of the boots. + Tier ArmourTier + // Trim specifies the trim of the armour. + Trim ArmourTrim +} + +// Use handles the auto-equipping of boots in the armour slot when using it. +func (b Boots) Use(_ *world.Tx, _ User, ctx *UseContext) bool { + ctx.SwapHeldWithArmour(3) + return false +} + +// MaxCount always returns 1. +func (b Boots) MaxCount() int { + return 1 +} + +// DurabilityInfo ... +func (b Boots) DurabilityInfo() DurabilityInfo { + return DurabilityInfo{ + MaxDurability: int(b.Tier.BaseDurability() + b.Tier.BaseDurability()/5.5), + BrokenItem: simpleItem(Stack{}), + } +} + +// SmeltInfo ... +func (b Boots) SmeltInfo() SmeltInfo { + switch b.Tier.(type) { + case ArmourTierIron, ArmourTierChain: + return newOreSmeltInfo(NewStack(IronNugget{}, 1), 0.1) + case ArmourTierGold: + return newOreSmeltInfo(NewStack(GoldNugget{}, 1), 0.1) + case ArmourTierCopper: + return newOreSmeltInfo(NewStack(CopperNugget{}, 1), 0.1) + } + return SmeltInfo{} +} + +// RepairableBy ... +func (b Boots) RepairableBy(i Stack) bool { + return armourTierRepairable(b.Tier)(i) +} + +// DefencePoints ... +func (b Boots) DefencePoints() float64 { + switch b.Tier.Name() { + case "leather", "copper", "golden", "chainmail": + return 1 + case "iron": + return 2 + case "diamond", "netherite": + return 3 + } + panic("invalid boots tier") +} + +// Toughness ... +func (b Boots) Toughness() float64 { + return b.Tier.Toughness() +} + +// KnockBackResistance ... +func (b Boots) KnockBackResistance() float64 { + return b.Tier.KnockBackResistance() +} + +// EnchantmentValue ... +func (b Boots) EnchantmentValue() int { + return b.Tier.EnchantmentValue() +} + +// Boots ... +func (b Boots) Boots() bool { + return true +} + +// WithTrim ... +func (b Boots) WithTrim(trim ArmourTrim) world.Item { + b.Trim = trim + return b +} + +// EncodeItem ... +func (b Boots) EncodeItem() (name string, meta int16) { + return "minecraft:" + b.Tier.Name() + "_boots", 0 +} + +// DecodeNBT ... +func (b Boots) DecodeNBT(data map[string]any) any { + if t, ok := b.Tier.(ArmourTierLeather); ok { + if v, ok := data["customColor"].(int32); ok { + t.Colour = rgbaFromInt32(v) + b.Tier = t + } + } + b.Trim = readTrim(data) + return b +} + +// EncodeNBT ... +func (b Boots) EncodeNBT() map[string]any { + m := map[string]any{} + if t, ok := b.Tier.(ArmourTierLeather); ok && t.Colour != (color.RGBA{}) { + m["customColor"] = int32FromRGBA(t.Colour) + } + writeTrim(m, b.Trim) + return m +} diff --git a/server/item/bottle_of_enchanting.go b/server/item/bottle_of_enchanting.go new file mode 100644 index 0000000..96ca279 --- /dev/null +++ b/server/item/bottle_of_enchanting.go @@ -0,0 +1,25 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" +) + +// BottleOfEnchanting is a bottle that releases experience orbs when thrown. +type BottleOfEnchanting struct{} + +// Use ... +func (b BottleOfEnchanting) Use(tx *world.Tx, user User, ctx *UseContext) bool { + create := tx.World().EntityRegistry().Config().BottleOfEnchanting + opts := world.EntitySpawnOpts{Position: eyePosition(user), Velocity: throwableOffset(user.Rotation()).Vec3().Mul(0.6)} + tx.AddEntity(create(opts, user)) + tx.PlaySound(user.Position(), sound.ItemThrow{}) + + ctx.SubtractFromCount(1) + return true +} + +// EncodeItem ... +func (b BottleOfEnchanting) EncodeItem() (name string, meta int16) { + return "minecraft:experience_bottle", 0 +} diff --git a/server/item/bow.go b/server/item/bow.go new file mode 100644 index 0000000..30ad644 --- /dev/null +++ b/server/item/bow.go @@ -0,0 +1,119 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/item/potion" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "math" + "time" +) + +// Bow is a ranged weapon that fires arrows. +type Bow struct{} + +// MaxCount always returns 1. +func (Bow) MaxCount() int { + return 1 +} + +// DurabilityInfo ... +func (Bow) DurabilityInfo() DurabilityInfo { + return DurabilityInfo{ + MaxDurability: 385, + BrokenItem: simpleItem(Stack{}), + } +} + +// FuelInfo ... +func (Bow) FuelInfo() FuelInfo { + return newFuelInfo(time.Second * 10) +} + +// Release ... +func (Bow) Release(releaser Releaser, tx *world.Tx, ctx *UseContext, duration time.Duration) { + creative := releaser.GameMode().CreativeInventory() + ticks := duration.Milliseconds() / 50 + if ticks < 3 { + // The player must hold the bow for at least three ticks. + return + } + + d := float64(ticks) / 20 + force := math.Min((d*d+d*2)/3, 1) + if force < 0.1 { + // The force must be at least 0.1. + return + } + + arrow, ok := ctx.FirstFunc(func(stack Stack) bool { + _, ok := stack.Item().(Arrow) + return ok + }) + if !ok && !creative { + // No arrows in inventory and not in creative mode. + return + } + + var tip potion.Potion + if !arrow.Empty() { + // Arrow is empty if not found in the creative inventory. + tip = arrow.Item().(Arrow).Tip + } + + held, _ := releaser.HeldItems() + damage, punchLevel, burnDuration, consume := 2.0, 0, time.Duration(0), !creative + for _, enchant := range held.Enchantments() { + if f, ok := enchant.Type().(interface{ BurnDuration() time.Duration }); ok { + burnDuration = f.BurnDuration() + } + if _, ok := enchant.Type().(interface{ KnockBackMultiplier() float64 }); ok { + punchLevel = enchant.Level() + } + if p, ok := enchant.Type().(interface{ PowerDamage(int) float64 }); ok { + damage += p.PowerDamage(enchant.Level()) + } + if i, ok := enchant.Type().(interface{ ConsumesArrows() bool }); ok && !i.ConsumesArrows() { + consume = false + } + } + + create := tx.World().EntityRegistry().Config().Arrow + opts := world.EntitySpawnOpts{ + Position: eyePosition(releaser), + Velocity: releaser.Rotation().Vec3().Mul(force * 5), + Rotation: releaser.Rotation().Neg(), + } + projectile := tx.AddEntity(create(opts, world.ArrowSpawnConfig{ + Damage: damage, + Owner: releaser, + Critical: force >= 1, + ObtainArrowOnPickup: !creative && consume, + PunchLevel: punchLevel, + Tip: tip, + })) + if f, ok := projectile.(interface{ SetOnFire(duration time.Duration) }); ok { + f.SetOnFire(burnDuration) + } + + ctx.DamageItem(1) + if consume { + ctx.Consume(arrow.Grow(-arrow.Count() + 1)) + } + + tx.PlaySound(releaser.Position(), sound.BowShoot{}) +} + +// EnchantmentValue ... +func (Bow) EnchantmentValue() int { + return 1 +} + +// Requirements returns the required items to release this item. +func (Bow) Requirements() []Stack { + return []Stack{NewStack(Arrow{}, 1)} +} + +// EncodeItem ... +func (Bow) EncodeItem() (name string, meta int16) { + return "minecraft:bow", 0 +} diff --git a/server/item/bowl.go b/server/item/bowl.go new file mode 100644 index 0000000..9c7478c --- /dev/null +++ b/server/item/bowl.go @@ -0,0 +1,16 @@ +package item + +import "time" + +// Bowl is a container that can hold certain foods. +type Bowl struct{} + +// FuelInfo ... +func (Bowl) FuelInfo() FuelInfo { + return newFuelInfo(time.Second * 10) +} + +// EncodeItem ... +func (Bowl) EncodeItem() (name string, meta int16) { + return "minecraft:bowl", 0 +} diff --git a/server/item/bread.go b/server/item/bread.go new file mode 100644 index 0000000..f98e337 --- /dev/null +++ b/server/item/bread.go @@ -0,0 +1,24 @@ +package item + +import "github.com/df-mc/dragonfly/server/world" + +// Bread is a food item that can be eaten by the player. +type Bread struct { + defaultFood +} + +// Consume ... +func (Bread) Consume(_ *world.Tx, c Consumer) Stack { + c.Saturate(5, 6) + return Stack{} +} + +// CompostChance ... +func (Bread) CompostChance() float64 { + return 0.85 +} + +// EncodeItem ... +func (Bread) EncodeItem() (name string, meta int16) { + return "minecraft:bread", 0 +} diff --git a/server/item/brick.go b/server/item/brick.go new file mode 100644 index 0000000..8dcd76d --- /dev/null +++ b/server/item/brick.go @@ -0,0 +1,14 @@ +package item + +// Brick is an item made from clay, and is used for making bricks and flower pots. +type Brick struct{} + +// EncodeItem ... +func (b Brick) EncodeItem() (name string, meta int16) { + return "minecraft:brick", 0 +} + +// PotDecoration ... +func (Brick) PotDecoration() bool { + return true +} diff --git a/server/item/bucket.go b/server/item/bucket.go new file mode 100644 index 0000000..31c8309 --- /dev/null +++ b/server/item/bucket.go @@ -0,0 +1,169 @@ +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/sound" + "github.com/go-gl/mathgl/mgl64" + "time" +) + +// BucketContent is the content of a bucket. +type BucketContent struct { + liquid world.Liquid + milk bool +} + +// LiquidBucketContent returns a new BucketContent with the liquid passed in. +func LiquidBucketContent(l world.Liquid) BucketContent { + return BucketContent{liquid: l} +} + +// MilkBucketContent returns a new BucketContent with the milk flag set. +func MilkBucketContent() BucketContent { + return BucketContent{milk: true} +} + +// Liquid returns the world.Liquid that a Bucket with this BucketContent places. +// If this BucketContent does not place a liquid block, false is returned. +func (b BucketContent) Liquid() (world.Liquid, bool) { + return b.liquid, b.liquid != nil +} + +// String converts the BucketContent to a string. +func (b BucketContent) String() string { + if b.milk { + return "milk" + } else if b.liquid != nil { + return b.liquid.LiquidType() + } + return "" +} + +// LiquidType returns the type of liquid the bucket contains. +func (b BucketContent) LiquidType() string { + if b.liquid != nil { + return b.liquid.LiquidType() + } + return "milk" +} + +// Bucket is a tool used to carry water, lava and fish. +type Bucket struct { + // Content is the content that the bucket has. By default, this value resolves to an empty bucket. + Content BucketContent +} + +// MaxCount returns 16. +func (b Bucket) MaxCount() int { + if b.Empty() { + return 16 + } + return 1 +} + +// AlwaysConsumable ... +func (b Bucket) AlwaysConsumable() bool { + return b.Content.milk +} + +// CanConsume ... +func (b Bucket) CanConsume() bool { + return b.Content.milk +} + +// ConsumeDuration ... +func (b Bucket) ConsumeDuration() time.Duration { + return DefaultConsumeDuration +} + +// Consume ... +func (b Bucket) Consume(_ *world.Tx, c Consumer) Stack { + for _, effect := range c.Effects() { + c.RemoveEffect(effect.Type()) + } + return NewStack(Bucket{}, 1) +} + +// Empty returns true if the bucket is empty. +func (b Bucket) Empty() bool { + return b.Content.liquid == nil && !b.Content.milk +} + +// FuelInfo ... +func (b Bucket) FuelInfo() FuelInfo { + if liq := b.Content.liquid; liq != nil && liq.LiquidType() == "lava" { + return newFuelInfo(time.Second * 1000).WithResidue(NewStack(Bucket{}, 1)) + } + return FuelInfo{} +} + +// UseOnBlock handles the bucket filling and emptying logic. +func (b Bucket) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, _ User, ctx *UseContext) bool { + if b.Content.milk { + return false + } + if b.Empty() { + return b.fillFrom(pos, tx, ctx) + } + liq := b.Content.liquid.WithDepth(8, false) + if bl := tx.Block(pos); canDisplace(bl, liq) || replaceableWith(bl, liq) { + tx.SetLiquid(pos, liq) + } else if bl := tx.Block(pos.Side(face)); canDisplace(bl, liq) || replaceableWith(bl, liq) { + tx.SetLiquid(pos.Side(face), liq) + } else { + return false + } + + tx.PlaySound(pos.Vec3Centre(), sound.BucketEmpty{Liquid: b.Content.liquid}) + ctx.NewItem = NewStack(Bucket{}, 1) + ctx.NewItemSurvivalOnly = true + ctx.SubtractFromCount(1) + return true +} + +// fillFrom fills a bucket from the liquid at the position passed in the world. If there is no liquid or if +// the liquid is no source, fillFrom returns false. +func (b Bucket) fillFrom(pos cube.Pos, tx *world.Tx, ctx *UseContext) bool { + liquid, ok := tx.Liquid(pos) + if !ok { + return false + } + if liquid.LiquidDepth() != 8 || liquid.LiquidFalling() { + // Only allow picking up liquid source blocks. + return false + } + tx.SetLiquid(pos, nil) + tx.PlaySound(pos.Vec3Centre(), sound.BucketFill{Liquid: liquid}) + + ctx.NewItem = NewStack(Bucket{Content: LiquidBucketContent(liquid)}, 1) + ctx.NewItemSurvivalOnly = true + ctx.SubtractFromCount(1) + return true +} + +// EncodeItem ... +func (b Bucket) EncodeItem() (name string, meta int16) { + if !b.Empty() { + return "minecraft:" + b.Content.String() + "_bucket", 0 + } + return "minecraft:bucket", 0 +} + +type replaceable interface { + ReplaceableBy(b world.Block) bool +} + +func replaceableWith(b world.Block, with world.Block) bool { + if r, ok := b.(replaceable); ok { + return r.ReplaceableBy(with) + } + return false +} + +func canDisplace(b world.Block, liq world.Liquid) bool { + if d, ok := b.(world.LiquidDisplacer); ok { + return d.CanDisplace(liq) + } + return false +} diff --git a/server/item/carrot_on_a_stick.go b/server/item/carrot_on_a_stick.go new file mode 100644 index 0000000..32875a0 --- /dev/null +++ b/server/item/carrot_on_a_stick.go @@ -0,0 +1,14 @@ +package item + +// CarrotOnAStick is an item that can be used to control saddled pigs. +type CarrotOnAStick struct{} + +// MaxCount ... +func (CarrotOnAStick) MaxCount() int { + return 1 +} + +// EncodeItem ... +func (CarrotOnAStick) EncodeItem() (name string, meta int16) { + return "minecraft:carrot_on_a_stick", 0 +} diff --git a/server/item/category/category.go b/server/item/category/category.go new file mode 100644 index 0000000..5dc7779 --- /dev/null +++ b/server/item/category/category.go @@ -0,0 +1,65 @@ +package category + +// Category represents the category a custom item will be displayed in, in the creative inventory. +type Category struct { + group string + category uint8 +} + +// Construction is the first tab in the creative inventory and usually contains blocks that are more for decoration and +// building than actual functionality. +func Construction() Category { + return Category{category: 1} +} + +// Nature is the fourth tab in the creative inventory and usually contains blocks and items that can be found naturally +// in vanilla-generated world. +func Nature() Category { + return Category{category: 2} +} + +// Equipment is the second tab in the creative inventory and usually contains armour, weapons and tools. +func Equipment() Category { + return Category{category: 3} +} + +// Items is the third tab in the creative inventory and usually contains blocks and items that do not come under any +// other category, such as minerals, mob drops, containers and redstone etc. +func Items() Category { + return Category{category: 4} +} + +// Uint8 ... +func (c Category) Uint8() uint8 { + return c.category +} + +// WithGroup returns the category with the provided subgroup. This can be used to put an item inside a group such as +// swords, food or different types of blocks. +func (c Category) WithGroup(group string) Category { + c.group = group + return c +} + +// String ... +func (c Category) String() string { + switch c.category { + case 1: + return "construction" + case 2: + return "nature" + case 3: + return "equipment" + case 4: + return "items" + } + panic("should never happen") +} + +// Group ... +func (c Category) Group() string { + if len(c.group) > 0 { + return "itemGroup.name." + c.group + } + return "" +} diff --git a/server/item/charcoal.go b/server/item/charcoal.go new file mode 100644 index 0000000..07fe1fc --- /dev/null +++ b/server/item/charcoal.go @@ -0,0 +1,16 @@ +package item + +import "time" + +// Charcoal is an item obtained by smelting logs or wood. +type Charcoal struct{} + +// FuelInfo ... +func (Charcoal) FuelInfo() FuelInfo { + return newFuelInfo(time.Second * 80) +} + +// EncodeItem ... +func (Charcoal) EncodeItem() (name string, meta int16) { + return "minecraft:charcoal", 0 +} diff --git a/server/item/chestplate.go b/server/item/chestplate.go new file mode 100644 index 0000000..e352296 --- /dev/null +++ b/server/item/chestplate.go @@ -0,0 +1,122 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/world" + "image/color" +) + +// Chestplate is a defensive item that may be equipped in the chestplate slot. Generally, chestplates provide +// the most defence of all armour items. +type Chestplate struct { + // Tier is the tier of the chestplate. + Tier ArmourTier + // Trim specifies the trim of the armour. + Trim ArmourTrim +} + +// Use handles the using of a chestplate to auto-equip it in the designated armour slot. +func (c Chestplate) Use(_ *world.Tx, _ User, ctx *UseContext) bool { + ctx.SwapHeldWithArmour(1) + return false +} + +// MaxCount always returns 1. +func (c Chestplate) MaxCount() int { + return 1 +} + +// DefencePoints ... +func (c Chestplate) DefencePoints() float64 { + switch c.Tier.Name() { + case "leather": + return 3 + case "copper": + return 4 + case "golden", "chainmail": + return 5 + case "iron": + return 6 + case "diamond", "netherite": + return 8 + } + panic("invalid chestplate tier") +} + +// Toughness ... +func (c Chestplate) Toughness() float64 { + return c.Tier.Toughness() +} + +// KnockBackResistance ... +func (c Chestplate) KnockBackResistance() float64 { + return c.Tier.KnockBackResistance() +} + +// EnchantmentValue ... +func (c Chestplate) EnchantmentValue() int { + return c.Tier.EnchantmentValue() +} + +// DurabilityInfo ... +func (c Chestplate) DurabilityInfo() DurabilityInfo { + return DurabilityInfo{ + MaxDurability: int(c.Tier.BaseDurability() + c.Tier.BaseDurability()/2.2), + BrokenItem: simpleItem(Stack{}), + } +} + +// SmeltInfo ... +func (c Chestplate) SmeltInfo() SmeltInfo { + switch c.Tier.(type) { + case ArmourTierIron, ArmourTierChain: + return newOreSmeltInfo(NewStack(IronNugget{}, 1), 0.1) + case ArmourTierGold: + return newOreSmeltInfo(NewStack(GoldNugget{}, 1), 0.1) + case ArmourTierCopper: + return newOreSmeltInfo(NewStack(CopperNugget{}, 1), 0.1) + } + return SmeltInfo{} +} + +// RepairableBy ... +func (c Chestplate) RepairableBy(i Stack) bool { + return armourTierRepairable(c.Tier)(i) +} + +// Chestplate ... +func (c Chestplate) Chestplate() bool { + return true +} + +// WithTrim ... +func (c Chestplate) WithTrim(trim ArmourTrim) world.Item { + c.Trim = trim + return c +} + +// EncodeItem ... +func (c Chestplate) EncodeItem() (name string, meta int16) { + return "minecraft:" + c.Tier.Name() + "_chestplate", 0 +} + +// DecodeNBT ... +func (c Chestplate) DecodeNBT(data map[string]any) any { + if t, ok := c.Tier.(ArmourTierLeather); ok { + if v, ok := data["customColor"].(int32); ok { + t.Colour = rgbaFromInt32(v) + c.Tier = t + } + } + c.Trim = readTrim(data) + return c +} + +// EncodeNBT ... +func (c Chestplate) EncodeNBT() map[string]any { + m := map[string]any{} + if t, ok := c.Tier.(ArmourTierLeather); ok && t.Colour != (color.RGBA{}) { + m["customColor"] = int32FromRGBA(t.Colour) + } + writeTrim(m, c.Trim) + return m +} diff --git a/server/item/chicken.go b/server/item/chicken.go new file mode 100644 index 0000000..2db0d49 --- /dev/null +++ b/server/item/chicken.go @@ -0,0 +1,45 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/world" + "math/rand/v2" + "time" +) + +// Chicken is a food item obtained from chickens. It can be cooked in a furnace, smoker, or campfire. +type Chicken struct { + defaultFood + + // Cooked is whether the chicken is cooked. + Cooked bool +} + +// Consume ... +func (c Chicken) Consume(_ *world.Tx, co Consumer) Stack { + if c.Cooked { + co.Saturate(6, 7.2) + } else { + co.Saturate(2, 1.2) + if rand.Float64() < 0.3 { + co.AddEffect(effect.New(effect.Hunger, 1, 30*time.Second)) + } + } + return Stack{} +} + +// SmeltInfo ... +func (c Chicken) SmeltInfo() SmeltInfo { + if c.Cooked { + return SmeltInfo{} + } + return newFoodSmeltInfo(NewStack(Chicken{Cooked: true}, 1), 0.35) +} + +// EncodeItem ... +func (c Chicken) EncodeItem() (name string, meta int16) { + if c.Cooked { + return "minecraft:cooked_chicken", 0 + } + return "minecraft:chicken", 0 +} diff --git a/server/item/clay_ball.go b/server/item/clay_ball.go new file mode 100644 index 0000000..96188d7 --- /dev/null +++ b/server/item/clay_ball.go @@ -0,0 +1,14 @@ +package item + +// ClayBall is obtained from mining clay blocks +type ClayBall struct{} + +// SmeltInfo ... +func (ClayBall) SmeltInfo() SmeltInfo { + return newSmeltInfo(NewStack(Brick{}, 1), 0.3) +} + +// EncodeItem ... +func (ClayBall) EncodeItem() (name string, meta int16) { + return "minecraft:clay_ball", 0 +} diff --git a/server/item/clock.go b/server/item/clock.go new file mode 100644 index 0000000..806f820 --- /dev/null +++ b/server/item/clock.go @@ -0,0 +1,9 @@ +package item + +// Clock is used to measure and display in-game time. +type Clock struct{} + +// EncodeItem ... +func (w Clock) EncodeItem() (name string, meta int16) { + return "minecraft:clock", 0 +} diff --git a/server/item/coal.go b/server/item/coal.go new file mode 100644 index 0000000..b97380d --- /dev/null +++ b/server/item/coal.go @@ -0,0 +1,16 @@ +package item + +import "time" + +// Coal is an item used as fuel & crafting torches. +type Coal struct{} + +// FuelInfo ... +func (Coal) FuelInfo() FuelInfo { + return newFuelInfo(time.Second * 80) +} + +// EncodeItem ... +func (Coal) EncodeItem() (name string, meta int16) { + return "minecraft:coal", 0 +} diff --git a/server/item/cod.go b/server/item/cod.go new file mode 100644 index 0000000..735c870 --- /dev/null +++ b/server/item/cod.go @@ -0,0 +1,37 @@ +package item + +import "github.com/df-mc/dragonfly/server/world" + +// Cod is a food item obtained from cod. It can be cooked in a furnace, smoker, or campfire. +type Cod struct { + defaultFood + + // Cooked is whether the cod is cooked. + Cooked bool +} + +// Consume ... +func (c Cod) Consume(_ *world.Tx, co Consumer) Stack { + if c.Cooked { + co.Saturate(5, 6) + } else { + co.Saturate(2, 0.4) + } + return Stack{} +} + +// SmeltInfo ... +func (c Cod) SmeltInfo() SmeltInfo { + if c.Cooked { + return SmeltInfo{} + } + return newFoodSmeltInfo(NewStack(Cod{Cooked: true}, 1), 0.35) +} + +// EncodeItem ... +func (c Cod) EncodeItem() (name string, meta int16) { + if c.Cooked { + return "minecraft:cooked_cod", 0 + } + return "minecraft:cod", 0 +} diff --git a/server/item/colour.go b/server/item/colour.go new file mode 100644 index 0000000..b9adc37 --- /dev/null +++ b/server/item/colour.go @@ -0,0 +1,212 @@ +package item + +import ( + "image/color" +) + +// Colour represents the colour of a block. Typically, Minecraft blocks have a total of 16 different colours. +type Colour struct { + colour +} + +// ColourWhite returns the white colour. +func ColourWhite() Colour { + return Colour{0} +} + +// ColourOrange returns the orange colour. +func ColourOrange() Colour { + return Colour{1} +} + +// ColourMagenta returns the magenta colour. +func ColourMagenta() Colour { + return Colour{2} +} + +// ColourLightBlue returns the light blue colour. +func ColourLightBlue() Colour { + return Colour{3} +} + +// ColourYellow returns the yellow colour. +func ColourYellow() Colour { + return Colour{4} +} + +// ColourLime returns the lime colour. +func ColourLime() Colour { + return Colour{5} +} + +// ColourPink returns the pink colour. +func ColourPink() Colour { + return Colour{6} +} + +// ColourGrey returns the grey colour. +func ColourGrey() Colour { + return Colour{7} +} + +// ColourLightGrey returns the light grey colour. +func ColourLightGrey() Colour { + return Colour{8} +} + +// ColourCyan returns the cyan colour. +func ColourCyan() Colour { + return Colour{9} +} + +// ColourPurple returns the purple colour. +func ColourPurple() Colour { + return Colour{10} +} + +// ColourBlue returns the blue colour. +func ColourBlue() Colour { + return Colour{11} +} + +// ColourBrown returns the brown colour. +func ColourBrown() Colour { + return Colour{12} +} + +// ColourGreen returns the green colour. +func ColourGreen() Colour { + return Colour{13} +} + +// ColourRed returns the red colour. +func ColourRed() Colour { + return Colour{14} +} + +// ColourBlack returns the black colour. +func ColourBlack() Colour { + return Colour{15} +} + +// Colours returns a list of all existing colours. +func Colours() []Colour { + return []Colour{ + ColourWhite(), ColourOrange(), ColourMagenta(), ColourLightBlue(), ColourYellow(), ColourLime(), ColourPink(), ColourGrey(), + ColourLightGrey(), ColourCyan(), ColourPurple(), ColourBlue(), ColourBrown(), ColourGreen(), ColourRed(), ColourBlack(), + } +} + +// colour is the underlying value of a Colour struct. +type colour uint8 + +// RGBA returns the colour as RGBA. The alpha channel is always set to the maximum value. Colour values as returned here +// were obtained by placing signs in a world with all possible dyes used on them. The world was then loaded in Dragonfly +// to read their respective colours. +func (c colour) RGBA() color.RGBA { + switch c { + case 0: + return color.RGBA{R: 0xf0, G: 0xf0, B: 0xf0, A: 0xff} + case 1: + return color.RGBA{R: 0xf9, G: 0x80, B: 0x1d, A: 0xff} + case 2: + return color.RGBA{R: 0xc7, G: 0x4e, B: 0xbd, A: 0xff} + case 3: + return color.RGBA{R: 0x3a, G: 0xb3, B: 0xda, A: 0xff} + case 4: + return color.RGBA{R: 0xfe, G: 0xd8, B: 0x3d, A: 0xff} + case 5: + return color.RGBA{R: 0x80, G: 0xc7, B: 0x1f, A: 0xff} + case 6: + return color.RGBA{R: 0xf3, G: 0x8b, B: 0xaa, A: 0xff} + case 7: + return color.RGBA{R: 0x47, G: 0x4f, B: 0x52, A: 0xff} + case 8: + return color.RGBA{R: 0x9d, G: 0x9d, B: 0x97, A: 0xff} + case 9: + return color.RGBA{R: 0x16, G: 0x9c, B: 0x9c, A: 0xff} + case 10: + return color.RGBA{R: 0x89, G: 0x32, B: 0xb8, A: 0xff} + case 11: + return color.RGBA{R: 0x3c, G: 0x44, B: 0xaa, A: 0xff} + case 12: + return color.RGBA{R: 0x83, G: 0x54, B: 0x32, A: 0xff} + case 13: + return color.RGBA{R: 0x5e, G: 0x7c, B: 0x16, A: 0xff} + case 14: + return color.RGBA{R: 0xb0, G: 0x2e, B: 0x26, A: 0xff} + default: + return color.RGBA{R: 0x1d, G: 0x1d, B: 0x21, A: 0xff} + } +} + +// SignRGBA returns the colour as RGBA. It is identical to the RGBA method, except for the colour black. For the colour +// black, a value of rgba(0, 0, 0, 255) is returned rather than rgba(29, 29, 33, 255), in order to match the black sign +// text colour found in vanilla Minecraft. +func (c colour) SignRGBA() color.RGBA { + if c == 15 { + return color.RGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff} + } + return c.RGBA() +} + +// String ... +func (c colour) String() string { + switch c { + default: + return "white" + case 1: + return "orange" + case 2: + return "magenta" + case 3: + return "light_blue" + case 4: + return "yellow" + case 5: + return "lime" + case 6: + return "pink" + case 7: + return "gray" + case 8: + return "light_gray" + case 9: + return "cyan" + case 10: + return "purple" + case 11: + return "blue" + case 12: + return "brown" + case 13: + return "green" + case 14: + return "red" + case 15: + return "black" + } +} + +// SilverString returns the name of the colour, with light_gray being replaced by silver. +func (c colour) SilverString() string { + if c == 8 { + return "silver" + } + return c.String() +} + +// Uint8 ... +func (c colour) Uint8() uint8 { + return uint8(c) +} + +// invertColour converts the item.Colour passed and returns the colour ID inverted. +func invertColour(c Colour) int16 { + return ^int16(c.Uint8()) & 0xf +} + +// invertColourID converts the int16 passed the returns the item.Colour inverted. +func invertColourID(id int16) Colour { + return Colours()[uint8(^id&0xf)] +} diff --git a/server/item/compass.go b/server/item/compass.go new file mode 100644 index 0000000..19219f9 --- /dev/null +++ b/server/item/compass.go @@ -0,0 +1,9 @@ +package item + +// Compass is an item used to find the spawn position of a world. +type Compass struct{} + +// EncodeItem ... +func (Compass) EncodeItem() (name string, meta int16) { + return "minecraft:compass", 0 +} diff --git a/server/item/cookie.go b/server/item/cookie.go new file mode 100644 index 0000000..ac69e5e --- /dev/null +++ b/server/item/cookie.go @@ -0,0 +1,24 @@ +package item + +import "github.com/df-mc/dragonfly/server/world" + +// Cookie is a food item that can be obtained in large quantities, but do not restore hunger or saturation significantly. +type Cookie struct { + defaultFood +} + +// Consume ... +func (Cookie) Consume(_ *world.Tx, c Consumer) Stack { + c.Saturate(2, 0.4) + return Stack{} +} + +// CompostChance ... +func (Cookie) CompostChance() float64 { + return 0.85 +} + +// EncodeItem ... +func (Cookie) EncodeItem() (name string, meta int16) { + return "minecraft:cookie", 0 +} diff --git a/server/item/copper_ingot.go b/server/item/copper_ingot.go new file mode 100644 index 0000000..2ac7b43 --- /dev/null +++ b/server/item/copper_ingot.go @@ -0,0 +1,21 @@ +package item + +import "github.com/sandertv/gophertunnel/minecraft/text" + +// CopperIngot is a metal ingot melted from copper ore. +type CopperIngot struct{} + +// EncodeItem ... +func (c CopperIngot) EncodeItem() (name string, meta int16) { + return "minecraft:copper_ingot", 0 +} + +// TrimMaterial ... +func (CopperIngot) TrimMaterial() string { + return "copper" +} + +// MaterialColour ... +func (CopperIngot) MaterialColour() string { + return text.Copper +} diff --git a/server/item/copper_nugget.go b/server/item/copper_nugget.go new file mode 100644 index 0000000..655045f --- /dev/null +++ b/server/item/copper_nugget.go @@ -0,0 +1,9 @@ +package item + +// CopperNugget is a piece of copper that can be obtained by smelting copper tools/weapons or armour. +type CopperNugget struct{} + +// EncodeItem ... +func (CopperNugget) EncodeItem() (name string, meta int16) { + return "minecraft:copper_nugget", 0 +} diff --git a/server/item/creative/category.go b/server/item/creative/category.go new file mode 100644 index 0000000..7533213 --- /dev/null +++ b/server/item/creative/category.go @@ -0,0 +1,37 @@ +package creative + +// Category represents a category of items in the creative inventory which are shown as different tabs. +type Category struct { + category +} + +type category uint8 + +// ConstructionCategory is the construction category which contains only blocks that do not fall under +// any other category. +func ConstructionCategory() Category { + return Category{1} +} + +// NatureCategory is the nature category which contains blocks and items that can be naturally found in the +// world. +func NatureCategory() Category { + return Category{2} +} + +// EquipmentCategory is the equipment category which contains tools, armour, food and any other form of +// equipment. +func EquipmentCategory() Category { + return Category{3} +} + +// ItemsCategory is the items category for all the miscellaneous items that do not fall under any other +// category. +func ItemsCategory() Category { + return Category{4} +} + +// Uint8 returns the category type as a uint8. +func (s category) Uint8() uint8 { + return uint8(s) +} diff --git a/server/item/creative/creative.go b/server/item/creative/creative.go new file mode 100644 index 0000000..c652f9d --- /dev/null +++ b/server/item/creative/creative.go @@ -0,0 +1,176 @@ +package creative + +import ( + _ "embed" + "fmt" + + "github.com/df-mc/dragonfly/server/internal/nbtconv" + // The following four imports are essential for this package: They make sure this package is loaded after + // all these imports. This ensures that all blocks and items are registered before the creative items are + // registered in the init function in this package. + _ "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/nbt" +) + +// Item represents a registered item in the creative inventory. It holds a stack of the item and a group that +// the item is part of. +type Item struct { + // Stack is the stack of the item that is registered in the creative inventory. + Stack item.Stack + // Group is the name of the group that the item is part of. If two groups are registered with the same + // name, the item will always reside in the first group that was registered. + Group string +} + +// Group represents a group of items in the creative inventory. Each group has a category, a name and an icon. +// If either the name or icon is empty, the group is considered an 'anonymous group' and will not group its +// contents together in the creative inventory. +type Group struct { + // Category is the category of the group. It determines the tab in which the group will be displayed in the + // creative inventory. + Category Category + // Name is the localised name of the group, i.e. "itemGroup.name.planks". + Name string + // Icon is the item that will be displayed as the icon of the group in the creative inventory. + Icon item.Stack +} + +// Groups returns a list with all groups that have been registered as a creative group. These groups will be +// accessible by players in-game who have creative mode enabled. +func Groups() []Group { + return creativeGroups +} + +// RegisterGroup registers a group as a creative group, exposing it in the creative inventory. It can then +// be referenced using its name when calling RegisterItem. +func RegisterGroup(group Group) { + creativeGroups = append(creativeGroups, group) +} + +// Items returns a list with all items that have been registered as a creative item. These items will +// be accessible by players in-game who have creative mode enabled. +func Items() []Item { + return creativeItemStacks +} + +// RegisterItem registers an item as a creative item, exposing it in the creative inventory. +func RegisterItem(item Item) { + creativeItemStacks = append(creativeItemStacks, item) +} + +var ( + //go:embed creative_items.nbt + creativeItemData []byte + + // creativeGroups holds a list of all groups that were registered to the creative inventory using + // RegisterGroup. + creativeGroups []Group + // creativeItemStacks holds a list of all item stacks that were registered to the creative inventory using + // RegisterItem. + creativeItemStacks []Item +) + +// creativeGroupEntry holds data of a creative group as present in the creative inventory. +type creativeGroupEntry struct { + Category int32 `nbt:"category"` + Name string `nbt:"name"` + Icon creativeItemEntry `nbt:"icon"` +} + +// creativeItemEntry holds data of a creative item as present in the creative inventory. +type creativeItemEntry struct { + Name string `nbt:"name"` + Meta int16 `nbt:"meta"` + NBT map[string]any `nbt:"nbt,omitempty"` + BlockProperties map[string]any `nbt:"block_properties,omitempty"` + GroupIndex int32 `nbt:"group_index,omitempty"` +} + +// registerCreativeItems initialises the creative items, registering all creative items that have also been registered as +// normal items and are present in vanilla. +// noinspection GoUnusedFunction +// +//lint:ignore U1000 Function is used through compiler directives. +func registerCreativeItems() { + var m struct { + Groups []creativeGroupEntry `nbt:"groups"` + Items []creativeItemEntry `nbt:"items"` + } + if err := nbt.Unmarshal(creativeItemData, &m); err != nil { + panic(err) + } + for i, group := range m.Groups { + name := group.Name + if name == "" { + name = fmt.Sprint("anon", i) + } + st, _ := itemStackFromEntry(group.Icon) + c := Category{category(group.Category)} + RegisterGroup(Group{Category: c, Name: name, Icon: st}) + } + for _, data := range m.Items { + if data.GroupIndex >= int32(len(creativeGroups)) { + panic(fmt.Errorf("invalid group index %v for item %v", data.GroupIndex, data.Name)) + } + st, ok := itemStackFromEntry(data) + if !ok { + continue + } + RegisterItem(Item{st, creativeGroups[data.GroupIndex].Name}) + } +} + +func itemStackFromEntry(data creativeItemEntry) (item.Stack, bool) { + var ( + it world.Item + ok bool + ) + if len(data.BlockProperties) > 0 { + // Item with a block, try parsing the block, then try asserting that to an item. Blocks no longer + // have their metadata sent, but we still need to get that metadata in order to be able to register + // different block states as different items. + if b, ok := world.BlockByName(data.Name, data.BlockProperties); ok { + if it, ok = b.(world.Item); !ok { + return item.Stack{}, false + } + } + } else { + if it, ok = world.ItemByName(data.Name, data.Meta); !ok { + // The item wasn't registered, so don't register it as a creative item. + return item.Stack{}, false + } + if _, resultingMeta := it.EncodeItem(); resultingMeta != data.Meta { + // We found an item registered with that ID and a meta of 0, but we only need items with strictly + // the same meta here. + return item.Stack{}, false + } + } + + if n, ok := it.(world.NBTer); ok { + if len(data.NBT) > 0 { + it = n.DecodeNBT(data.NBT).(world.Item) + } + } + + st := item.NewStack(it, 1) + if len(data.NBT) > 0 { + var invalid bool + for _, e := range nbtconv.Slice(data.NBT, "ench") { + if v, ok := e.(map[string]any); ok { + t, ok := item.EnchantmentByID(int(nbtconv.Int16(v, "id"))) + if !ok { + invalid = true + break + } + st = st.WithEnchantments(item.NewEnchantment(t, int(nbtconv.Int16(v, "lvl")))) + } + } + if invalid { + // Invalid enchantment, skip this item. + return item.Stack{}, false + } + } + return st, true +} diff --git a/server/item/creative/creative_items.nbt b/server/item/creative/creative_items.nbt new file mode 100644 index 0000000..ebb532f Binary files /dev/null and b/server/item/creative/creative_items.nbt differ diff --git a/server/item/crossbow.go b/server/item/crossbow.go new file mode 100644 index 0000000..ed90384 --- /dev/null +++ b/server/item/crossbow.go @@ -0,0 +1,249 @@ +package item + +import ( + "time" + _ "unsafe" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" +) + +// Crossbow is a ranged weapon similar to a bow that uses arrows or fireworks +// as ammunition. +type Crossbow struct { + // Item is the item the crossbow is charged with. + Item Stack +} + +// Charge starts the charging process and checks if the charge duration meets +// the required duration. +func (c Crossbow) Charge(releaser Releaser, _ *world.Tx, ctx *UseContext, duration time.Duration) bool { + if !c.Item.Empty() { + return false + } + + creative := releaser.GameMode().CreativeInventory() + held, left := releaser.HeldItems() + + if chargeDuration, _ := c.chargeDuration(held); duration < chargeDuration { + return false + } + projectileItem, ok := c.findProjectile(releaser, ctx) + if !ok { + return false + } + c.Item = projectileItem.Grow(-projectileItem.Count() + 1) + if !creative { + ctx.Consume(c.Item) + } + + releaser.SetHeldItems(held.WithItem(c), left) + return true +} + +// ContinueCharge ... +func (c Crossbow) ContinueCharge(releaser Releaser, tx *world.Tx, ctx *UseContext, duration time.Duration) { + if !c.Item.Empty() { + return + } + + held, _ := releaser.HeldItems() + if _, ok := c.findProjectile(releaser, ctx); !ok { + return + } + + chargeDuration, qcLevel := c.chargeDuration(held) + if duration.Seconds() <= 0.1 { + tx.PlaySound(releaser.Position(), sound.CrossbowLoad{Stage: sound.CrossbowLoadingStart, QuickCharge: qcLevel > 0}) + } + + // Base reload time is 25 ticks; each Quick Charge level reduces by 5 ticks + multiplier := 25.0 / float64(25-(5*qcLevel)) + + // Adjust ticks based on the multiplier + adjustedTicks := int(float64(duration.Milliseconds()) / (50 / multiplier)) + + // Play sound after every 16 ticks (adjusted by Quick Charge) + if adjustedTicks%16 == 0 { + tx.PlaySound(releaser.Position(), sound.CrossbowLoad{Stage: sound.CrossbowLoadingMiddle, QuickCharge: qcLevel > 0}) + } + + if progress := float64(duration) / float64(chargeDuration); progress >= 1 { + tx.PlaySound(releaser.Position(), sound.CrossbowLoad{Stage: sound.CrossbowLoadingEnd, QuickCharge: qcLevel > 0}) + } +} + +// chargeDuration calculates the duration required to charge the crossbow and +// the quick charge enchantment level, if any. +func (c Crossbow) chargeDuration(s Stack) (dur time.Duration, quickChargeLvl int) { + dur, lvl := time.Duration(1.25*float64(time.Second)), 0 + for _, enchant := range s.Enchantments() { + if q, ok := enchant.Type().(interface{ ChargeDuration(int) time.Duration }); ok { + dur = min(dur, q.ChargeDuration(enchant.Level())) + lvl = enchant.Level() + } + } + return dur, lvl +} + +// findProjectile looks through the inventory of a Releaser to find a projectile +// to insert into the crossbow. It first checks the left hand for fireworks or +// arrows, and searches the rest of the inventory for arrows if no valid +// projectile was in the left hand. False is returned if no valid projectile was +// anywhere in the inventory. +func (c Crossbow) findProjectile(r Releaser, ctx *UseContext) (Stack, bool) { + _, left := r.HeldItems() + _, isFirework := left.Item().(Firework) + _, isArrow := left.Item().(Arrow) + if isFirework || isArrow { + return left, true + } + if res, ok := ctx.FirstFunc(func(stack Stack) bool { + _, ok := stack.Item().(Arrow) + return ok + }); ok { + return res, true + } + if r.GameMode().CreativeInventory() { + // No projectiles in inventory but the player is in creative mode, so + // return an arrow anyway. + return NewStack(Arrow{}, 1), true + } + return Stack{}, false +} + +// ReleaseCharge checks if the item is fully charged and, if so, releases it. +func (c Crossbow) ReleaseCharge(releaser Releaser, tx *world.Tx, ctx *UseContext) bool { + if c.Item.Empty() { + return false + } + + held, _ := releaser.HeldItems() + creative := releaser.GameMode().CreativeInventory() + + pierceLevel, multishot := 0, false + for _, enchant := range held.Enchantments() { + if _, ok := enchant.Type().(interface{ MultipleProjectiles() bool }); ok { + multishot = true + } + if _, ok := enchant.Type().(interface{ Pierces() bool }); ok { + pierceLevel = enchant.Level() + } + } + + arrowConf := world.ArrowSpawnConfig{ + Damage: 9, + Owner: releaser, + Critical: true, + ObtainArrowOnPickup: !creative, + PiercingLevel: pierceLevel, + } + c.shoot(releaser, tx, 0, arrowConf) + if multishot { + arrowConf.ObtainArrowOnPickup = false + c.shoot(releaser, tx, -10, arrowConf) + c.shoot(releaser, tx, 10, arrowConf) + } + c.applyDamage(ctx) + + c.Item = Stack{} + held, left := releaser.HeldItems() + crossbow := held.WithItem(c) + releaser.SetHeldItems(crossbow, left) + tx.PlaySound(releaser.Position(), sound.CrossbowShoot{}) + 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() + dirVec := cube.Rotation{rot[0] + offsetAngle, rot[1]}.Vec3() + + if firework, ok := c.Item.Item().(Firework); ok { + createFirework := tx.World().EntityRegistry().Config().Firework + projectile := createFirework(world.EntitySpawnOpts{ + Position: torsoPosition(releaser), + Velocity: dirVec.Mul(0.8), + Rotation: rot.Neg(), + }, firework, releaser, 1.0, 0, false) + tx.AddEntity(projectile) + } else { + createArrow := tx.World().EntityRegistry().Config().Arrow + arrowConf.Tip = c.Item.Item().(Arrow).Tip + arrow := createArrow(world.EntitySpawnOpts{ + Position: torsoPosition(releaser), + Velocity: dirVec.Mul(5.15), + Rotation: rot.Neg(), + }, arrowConf) + tx.AddEntity(arrow) + } +} + +// applyDamage applies damage on a UseContext based on the projectile loaded +// in the crossboww. +func (c Crossbow) applyDamage(ctx *UseContext) { + if _, ok := c.Item.Item().(Firework); ok { + ctx.DamageItem(3) + } else { + ctx.DamageItem(1) + } +} + +// MaxCount always returns 1. +func (Crossbow) MaxCount() int { + return 1 +} + +// DurabilityInfo ... +func (Crossbow) DurabilityInfo() DurabilityInfo { + return DurabilityInfo{ + MaxDurability: 464, + BrokenItem: simpleItem(Stack{}), + } +} + +// FuelInfo ... +func (Crossbow) FuelInfo() FuelInfo { + return newFuelInfo(time.Second * 15) +} + +// EnchantmentValue ... +func (Crossbow) EnchantmentValue() int { + return 1 +} + +// EncodeItem ... +func (Crossbow) EncodeItem() (name string, meta int16) { + return "minecraft:crossbow", 0 +} + +// DecodeNBT ... +func (c Crossbow) DecodeNBT(data map[string]any) any { + c.Item = mapItem(data, "chargedItem") + return c +} + +// EncodeNBT ... +func (c Crossbow) EncodeNBT() map[string]any { + if !c.Item.Empty() { + return map[string]any{"chargedItem": writeItem(c.Item, true)} + } + return nil +} + +// noinspection ALL +// +//go:linkname writeItem github.com/df-mc/dragonfly/server/internal/nbtconv.WriteItem +func writeItem(s Stack, disk bool) map[string]any + +// noinspection ALL +// +//go:linkname mapItem github.com/df-mc/dragonfly/server/internal/nbtconv.MapItem +func mapItem(x map[string]any, k string) Stack diff --git a/server/item/diamond.go b/server/item/diamond.go new file mode 100644 index 0000000..0e58b1b --- /dev/null +++ b/server/item/diamond.go @@ -0,0 +1,26 @@ +package item + +import "github.com/sandertv/gophertunnel/minecraft/text" + +// Diamond is a rare mineral obtained from diamond ore or loot chests. +type Diamond struct{} + +// EncodeItem ... +func (Diamond) EncodeItem() (name string, meta int16) { + return "minecraft:diamond", 0 +} + +// TrimMaterial ... +func (Diamond) TrimMaterial() string { + return "diamond" +} + +// MaterialColour ... +func (Diamond) MaterialColour() string { + return text.Diamond +} + +// PayableForBeacon ... +func (Diamond) PayableForBeacon() bool { + return true +} diff --git a/server/item/disc_fragment.go b/server/item/disc_fragment.go new file mode 100644 index 0000000..8f0c055 --- /dev/null +++ b/server/item/disc_fragment.go @@ -0,0 +1,10 @@ +package item + +// DiscFragment is a music disc fragment obtained from ancient city loot chests. They are extremely rare to find and +// nine of them in a crafting table makes a music disc named, "5". +type DiscFragment struct{} + +// EncodeItem ... +func (DiscFragment) EncodeItem() (name string, meta int16) { + return "minecraft:disc_fragment_5", 0 +} diff --git a/server/item/dragon_breath.go b/server/item/dragon_breath.go new file mode 100644 index 0000000..3f548da --- /dev/null +++ b/server/item/dragon_breath.go @@ -0,0 +1,9 @@ +package item + +// DragonBreath is a brewing item that is used solely to make lingering potions. +type DragonBreath struct{} + +// EncodeItem ... +func (DragonBreath) EncodeItem() (name string, meta int16) { + return "minecraft:dragon_breath", 0 +} diff --git a/server/item/dried_kelp.go b/server/item/dried_kelp.go new file mode 100644 index 0000000..0a483b5 --- /dev/null +++ b/server/item/dried_kelp.go @@ -0,0 +1,35 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/world" + "time" +) + +// DriedKelp is a food item that can be quickly eaten by the player. +type DriedKelp struct{} + +// AlwaysConsumable ... +func (DriedKelp) AlwaysConsumable() bool { + return false +} + +// ConsumeDuration ... +func (DriedKelp) ConsumeDuration() time.Duration { + return DefaultConsumeDuration / 2 +} + +// Consume ... +func (DriedKelp) Consume(_ *world.Tx, c Consumer) Stack { + c.Saturate(1, 0.2) + return Stack{} +} + +// CompostChance ... +func (DriedKelp) CompostChance() float64 { + return 0.3 +} + +// EncodeItem ... +func (DriedKelp) EncodeItem() (name string, meta int16) { + return "minecraft:dried_kelp", 0 +} diff --git a/server/item/durability.go b/server/item/durability.go new file mode 100644 index 0000000..eff07c0 --- /dev/null +++ b/server/item/durability.go @@ -0,0 +1,37 @@ +package item + +// Durable represents an item that has durability, and may therefore be broken. Some durable items, when +// broken, create a new item, such as an elytra. +type Durable interface { + // DurabilityInfo returns info related to the durability of an item. + DurabilityInfo() DurabilityInfo +} + +// DurabilityInfo is the info of a durable item. It includes fields that must be set in order to define +// durability related behaviour. +type DurabilityInfo struct { + // MaxDurability is the maximum durability that this item may have. This field must be positive for the + // durability to function properly. + MaxDurability int + // BrokenItem is the item created when the item is broken. For most durable items, this is simply an + // air item. + BrokenItem func() Stack + // AttackDurability and BreakDurability are the losses in durability that the item sustains when they are + // used to do the respective actions. + AttackDurability, BreakDurability int + // Persistent is true if the item is persistent, i.e. it will not be destroyed when at its last durability stage. + Persistent bool +} + +// Repairable represents a durable item that can be repaired by other items. +type Repairable interface { + Durable + RepairableBy(i Stack) bool +} + +// simpleItem is a convenience function to return an item stack as BrokenItem. +func simpleItem(i Stack) func() Stack { + return func() Stack { + return i + } +} diff --git a/server/item/dye.go b/server/item/dye.go new file mode 100644 index 0000000..602aaf9 --- /dev/null +++ b/server/item/dye.go @@ -0,0 +1,37 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// Dye is an item that comes in 16 colours which allows you to colour blocks like concrete and sheep. +type Dye struct { + // Colour is the colour of the dye. + Colour Colour +} + +// UseOnBlock implements the colouring behaviour of signs. +func (d Dye) UseOnBlock(pos cube.Pos, _ cube.Face, _ mgl64.Vec3, tx *world.Tx, user User, ctx *UseContext) bool { + if dy, ok := tx.Block(pos).(dyeable); ok { + if res, ok := dy.Dye(pos, user.Position(), d.Colour); ok { + tx.SetBlock(pos, res, nil) + ctx.SubtractFromCount(1) + return true + } + } + return false +} + +// dyeable represents a block that may be dyed by clicking it with a dye item. +type dyeable interface { + // Dye uses a dye with the Colour passed on the block. The resulting block is returned. A bool is returned to + // indicate if dyeing the block was successful. + Dye(pos cube.Pos, userPos mgl64.Vec3, c Colour) (world.Block, bool) +} + +// EncodeItem ... +func (d Dye) EncodeItem() (name string, meta int16) { + return "minecraft:" + d.Colour.String() + "_dye", 0 +} diff --git a/server/item/echo_shard.go b/server/item/echo_shard.go new file mode 100644 index 0000000..2787d90 --- /dev/null +++ b/server/item/echo_shard.go @@ -0,0 +1,9 @@ +package item + +// EchoShard is an item found in ancient cities which can be used to craft recovery compasses. +type EchoShard struct{} + +// EncodeItem ... +func (EchoShard) EncodeItem() (name string, meta int16) { + return "minecraft:echo_shard", 0 +} diff --git a/server/item/egg.go b/server/item/egg.go new file mode 100644 index 0000000..b31de9f --- /dev/null +++ b/server/item/egg.go @@ -0,0 +1,30 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" +) + +// Egg is an item that can be used to craft food items, or as a throwable entity to spawn chicks. +type Egg struct{} + +// MaxCount ... +func (e Egg) MaxCount() int { + return 16 +} + +// Use ... +func (e Egg) Use(tx *world.Tx, user User, ctx *UseContext) bool { + create := tx.World().EntityRegistry().Config().Egg + opts := world.EntitySpawnOpts{Position: eyePosition(user), Velocity: user.Rotation().Vec3().Mul(1.5)} + tx.AddEntity(create(opts, user)) + tx.PlaySound(user.Position(), sound.ItemThrow{}) + + ctx.SubtractFromCount(1) + return true +} + +// EncodeItem ... +func (e Egg) EncodeItem() (name string, meta int16) { + return "minecraft:egg", 0 +} diff --git a/server/item/elytra.go b/server/item/elytra.go new file mode 100644 index 0000000..21e7099 --- /dev/null +++ b/server/item/elytra.go @@ -0,0 +1,57 @@ +package item + +import "github.com/df-mc/dragonfly/server/world" + +// Elytra is a pair of rare wings found in end ships that are the only single-item source of flight in Survival mode. +type Elytra struct{} + +// Use handles the using of an elytra to auto-equip it in an armour slot. +func (Elytra) Use(_ *world.Tx, _ User, ctx *UseContext) bool { + ctx.SwapHeldWithArmour(1) + return false +} + +// DurabilityInfo ... +func (Elytra) DurabilityInfo() DurabilityInfo { + return DurabilityInfo{ + MaxDurability: 433, + Persistent: true, + BrokenItem: simpleItem(Stack{}), + } +} + +// RepairableBy ... +func (Elytra) RepairableBy(i Stack) bool { + _, ok := i.Item().(PhantomMembrane) + return ok +} + +// MaxCount always returns 1. +func (Elytra) MaxCount() int { + return 1 +} + +// Chestplate ... +func (Elytra) Chestplate() bool { + return true +} + +// DefencePoints ... +func (Elytra) DefencePoints() float64 { + return 0 +} + +// Toughness ... +func (e Elytra) Toughness() float64 { + return 0 +} + +// KnockBackResistance ... +func (e Elytra) KnockBackResistance() float64 { + return 0 +} + +// EncodeItem ... +func (Elytra) EncodeItem() (name string, meta int16) { + return "minecraft:elytra", 0 +} diff --git a/server/item/emerald.go b/server/item/emerald.go new file mode 100644 index 0000000..51b54d7 --- /dev/null +++ b/server/item/emerald.go @@ -0,0 +1,26 @@ +package item + +import "github.com/sandertv/gophertunnel/minecraft/text" + +// Emerald is a rare mineral obtained from emerald ore or from villagers. +type Emerald struct{} + +// EncodeItem ... +func (Emerald) EncodeItem() (name string, meta int16) { + return "minecraft:emerald", 0 +} + +// TrimMaterial ... +func (Emerald) TrimMaterial() string { + return "emerald" +} + +// MaterialColour ... +func (Emerald) MaterialColour() string { + return text.Emerald +} + +// PayableForBeacon ... +func (Emerald) PayableForBeacon() bool { + return true +} diff --git a/server/item/enchanted_apple.go b/server/item/enchanted_apple.go new file mode 100644 index 0000000..0567a9d --- /dev/null +++ b/server/item/enchanted_apple.go @@ -0,0 +1,35 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/world" + "time" +) + +// EnchantedApple is a rare variant of the golden apple that has stronger effects. +type EnchantedApple struct{} + +// AlwaysConsumable ... +func (EnchantedApple) AlwaysConsumable() bool { + return true +} + +// ConsumeDuration ... +func (EnchantedApple) ConsumeDuration() time.Duration { + return DefaultConsumeDuration +} + +// Consume ... +func (EnchantedApple) Consume(_ *world.Tx, c Consumer) Stack { + c.Saturate(4, 9.6) + c.AddEffect(effect.New(effect.Absorption, 4, 2*time.Minute)) + c.AddEffect(effect.New(effect.Regeneration, 2, 30*time.Second)) + c.AddEffect(effect.New(effect.FireResistance, 1, 5*time.Minute)) + c.AddEffect(effect.New(effect.Resistance, 1, 5*time.Minute)) + return Stack{} +} + +// EncodeItem ... +func (EnchantedApple) EncodeItem() (name string, meta int16) { + return "minecraft:enchanted_golden_apple", 0 +} diff --git a/server/item/enchanted_book.go b/server/item/enchanted_book.go new file mode 100644 index 0000000..a237920 --- /dev/null +++ b/server/item/enchanted_book.go @@ -0,0 +1,14 @@ +package item + +// EnchantedBook is an item that lets players add enchantments to certain items using an anvil. +type EnchantedBook struct{} + +// MaxCount ... +func (b EnchantedBook) MaxCount() int { + return 1 +} + +// EncodeItem ... +func (EnchantedBook) EncodeItem() (name string, meta int16) { + return "minecraft:enchanted_book", 0 +} diff --git a/server/item/enchantment.go b/server/item/enchantment.go new file mode 100644 index 0000000..ca8a9da --- /dev/null +++ b/server/item/enchantment.go @@ -0,0 +1,98 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/world" + "maps" + "slices" + "sort" +) + +// Enchantment is an enchantment that can be applied to a Stack. It holds an EnchantmentType and level that influences +// the power of the enchantment. +type Enchantment struct { + t EnchantmentType + lvl int +} + +// NewEnchantment creates and returns an Enchantment with a specific EnchantmentType and level. If the level passed +// exceeds EnchantmentType.MaxLevel, NewEnchantment panics. +func NewEnchantment(t EnchantmentType, lvl int) Enchantment { + if lvl < 1 { + panic("enchantment level must never be below 1") + } + return Enchantment{t: t, lvl: lvl} +} + +// Level returns the current level of the Enchantment as passed to NewEnchantment upon construction. +func (e Enchantment) Level() int { + return e.lvl +} + +// Type returns the EnchantmentType of the Enchantment as passed to NewEnchantment upon construction. +func (e Enchantment) Type() EnchantmentType { + return e.t +} + +// EnchantmentType represents an enchantment type that can be applied to a Stack, with specific behaviour that modifies +// the Stack's behaviour. +// An instance of an EnchantmentType may be created using NewEnchantment. +type EnchantmentType interface { + // Name returns the name of the enchantment. + Name() string + // MaxLevel returns the maximum level the enchantment should be able to have. + MaxLevel() int + // Cost returns the minimum and maximum cost the enchantment may inhibit. The higher this range is, the more likely + // better enchantments are to be selected. + Cost(level int) (int, int) + // Rarity returns the enchantment's rarity. + Rarity() EnchantmentRarity + // CompatibleWithEnchantment is called when an enchantment is added to an item. It can be used to check if + // the enchantment is compatible with other enchantments. + CompatibleWithEnchantment(t EnchantmentType) bool + // CompatibleWithItem is also called when an enchantment is added to an item. It can be used to check if + // the enchantment is compatible with the item type. + CompatibleWithItem(i world.Item) bool +} + +// Enchantable is an interface that can be implemented by items that can be enchanted through an enchanting table. +type Enchantable interface { + // EnchantmentValue returns the value the item may inhibit on possible enchantments. + EnchantmentValue() int +} + +// RegisterEnchantment registers an enchantment with the ID passed. Once registered, enchantments may be received +// by instantiating an EnchantmentType struct (e.g. enchantment.Protection{}) +func RegisterEnchantment(id int, enchantment EnchantmentType) { + enchantmentsMap[id] = enchantment + enchantmentIDs[enchantment] = id +} + +var ( + enchantmentsMap = map[int]EnchantmentType{} + enchantmentIDs = map[EnchantmentType]int{} +) + +// EnchantmentByID attempts to return an enchantment by the ID it was registered with. If found, the enchantment found +// is returned and the bool true. +func EnchantmentByID(id int) (EnchantmentType, bool) { + e, ok := enchantmentsMap[id] + return e, ok +} + +// EnchantmentID attempts to return the ID the enchantment was registered with. If found, the id is returned and +// the bool true. +func EnchantmentID(e EnchantmentType) (int, bool) { + id, ok := enchantmentIDs[e] + return id, ok +} + +// Enchantments returns a slice of all registered enchantments. +func Enchantments() []EnchantmentType { + e := slices.Collect(maps.Values(enchantmentsMap)) + sort.Slice(e, func(i, j int) bool { + id1, _ := EnchantmentID(e[i]) + id2, _ := EnchantmentID(e[j]) + return id1 < id2 + }) + return e +} diff --git a/server/item/enchantment/aqua_affinity.go b/server/item/enchantment/aqua_affinity.go new file mode 100644 index 0000000..e4fd972 --- /dev/null +++ b/server/item/enchantment/aqua_affinity.go @@ -0,0 +1,42 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// AquaAffinity is a helmet enchantment that increases underwater mining speed. +var AquaAffinity aquaAffinity + +type aquaAffinity struct{} + +// Name ... +func (aquaAffinity) Name() string { + return "Aqua Affinity" +} + +// MaxLevel ... +func (aquaAffinity) MaxLevel() int { + return 1 +} + +// Cost ... +func (aquaAffinity) Cost(int) (int, int) { + return 1, 41 +} + +// Rarity ... +func (aquaAffinity) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityRare +} + +// CompatibleWithEnchantment ... +func (aquaAffinity) CompatibleWithEnchantment(item.EnchantmentType) bool { + return true +} + +// CompatibleWithItem ... +func (aquaAffinity) CompatibleWithItem(i world.Item) bool { + h, ok := i.(item.HelmetType) + return ok && h.Helmet() +} diff --git a/server/item/enchantment/blast_protection.go b/server/item/enchantment/blast_protection.go new file mode 100644 index 0000000..019e239 --- /dev/null +++ b/server/item/enchantment/blast_protection.go @@ -0,0 +1,48 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// BlastProtection is an armour enchantment that reduces damage from explosions. +var BlastProtection blastProtection + +type blastProtection struct{} + +// Name ... +func (blastProtection) Name() string { + return "Blast Protection" +} + +// MaxLevel ... +func (blastProtection) MaxLevel() int { + return 4 +} + +// Cost ... +func (blastProtection) Cost(level int) (int, int) { + minCost := 5 + (level-1)*8 + return minCost, minCost + 8 +} + +// Rarity ... +func (blastProtection) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityRare +} + +// Modifier returns the base protection modifier for the enchantment. +func (blastProtection) Modifier() float64 { + return 0.08 +} + +// CompatibleWithEnchantment ... +func (blastProtection) CompatibleWithEnchantment(t item.EnchantmentType) bool { + return t != FireProtection && t != ProjectileProtection && t != Protection +} + +// CompatibleWithItem ... +func (blastProtection) CompatibleWithItem(i world.Item) bool { + _, ok := i.(item.Armour) + return ok +} diff --git a/server/item/enchantment/damage.go b/server/item/enchantment/damage.go new file mode 100644 index 0000000..26fa44a --- /dev/null +++ b/server/item/enchantment/damage.go @@ -0,0 +1,52 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "math" +) + +// AffectedDamageSource represents a world.DamageSource whose damage may be +// affected by an enchantment. A world.DamageSource does not need to implement +// AffectedDamageSource to let protection affect the damage. This happens +// depending on the (world.DamageSource).ReducedByResistance() method. +type AffectedDamageSource interface { + world.DamageSource + // AffectedByEnchantment specifies if a world.DamageSource is affected by + // the item.EnchantmentType passed. + AffectedByEnchantment(e item.EnchantmentType) bool +} + +// DamageModifier is an item.EnchantmentType that can reduce damage through a +// modifier if an AffectedDamageSource returns true for it. +type DamageModifier interface { + Modifier() float64 +} + +// ProtectionFactor calculates the combined protection factor for a slice of +// item.Enchantment. The factor depends on the world.DamageSource passed and is +// in a range of [0, 0.8], where 0.8 means incoming damage would be reduced by +// 80%. +func ProtectionFactor(src world.DamageSource, enchantments []item.Enchantment) float64 { + f := 0.0 + for _, e := range enchantments { + t := e.Type() + modifier, ok := t.(DamageModifier) + if !ok { + continue + } + reduced := false + if _, ok := t.(protection); ok && src.ReducedByResistance() { + // Special case for protection, because it applies to all damage + // sources by default, except those not reduced by resistance. + reduced = true + } else if asrc, ok := src.(AffectedDamageSource); ok && asrc.AffectedByEnchantment(t) { + reduced = true + } + + if reduced { + f += float64(e.Level()) * modifier.Modifier() + } + } + return math.Min(f, 0.8) +} diff --git a/server/item/enchantment/depth_strider.go b/server/item/enchantment/depth_strider.go new file mode 100644 index 0000000..0413fa1 --- /dev/null +++ b/server/item/enchantment/depth_strider.go @@ -0,0 +1,44 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// DepthStrider is a boot enchantment that increases underwater movement speed. +var DepthStrider depthStrider + +type depthStrider struct{} + +// Name ... +func (depthStrider) Name() string { + return "Depth Strider" +} + +// MaxLevel ... +func (depthStrider) MaxLevel() int { + return 3 +} + +// Cost ... +func (depthStrider) Cost(level int) (int, int) { + minCost := level * 10 + return minCost, minCost + 15 +} + +// Rarity ... +func (depthStrider) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityRare +} + +// CompatibleWithEnchantment ... +func (depthStrider) CompatibleWithEnchantment(item.EnchantmentType) bool { + // TODO: Frost Walker + return true +} + +// CompatibleWithItem ... +func (depthStrider) CompatibleWithItem(i world.Item) bool { + b, ok := i.(item.BootsType) + return ok && b.Boots() +} diff --git a/server/item/enchantment/efficiency.go b/server/item/enchantment/efficiency.go new file mode 100644 index 0000000..742a19a --- /dev/null +++ b/server/item/enchantment/efficiency.go @@ -0,0 +1,48 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// Efficiency is an enchantment that increases mining speed. +var Efficiency efficiency + +type efficiency struct{} + +// Name ... +func (efficiency) Name() string { + return "Efficiency" +} + +// MaxLevel ... +func (efficiency) MaxLevel() int { + return 5 +} + +// Cost ... +func (efficiency) Cost(level int) (int, int) { + minCost := 1 + 10*(level-1) + return minCost, minCost + 50 +} + +// Rarity ... +func (efficiency) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityCommon +} + +// Addend returns the mining speed addend from efficiency. +func (efficiency) Addend(level int) float64 { + return float64(level*level + 1) +} + +// CompatibleWithEnchantment ... +func (efficiency) CompatibleWithEnchantment(item.EnchantmentType) bool { + return true +} + +// CompatibleWithItem ... +func (efficiency) CompatibleWithItem(i world.Item) bool { + t, ok := i.(item.Tool) + return ok && (t.ToolType() != item.TypeSword && t.ToolType() != item.TypeNone) +} diff --git a/server/item/enchantment/feather_falling.go b/server/item/enchantment/feather_falling.go new file mode 100644 index 0000000..22f8d14 --- /dev/null +++ b/server/item/enchantment/feather_falling.go @@ -0,0 +1,49 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// FeatherFalling is an enchantment to boots that reduces fall damage. It does +// not affect falling speed. +var FeatherFalling featherFalling + +type featherFalling struct{} + +// Name ... +func (featherFalling) Name() string { + return "Feather Falling" +} + +// MaxLevel ... +func (featherFalling) MaxLevel() int { + return 4 +} + +// Cost ... +func (featherFalling) Cost(level int) (int, int) { + minCost := 5 + (level-1)*6 + return minCost, minCost + 6 +} + +// Rarity ... +func (featherFalling) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityUncommon +} + +// Modifier returns the base protection modifier for the enchantment. +func (featherFalling) Modifier() float64 { + return 0.12 +} + +// CompatibleWithEnchantment ... +func (featherFalling) CompatibleWithEnchantment(item.EnchantmentType) bool { + return true +} + +// CompatibleWithItem ... +func (featherFalling) CompatibleWithItem(i world.Item) bool { + b, ok := i.(item.BootsType) + return ok && b.Boots() +} diff --git a/server/item/enchantment/fire_aspect.go b/server/item/enchantment/fire_aspect.go new file mode 100644 index 0000000..e93c112 --- /dev/null +++ b/server/item/enchantment/fire_aspect.go @@ -0,0 +1,49 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "time" +) + +// FireAspect is a sword enchantment that sets the target on fire. +var FireAspect fireAspect + +type fireAspect struct{} + +// Name ... +func (fireAspect) Name() string { + return "Fire Aspect" +} + +// MaxLevel ... +func (fireAspect) MaxLevel() int { + return 2 +} + +// Cost ... +func (fireAspect) Cost(level int) (int, int) { + minCost := 10 + (level-1)*20 + return minCost, minCost + 50 +} + +// Rarity ... +func (fireAspect) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityRare +} + +// Duration returns how long the fire from fire aspect will last. +func (fireAspect) Duration(level int) time.Duration { + return time.Second * 4 * time.Duration(level) +} + +// CompatibleWithEnchantment ... +func (fireAspect) CompatibleWithEnchantment(item.EnchantmentType) bool { + return true +} + +// CompatibleWithItem ... +func (fireAspect) CompatibleWithItem(i world.Item) bool { + t, ok := i.(item.Tool) + return ok && t.ToolType() == item.TypeSword +} diff --git a/server/item/enchantment/fire_protection.go b/server/item/enchantment/fire_protection.go new file mode 100644 index 0000000..9164bbb --- /dev/null +++ b/server/item/enchantment/fire_protection.go @@ -0,0 +1,48 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// FireProtection is an armour enchantment that decreases fire damage. +var FireProtection fireProtection + +type fireProtection struct{} + +// Name ... +func (fireProtection) Name() string { + return "Fire Protection" +} + +// MaxLevel ... +func (fireProtection) MaxLevel() int { + return 4 +} + +// Cost ... +func (fireProtection) Cost(level int) (int, int) { + minCost := 10 + (level-1)*8 + return minCost, minCost + 8 +} + +// Rarity ... +func (fireProtection) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityUncommon +} + +// Modifier returns the base protection modifier for the enchantment. +func (fireProtection) Modifier() float64 { + return 0.08 +} + +// CompatibleWithEnchantment ... +func (fireProtection) CompatibleWithEnchantment(t item.EnchantmentType) bool { + return t != BlastProtection && t != ProjectileProtection && t != Protection +} + +// CompatibleWithItem ... +func (fireProtection) CompatibleWithItem(i world.Item) bool { + _, ok := i.(item.Armour) + return ok +} diff --git a/server/item/enchantment/flame.go b/server/item/enchantment/flame.go new file mode 100644 index 0000000..903343a --- /dev/null +++ b/server/item/enchantment/flame.go @@ -0,0 +1,49 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "time" +) + +// Flame turns your arrows into flaming arrows allowing you to set your targets +// on fire. +var Flame flame + +type flame struct{} + +// Name ... +func (flame) Name() string { + return "Flame" +} + +// MaxLevel ... +func (flame) MaxLevel() int { + return 1 +} + +// Cost ... +func (flame) Cost(int) (int, int) { + return 20, 50 +} + +// Rarity ... +func (flame) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityRare +} + +// BurnDuration always returns five seconds, no matter the level. +func (flame) BurnDuration() time.Duration { + return time.Second * 5 +} + +// CompatibleWithEnchantment ... +func (flame) CompatibleWithEnchantment(item.EnchantmentType) bool { + return true +} + +// CompatibleWithItem ... +func (flame) CompatibleWithItem(i world.Item) bool { + _, ok := i.(item.Bow) + return ok +} diff --git a/server/item/enchantment/fortune.go b/server/item/enchantment/fortune.go new file mode 100644 index 0000000..37c3d1c --- /dev/null +++ b/server/item/enchantment/fortune.go @@ -0,0 +1,43 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// Fortune is an enchantment that gives a chance to receive more item drops from certain blocks. +var Fortune fortune + +type fortune struct{} + +// Name ... +func (fortune) Name() string { + return "Fortune" +} + +// MaxLevel ... +func (fortune) MaxLevel() int { + return 3 +} + +// Cost ... +func (fortune) Cost(level int) (int, int) { + minCost := 15 + (level-1)*9 + return minCost, minCost + 50 + level +} + +// Rarity ... +func (fortune) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityRare +} + +// CompatibleWithEnchantment ... +func (fortune) CompatibleWithEnchantment(t item.EnchantmentType) bool { + return t != SilkTouch +} + +// CompatibleWithItem ... +func (fortune) CompatibleWithItem(i world.Item) bool { + t, ok := i.(item.Tool) + return ok && (t.ToolType() == item.TypePickaxe || t.ToolType() == item.TypeShovel || t.ToolType() == item.TypeAxe || t.ToolType() == item.TypeHoe) +} diff --git a/server/item/enchantment/infinity.go b/server/item/enchantment/infinity.go new file mode 100644 index 0000000..18c0381 --- /dev/null +++ b/server/item/enchantment/infinity.go @@ -0,0 +1,48 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// Infinity is an enchantment to bows that prevents regular arrows from being +// consumed when shot. +var Infinity infinity + +type infinity struct{} + +// Name ... +func (infinity) Name() string { + return "Infinity" +} + +// MaxLevel ... +func (infinity) MaxLevel() int { + return 1 +} + +// Cost ... +func (infinity) Cost(int) (int, int) { + return 20, 50 +} + +// Rarity ... +func (infinity) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityVeryRare +} + +// ConsumesArrows always returns false. +func (infinity) ConsumesArrows() bool { + return false +} + +// CompatibleWithEnchantment ... +func (infinity) CompatibleWithEnchantment(t item.EnchantmentType) bool { + return t != Mending +} + +// CompatibleWithItem ... +func (infinity) CompatibleWithItem(i world.Item) bool { + _, ok := i.(item.Bow) + return ok +} diff --git a/server/item/enchantment/knockback.go b/server/item/enchantment/knockback.go new file mode 100644 index 0000000..2465f0a --- /dev/null +++ b/server/item/enchantment/knockback.go @@ -0,0 +1,48 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// Knockback is an enchantment to a sword that increases the sword's knock-back. +var Knockback knockback + +type knockback struct{} + +// Name ... +func (knockback) Name() string { + return "Knockback" +} + +// MaxLevel ... +func (knockback) MaxLevel() int { + return 2 +} + +// Cost ... +func (knockback) Cost(level int) (int, int) { + minCost := 5 + (level-1)*20 + return minCost, minCost + 50 +} + +// Rarity ... +func (knockback) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityUncommon +} + +// Force returns the increase in knock-back force from the enchantment. +func (knockback) Force(level int) float64 { + return float64(level) / 2 +} + +// CompatibleWithEnchantment ... +func (knockback) CompatibleWithEnchantment(item.EnchantmentType) bool { + return true +} + +// CompatibleWithItem ... +func (knockback) CompatibleWithItem(i world.Item) bool { + t, ok := i.(item.Tool) + return ok && t.ToolType() == item.TypeSword +} diff --git a/server/item/enchantment/mending.go b/server/item/enchantment/mending.go new file mode 100644 index 0000000..45137ea --- /dev/null +++ b/server/item/enchantment/mending.go @@ -0,0 +1,49 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// Mending is an enchantment that repairs the item when experience orbs are +// collected. +var Mending mending + +type mending struct{} + +// Name ... +func (mending) Name() string { + return "Mending" +} + +// MaxLevel ... +func (mending) MaxLevel() int { + return 1 +} + +// Cost ... +func (mending) Cost(level int) (int, int) { + minCost := level * 25 + return minCost, minCost + 50 +} + +// Rarity ... +func (mending) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityRare +} + +// Treasure ... +func (mending) Treasure() bool { + return true +} + +// CompatibleWithEnchantment ... +func (mending) CompatibleWithEnchantment(t item.EnchantmentType) bool { + return t != Infinity +} + +// CompatibleWithItem ... +func (mending) CompatibleWithItem(i world.Item) bool { + _, ok := i.(item.Durable) + return ok +} diff --git a/server/item/enchantment/multishot.go b/server/item/enchantment/multishot.go new file mode 100644 index 0000000..e007d55 --- /dev/null +++ b/server/item/enchantment/multishot.go @@ -0,0 +1,47 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// Multishot is an enchantment for crossbows that allow them to shoot three arrows or firework rockets at the cost of one. +var Multishot multishot + +type multishot struct{} + +// Name ... +func (multishot) Name() string { + return "Multishot" +} + +// MaxLevel ... +func (multishot) MaxLevel() int { + return 1 +} + +// Cost ... +func (m multishot) Cost(level int) (int, int) { + return 20, 50 +} + +// Rarity ... +func (multishot) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityRare +} + +// CompatibleWithEnchantment ... +func (multishot) CompatibleWithEnchantment(t item.EnchantmentType) bool { + return t != Piercing +} + +// CompatibleWithItem ... +func (multishot) CompatibleWithItem(i world.Item) bool { + _, ok := i.(item.Crossbow) + return ok +} + +// MultipleProjectiles ... +func (multishot) MultipleProjectiles() bool { + return true +} diff --git a/server/item/enchantment/piercing.go b/server/item/enchantment/piercing.go new file mode 100644 index 0000000..49b00c3 --- /dev/null +++ b/server/item/enchantment/piercing.go @@ -0,0 +1,40 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// Piercing is an enchantment that allows arrows to damage and pierce through multiple entities, including shields. +var Piercing piercing + +type piercing struct{} + +func (p piercing) Name() string { + return "Piercing" +} + +func (p piercing) MaxLevel() int { + return 4 +} + +func (p piercing) Cost(level int) (int, int) { + return 1 + (level-1)*10, 50 +} + +func (p piercing) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityCommon +} + +func (p piercing) CompatibleWithEnchantment(t item.EnchantmentType) bool { + return t != Multishot +} + +func (p piercing) CompatibleWithItem(i world.Item) bool { + _, ok := i.(item.Crossbow) + return ok +} + +func (p piercing) Pierces() bool { + return true +} diff --git a/server/item/enchantment/power.go b/server/item/enchantment/power.go new file mode 100644 index 0000000..5a93687 --- /dev/null +++ b/server/item/enchantment/power.go @@ -0,0 +1,48 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// Power is a bow enchantment which increases arrow damage. +var Power power + +type power struct{} + +// Name ... +func (power) Name() string { + return "Power" +} + +// MaxLevel ... +func (power) MaxLevel() int { + return 5 +} + +// Cost ... +func (power) Cost(level int) (int, int) { + minCost := 1 + (level-1)*10 + return minCost, minCost + 15 +} + +// Rarity ... +func (power) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityCommon +} + +// PowerDamage returns the extra base damage dealt by the enchantment and level. +func (power) PowerDamage(level int) float64 { + return float64(level+1) * 0.5 +} + +// CompatibleWithEnchantment ... +func (power) CompatibleWithEnchantment(item.EnchantmentType) bool { + return true +} + +// CompatibleWithItem ... +func (power) CompatibleWithItem(i world.Item) bool { + _, ok := i.(item.Bow) + return ok +} diff --git a/server/item/enchantment/projectile_protection.go b/server/item/enchantment/projectile_protection.go new file mode 100644 index 0000000..a21a6d3 --- /dev/null +++ b/server/item/enchantment/projectile_protection.go @@ -0,0 +1,49 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// ProjectileProtection is an armour enchantment that reduces damage from +// projectiles. +var ProjectileProtection projectileProtection + +type projectileProtection struct{} + +// Name ... +func (projectileProtection) Name() string { + return "Projectile Protection" +} + +// MaxLevel ... +func (projectileProtection) MaxLevel() int { + return 4 +} + +// Cost ... +func (projectileProtection) Cost(level int) (int, int) { + minCost := 3 + (level-1)*6 + return minCost, minCost + 6 +} + +// Rarity ... +func (projectileProtection) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityUncommon +} + +// Modifier returns the base protection modifier for the enchantment. +func (projectileProtection) Modifier() float64 { + return 0.08 +} + +// CompatibleWithEnchantment ... +func (projectileProtection) CompatibleWithEnchantment(t item.EnchantmentType) bool { + return t != BlastProtection && t != FireProtection && t != Protection +} + +// CompatibleWithItem ... +func (projectileProtection) CompatibleWithItem(i world.Item) bool { + _, ok := i.(item.Armour) + return ok +} diff --git a/server/item/enchantment/protection.go b/server/item/enchantment/protection.go new file mode 100644 index 0000000..ebcada7 --- /dev/null +++ b/server/item/enchantment/protection.go @@ -0,0 +1,48 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// Protection is an armour enchantment which increases the damage reduction. +var Protection protection + +type protection struct{} + +// Name ... +func (protection) Name() string { + return "Protection" +} + +// MaxLevel ... +func (protection) MaxLevel() int { + return 4 +} + +// Cost ... +func (protection) Cost(level int) (int, int) { + minCost := 1 + (level-1)*11 + return minCost, minCost + 11 +} + +// Rarity ... +func (protection) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityCommon +} + +// Modifier returns the base protection modifier for the enchantment. +func (protection) Modifier() float64 { + return 0.04 +} + +// CompatibleWithEnchantment ... +func (protection) CompatibleWithEnchantment(t item.EnchantmentType) bool { + return t != BlastProtection && t != FireProtection && t != ProjectileProtection +} + +// CompatibleWithItem ... +func (protection) CompatibleWithItem(i world.Item) bool { + _, ok := i.(item.Armour) + return ok +} diff --git a/server/item/enchantment/punch.go b/server/item/enchantment/punch.go new file mode 100644 index 0000000..e330955 --- /dev/null +++ b/server/item/enchantment/punch.go @@ -0,0 +1,48 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// Punch increases the knock-back dealt when hitting a player or mob with a bow. +var Punch punch + +type punch struct{} + +// Name ... +func (punch) Name() string { + return "Punch" +} + +// MaxLevel ... +func (punch) MaxLevel() int { + return 2 +} + +// Cost ... +func (punch) Cost(level int) (int, int) { + minCost := 12 + (level-1)*20 + return minCost, minCost + 25 +} + +// Rarity ... +func (punch) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityRare +} + +// KnockBackMultiplier returns the punch multiplier for the level and horizontal speed. +func (punch) KnockBackMultiplier() float64 { + return 0.25 +} + +// CompatibleWithEnchantment ... +func (punch) CompatibleWithEnchantment(item.EnchantmentType) bool { + return true +} + +// CompatibleWithItem ... +func (punch) CompatibleWithItem(i world.Item) bool { + _, ok := i.(item.Bow) + return ok +} diff --git a/server/item/enchantment/quick_charge.go b/server/item/enchantment/quick_charge.go new file mode 100644 index 0000000..e9f305b --- /dev/null +++ b/server/item/enchantment/quick_charge.go @@ -0,0 +1,49 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "time" +) + +// QuickCharge is an enchantment for quickly reloading a crossbow. +var QuickCharge quickCharge + +type quickCharge struct{} + +// Name ... +func (quickCharge) Name() string { + return "Quick Charge" +} + +// MaxLevel ... +func (quickCharge) MaxLevel() int { + return 3 +} + +// Cost ... +func (quickCharge) Cost(level int) (int, int) { + minCost := 12 + (level-1)*20 + return minCost, 50 +} + +// Rarity ... +func (quickCharge) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityUncommon +} + +// ChargeDuration returns the charge duration. +func (quickCharge) ChargeDuration(level int) time.Duration { + return time.Duration((1.25 - 0.25*float64(level)) * float64(time.Second)) +} + +// CompatibleWithEnchantment ... +func (quickCharge) CompatibleWithEnchantment(item.EnchantmentType) bool { + return true +} + +// CompatibleWithItem ... +func (quickCharge) CompatibleWithItem(i world.Item) bool { + _, ok := i.(item.Crossbow) + return ok +} diff --git a/server/item/enchantment/register.go b/server/item/enchantment/register.go new file mode 100644 index 0000000..8e0b090 --- /dev/null +++ b/server/item/enchantment/register.go @@ -0,0 +1,44 @@ +package enchantment + +import "github.com/df-mc/dragonfly/server/item" + +func init() { + item.RegisterEnchantment(0, Protection) + item.RegisterEnchantment(1, FireProtection) + item.RegisterEnchantment(2, FeatherFalling) + item.RegisterEnchantment(3, BlastProtection) + item.RegisterEnchantment(4, ProjectileProtection) + item.RegisterEnchantment(5, Thorns) + item.RegisterEnchantment(6, Respiration) + item.RegisterEnchantment(7, DepthStrider) + item.RegisterEnchantment(8, AquaAffinity) + item.RegisterEnchantment(9, Sharpness) + // TODO: (10) Smite. (Requires undead mobs) + // TODO: (11) Bane of Arthropods. (Requires arthropod mobs) + item.RegisterEnchantment(12, Knockback) + item.RegisterEnchantment(13, FireAspect) + // TODO: (14) Looting. + item.RegisterEnchantment(15, Efficiency) + item.RegisterEnchantment(16, SilkTouch) + item.RegisterEnchantment(17, Unbreaking) + item.RegisterEnchantment(18, Fortune) + item.RegisterEnchantment(19, Power) + item.RegisterEnchantment(20, Punch) + item.RegisterEnchantment(21, Flame) + item.RegisterEnchantment(22, Infinity) + // TODO: (23) Luck of the Sea. + // TODO: (24) Lure. + // TODO: (25) Frost Walker. + item.RegisterEnchantment(26, Mending) + // TODO: (27) Curse of Binding. + item.RegisterEnchantment(28, CurseOfVanishing) + // TODO: (29) Impaling. + // TODO: (30) Riptide. + // TODO: (31) Loyalty. + // TODO: (32) Channeling. + item.RegisterEnchantment(33, Multishot) + item.RegisterEnchantment(34, Piercing) + item.RegisterEnchantment(35, QuickCharge) + item.RegisterEnchantment(36, SoulSpeed) + item.RegisterEnchantment(37, SwiftSneak) +} diff --git a/server/item/enchantment/respiration.go b/server/item/enchantment/respiration.go new file mode 100644 index 0000000..fb42dd8 --- /dev/null +++ b/server/item/enchantment/respiration.go @@ -0,0 +1,49 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// Respiration extends underwater breathing time by +15 seconds per enchantment +// level in addition to the default time of 15 seconds. +var Respiration respiration + +type respiration struct{} + +// Name ... +func (respiration) Name() string { + return "Respiration" +} + +// MaxLevel ... +func (respiration) MaxLevel() int { + return 3 +} + +// Cost ... +func (respiration) Cost(level int) (int, int) { + minCost := 10 * level + return minCost, minCost + 30 +} + +// Rarity ... +func (respiration) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityRare +} + +// Chance returns the chance of the enchantment blocking the air supply from ticking. +func (respiration) Chance(level int) float64 { + return 1.0 / float64(level+1) +} + +// CompatibleWithEnchantment ... +func (respiration) CompatibleWithEnchantment(item.EnchantmentType) bool { + return true +} + +// CompatibleWithItem ... +func (respiration) CompatibleWithItem(i world.Item) bool { + h, ok := i.(item.HelmetType) + return ok && h.Helmet() +} diff --git a/server/item/enchantment/sharpness.go b/server/item/enchantment/sharpness.go new file mode 100644 index 0000000..0cb6193 --- /dev/null +++ b/server/item/enchantment/sharpness.go @@ -0,0 +1,49 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// Sharpness is an enchantment applied to a sword or axe that increases melee +// damage. +var Sharpness sharpness + +type sharpness struct{} + +// Name ... +func (sharpness) Name() string { + return "Sharpness" +} + +// MaxLevel ... +func (sharpness) MaxLevel() int { + return 5 +} + +// Cost ... +func (sharpness) Cost(level int) (int, int) { + minCost := 1 + (level-1)*11 + return minCost, minCost + 20 +} + +// Rarity ... +func (sharpness) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityCommon +} + +// Addend returns the additional damage when attacking with sharpness. +func (sharpness) Addend(level int) float64 { + return float64(level) * 1.25 +} + +// CompatibleWithEnchantment ... +func (sharpness) CompatibleWithEnchantment(item.EnchantmentType) bool { + return true +} + +// CompatibleWithItem ... +func (sharpness) CompatibleWithItem(i world.Item) bool { + t, ok := i.(item.Tool) + return ok && (t.ToolType() == item.TypeSword || t.ToolType() == item.TypeAxe) +} diff --git a/server/item/enchantment/silk_touch.go b/server/item/enchantment/silk_touch.go new file mode 100644 index 0000000..2b68018 --- /dev/null +++ b/server/item/enchantment/silk_touch.go @@ -0,0 +1,43 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// SilkTouch is an enchantment that allows many blocks to drop themselves +// instead of their usual items when mined. +var SilkTouch silkTouch + +type silkTouch struct{} + +// Name ... +func (silkTouch) Name() string { + return "Silk Touch" +} + +// MaxLevel ... +func (silkTouch) MaxLevel() int { + return 1 +} + +// Cost ... +func (silkTouch) Cost(int) (int, int) { + return 15, 65 +} + +// Rarity ... +func (silkTouch) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityVeryRare +} + +// CompatibleWithEnchantment ... +func (silkTouch) CompatibleWithEnchantment(t item.EnchantmentType) bool { + return t != Fortune +} + +// CompatibleWithItem ... +func (silkTouch) CompatibleWithItem(i world.Item) bool { + t, ok := i.(item.Tool) + return ok && (t.ToolType() != item.TypeSword && t.ToolType() != item.TypeNone) +} diff --git a/server/item/enchantment/soul_speed.go b/server/item/enchantment/soul_speed.go new file mode 100644 index 0000000..7c2e4fa --- /dev/null +++ b/server/item/enchantment/soul_speed.go @@ -0,0 +1,49 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// SoulSpeed is an enchantment that can be applied on boots and allows the +// player to walk more quickly on soul sand or soul soil. +var SoulSpeed soulSpeed + +type soulSpeed struct{} + +// Name ... +func (soulSpeed) Name() string { + return "Soul Speed" +} + +// MaxLevel ... +func (soulSpeed) MaxLevel() int { + return 3 +} + +// Cost ... +func (soulSpeed) Cost(level int) (int, int) { + minCost := level * 10 + return minCost, minCost + 15 +} + +// Rarity ... +func (soulSpeed) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityVeryRare +} + +// Treasure ... +func (soulSpeed) Treasure() bool { + return true +} + +// CompatibleWithEnchantment ... +func (soulSpeed) CompatibleWithEnchantment(item.EnchantmentType) bool { + return true +} + +// CompatibleWithItem ... +func (soulSpeed) CompatibleWithItem(i world.Item) bool { + b, ok := i.(item.BootsType) + return ok && b.Boots() +} diff --git a/server/item/enchantment/swift_sneak.go b/server/item/enchantment/swift_sneak.go new file mode 100644 index 0000000..97619ce --- /dev/null +++ b/server/item/enchantment/swift_sneak.go @@ -0,0 +1,49 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// SwiftSneak is a non-renewable enchantment that can be applied to leggings +// and allows the player to walk more quickly while sneaking. +var SwiftSneak swiftSneak + +type swiftSneak struct{} + +// Name ... +func (swiftSneak) Name() string { + return "Swift Sneak" +} + +// MaxLevel ... +func (swiftSneak) MaxLevel() int { + return 3 +} + +// Cost ... +func (swiftSneak) Cost(level int) (int, int) { + minCost := level * 25 + return minCost, minCost + 50 +} + +// Rarity ... +func (swiftSneak) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityVeryRare +} + +// CompatibleWithEnchantment ... +func (swiftSneak) CompatibleWithEnchantment(item.EnchantmentType) bool { + return true +} + +// Treasure ... +func (swiftSneak) Treasure() bool { + return true +} + +// CompatibleWithItem ... +func (swiftSneak) CompatibleWithItem(i world.Item) bool { + b, ok := i.(item.LeggingsType) + return ok && b.Leggings() +} diff --git a/server/item/enchantment/thorns.go b/server/item/enchantment/thorns.go new file mode 100644 index 0000000..2b5823d --- /dev/null +++ b/server/item/enchantment/thorns.go @@ -0,0 +1,54 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// Thorns is an enchantment that inflicts damage on attackers. +var Thorns thorns + +type thorns struct{} + +// Name ... +func (thorns) Name() string { + return "Thorns" +} + +// MaxLevel ... +func (thorns) MaxLevel() int { + return 3 +} + +// Cost ... +func (thorns) Cost(level int) (int, int) { + minCost := 10 + 20*(level-1) + return minCost, minCost + 50 +} + +// Rarity ... +func (thorns) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityVeryRare +} + +// CompatibleWithEnchantment ... +func (thorns) CompatibleWithEnchantment(item.EnchantmentType) bool { + return true +} + +// CompatibleWithItem ... +func (thorns) CompatibleWithItem(i world.Item) bool { + _, ok := i.(item.Armour) + return ok +} + +// ThornsDamageSource is used for damage caused by thorns. +type ThornsDamageSource struct { + // Owner is the owner of the armour with the thorns enchantment. + Owner world.Entity +} + +func (ThornsDamageSource) ReducedByResistance() bool { return true } +func (ThornsDamageSource) ReducedByArmour() bool { return false } +func (ThornsDamageSource) Fire() bool { return false } +func (ThornsDamageSource) IgnoreTotem() bool { return false } diff --git a/server/item/enchantment/unbreaking.go b/server/item/enchantment/unbreaking.go new file mode 100644 index 0000000..cb235f5 --- /dev/null +++ b/server/item/enchantment/unbreaking.go @@ -0,0 +1,58 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "math/rand/v2" +) + +// Unbreaking is an enchantment that gives a chance for an item to avoid +// durability reduction when it is used, effectively increasing the item's +// durability. +var Unbreaking unbreaking + +type unbreaking struct{} + +// Name ... +func (unbreaking) Name() string { + return "Unbreaking" +} + +// MaxLevel ... +func (unbreaking) MaxLevel() int { + return 3 +} + +// Cost ... +func (unbreaking) Cost(level int) (int, int) { + minCost := 5 + 8*(level-1) + return minCost, minCost + 50 +} + +// Rarity ... +func (unbreaking) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityUncommon +} + +// CompatibleWithEnchantment ... +func (unbreaking) CompatibleWithEnchantment(item.EnchantmentType) bool { + return true +} + +// CompatibleWithItem ... +func (unbreaking) CompatibleWithItem(i world.Item) bool { + _, ok := i.(item.Durable) + return ok +} + +// Reduce returns the amount of damage that should be reduced with unbreaking. +func (unbreaking) Reduce(it world.Item, level, amount int) int { + after := amount + _, ok := it.(item.Armour) + for i := 0; i < amount; i++ { + if (!ok || rand.Float64() >= 0.6) && rand.IntN(level+1) > 0 { + after-- + } + } + return after +} diff --git a/server/item/enchantment/vanishing.go b/server/item/enchantment/vanishing.go new file mode 100644 index 0000000..63df36e --- /dev/null +++ b/server/item/enchantment/vanishing.go @@ -0,0 +1,58 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// CurseOfVanishing is an enchantment that causes the item to disappear on +// death. +var CurseOfVanishing curseOfVanishing + +type curseOfVanishing struct{} + +// Name ... +func (curseOfVanishing) Name() string { + return "Curse of Vanishing" +} + +// MaxLevel ... +func (curseOfVanishing) MaxLevel() int { + return 1 +} + +// Cost ... +func (curseOfVanishing) Cost(int) (int, int) { + return 25, 50 +} + +// Rarity ... +func (curseOfVanishing) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityVeryRare +} + +// CompatibleWithEnchantment ... +func (curseOfVanishing) CompatibleWithEnchantment(_ item.EnchantmentType) bool { + return true +} + +// CompatibleWithItem ... +func (curseOfVanishing) CompatibleWithItem(i world.Item) bool { + _, arm := i.(item.Armour) + _, com := i.(item.Compass) + _, dur := i.(item.Durable) + _, rec := i.(item.RecoveryCompass) + // TODO: Carrot on a Stick + // TODO: Warped Fungus on a Stick + return arm || com || dur || rec +} + +// Treasure ... +func (curseOfVanishing) Treasure() bool { + return true +} + +// Curse ... +func (curseOfVanishing) Curse() bool { + return true +} diff --git a/server/item/enchantment_rarity.go b/server/item/enchantment_rarity.go new file mode 100644 index 0000000..790f3b3 --- /dev/null +++ b/server/item/enchantment_rarity.go @@ -0,0 +1,51 @@ +package item + +// EnchantmentRarity represents an enchantment rarity for enchantments. These rarities may inhibit certain properties, +// such as anvil costs or enchanting table weights. +type EnchantmentRarity interface { + // Name returns the name of the enchantment rarity. + Name() string + // Cost returns the cost of the enchantment rarity. + Cost() int + // Weight returns the weight of the enchantment rarity. + Weight() int +} + +var ( + // EnchantmentRarityCommon represents the common enchantment rarity. + EnchantmentRarityCommon enchantmentRarityCommon + // EnchantmentRarityUncommon represents the uncommon enchantment rarity. + EnchantmentRarityUncommon enchantmentRarityUncommon + // EnchantmentRarityRare represents the rare enchantment rarity. + EnchantmentRarityRare enchantmentRarityRare + // EnchantmentRarityVeryRare represents the very rare enchantment rarity. + EnchantmentRarityVeryRare enchantmentRarityVeryRare +) + +// enchantmentRarityCommon represents the common enchantment rarity. +type enchantmentRarityCommon struct{} + +func (enchantmentRarityCommon) Name() string { return "Common" } +func (enchantmentRarityCommon) Cost() int { return 1 } +func (enchantmentRarityCommon) Weight() int { return 10 } + +// enchantmentRarityUncommon represents the uncommon enchantment rarity. +type enchantmentRarityUncommon struct{} + +func (enchantmentRarityUncommon) Name() string { return "Uncommon" } +func (enchantmentRarityUncommon) Cost() int { return 2 } +func (enchantmentRarityUncommon) Weight() int { return 5 } + +// enchantmentRarityRare represents the rare enchantment rarity. +type enchantmentRarityRare struct{} + +func (enchantmentRarityRare) Name() string { return "Rare" } +func (enchantmentRarityRare) Cost() int { return 4 } +func (enchantmentRarityRare) Weight() int { return 2 } + +// enchantmentRarityVeryRare represents the very rare enchantment rarity. +type enchantmentRarityVeryRare struct{} + +func (enchantmentRarityVeryRare) Name() string { return "Very Rare" } +func (enchantmentRarityVeryRare) Cost() int { return 8 } +func (enchantmentRarityVeryRare) Weight() int { return 1 } diff --git a/server/item/ender_pearl.go b/server/item/ender_pearl.go new file mode 100644 index 0000000..061498a --- /dev/null +++ b/server/item/ender_pearl.go @@ -0,0 +1,36 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "time" +) + +// EnderPearl is a smooth, greenish-blue item used to teleport and to make an eye of ender. +type EnderPearl struct{} + +// Use ... +func (e EnderPearl) Use(tx *world.Tx, user User, ctx *UseContext) bool { + create := tx.World().EntityRegistry().Config().EnderPearl + opts := world.EntitySpawnOpts{Position: eyePosition(user), Velocity: user.Rotation().Vec3().Mul(1.5)} + tx.AddEntity(create(opts, user)) + tx.PlaySound(user.Position(), sound.ItemThrow{}) + + ctx.SubtractFromCount(1) + return true +} + +// Cooldown ... +func (EnderPearl) Cooldown() time.Duration { + return time.Second +} + +// MaxCount ... +func (EnderPearl) MaxCount() int { + return 16 +} + +// EncodeItem ... +func (EnderPearl) EncodeItem() (name string, meta int16) { + return "minecraft:ender_pearl", 0 +} diff --git a/server/item/feather.go b/server/item/feather.go new file mode 100644 index 0000000..58b178c --- /dev/null +++ b/server/item/feather.go @@ -0,0 +1,9 @@ +package item + +// Feather are items dropped by chickens and parrots, as well as tamed cats as morning gifts. +type Feather struct{} + +// EncodeItem ... +func (Feather) EncodeItem() (name string, meta int16) { + return "minecraft:feather", 0 +} diff --git a/server/item/fermented_spider_eye.go b/server/item/fermented_spider_eye.go new file mode 100644 index 0000000..92f0b6d --- /dev/null +++ b/server/item/fermented_spider_eye.go @@ -0,0 +1,9 @@ +package item + +// FermentedSpiderEye is a brewing ingredient. +type FermentedSpiderEye struct{} + +// EncodeItem ... +func (FermentedSpiderEye) EncodeItem() (name string, meta int16) { + return "minecraft:fermented_spider_eye", 0 +} diff --git a/server/item/fire_charge.go b/server/item/fire_charge.go new file mode 100644 index 0000000..b74653b --- /dev/null +++ b/server/item/fire_charge.go @@ -0,0 +1,37 @@ +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/sound" + "github.com/go-gl/mathgl/mgl64" + "math/rand/v2" + "time" +) + +// FireCharge is an item that can be used to place fire when used on a block, or shot from a dispenser to create a small +// fireball. +type FireCharge struct{} + +// EncodeItem ... +func (f FireCharge) EncodeItem() (name string, meta int16) { + return "minecraft:fire_charge", 0 +} + +// UseOnBlock ... +func (f FireCharge) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user User, ctx *UseContext) bool { + if l, ok := tx.Block(pos).(ignitable); ok && l.Ignite(pos, tx, user) { + ctx.SubtractFromCount(1) + tx.PlaySound(pos.Vec3Centre(), sound.FireCharge{}) + return true + } else if s := pos.Side(face); tx.Block(s) == air() { + ctx.SubtractFromCount(1) + tx.PlaySound(s.Vec3Centre(), sound.FireCharge{}) + + flame := fire() + tx.SetBlock(s, flame, nil) + tx.ScheduleBlockUpdate(s, flame, time.Duration(30+rand.IntN(10))*time.Second/20) + return true + } + return false +} diff --git a/server/item/firework.go b/server/item/firework.go new file mode 100644 index 0000000..531bf92 --- /dev/null +++ b/server/item/firework.go @@ -0,0 +1,93 @@ +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/sound" + "github.com/go-gl/mathgl/mgl64" + "math/rand/v2" + "time" +) + +// Firework is an item (and entity) used for creating decorative explosions, boosting when flying with elytra, and +// loading into a crossbow as ammunition. +type Firework struct { + // Duration is the flight duration of the firework. + Duration time.Duration + // Explosions is the list of explosions the firework should create when launched. + Explosions []FireworkExplosion +} + +// Use ... +func (f Firework) Use(tx *world.Tx, user User, ctx *UseContext) bool { + if g, ok := user.(interface { + Gliding() bool + }); !ok || !g.Gliding() { + return false + } + + pos := user.Position() + + tx.PlaySound(pos, sound.FireworkLaunch{}) + create := tx.World().EntityRegistry().Config().Firework + opts := world.EntitySpawnOpts{Position: pos, Rotation: user.Rotation()} + tx.AddEntity(create(opts, f, user, 1.15, 0.04, true)) + + ctx.SubtractFromCount(1) + return true +} + +// UseOnBlock ... +func (f Firework) UseOnBlock(pos cube.Pos, _ cube.Face, clickPos mgl64.Vec3, tx *world.Tx, user User, ctx *UseContext) bool { + fpos := pos.Vec3().Add(clickPos) + create := tx.World().EntityRegistry().Config().Firework + opts := world.EntitySpawnOpts{Position: fpos, Rotation: cube.Rotation{rand.Float64() * 360, 90}} + tx.AddEntity(create(opts, f, user, 1.15, 0.04, false)) + tx.PlaySound(fpos, sound.FireworkLaunch{}) + + ctx.SubtractFromCount(1) + return true +} + +// EncodeNBT ... +func (f Firework) EncodeNBT() map[string]any { + explosions := make([]any, 0, len(f.Explosions)) + for _, explosion := range f.Explosions { + explosions = append(explosions, explosion.EncodeNBT()) + } + return map[string]any{"Fireworks": map[string]any{ + "Explosions": explosions, + "Flight": uint8((f.Duration/10 - time.Millisecond*50).Milliseconds() / 50), + }} +} + +// DecodeNBT ... +func (f Firework) DecodeNBT(data map[string]any) any { + if fireworks, ok := data["Fireworks"].(map[string]any); ok { + if explosions, ok := fireworks["Explosions"].([]any); ok { + f.Explosions = make([]FireworkExplosion, len(explosions)) + for i, explosion := range f.Explosions { + f.Explosions[i] = explosion.DecodeNBT(explosions[i].(map[string]any)).(FireworkExplosion) + } + } + if durationTicks, ok := fireworks["Flight"].(uint8); ok { + f.Duration = (time.Duration(durationTicks)*time.Millisecond*50 + time.Millisecond*50) * 10 + } + } + return f +} + +// RandomisedDuration returns the randomised flight duration of the firework. +func (f Firework) RandomisedDuration() time.Duration { + return f.Duration + time.Duration(rand.IntN(int(time.Millisecond*600))) +} + +// OffHand ... +func (Firework) OffHand() bool { + return true +} + +// EncodeItem ... +func (Firework) EncodeItem() (name string, meta int16) { + return "minecraft:firework_rocket", 0 +} diff --git a/server/item/firework_explosion.go b/server/item/firework_explosion.go new file mode 100644 index 0000000..d52083d --- /dev/null +++ b/server/item/firework_explosion.go @@ -0,0 +1,51 @@ +package item + +// FireworkExplosion represents an explosion of a firework. +type FireworkExplosion struct { + // Shape represents the shape of the explosion. + Shape FireworkShape + // Colour is the colour of the explosion. + Colour Colour + // Fade is the colour the explosion should fade into. Fades must be set to true in order for this to function. + Fade Colour + // Fades is true if the explosion should fade into the fade colour. + Fades bool + // Twinkle is true if the explosion should twinkle on explode. + Twinkle bool + // Trail is true if the explosion should have a trail. + Trail bool +} + +// EncodeNBT ... +func (f FireworkExplosion) EncodeNBT() map[string]any { + data := map[string]any{ + "FireworkType": f.Shape.Uint8(), + "FireworkColor": [1]uint8{uint8(invertColour(f.Colour))}, + "FireworkFade": [0]uint8{}, + "FireworkFlicker": boolByte(f.Twinkle), + "FireworkTrail": boolByte(f.Trail), + } + if f.Fades { + data["FireworkFade"] = [1]uint8{uint8(invertColour(f.Fade))} + } + return data +} + +// DecodeNBT ... +func (f FireworkExplosion) DecodeNBT(data map[string]any) any { + f.Shape = FireworkShapes()[data["FireworkType"].(uint8)] + f.Twinkle = data["FireworkFlicker"].(uint8) == 1 + f.Trail = data["FireworkTrail"].(uint8) == 1 + + colours := data["FireworkColor"] + if diskColour, ok := colours.([1]uint8); ok { + f.Colour = invertColourID(int16(diskColour[0])) + } else if networkColours, ok := colours.([]any); ok { + f.Colour = invertColourID(int16(networkColours[0].(uint8))) + } + + if fades, ok := data["FireworkFade"].([1]uint8); ok { + f.Fade, f.Fades = invertColourID(int16(fades[0])), true + } + return f +} diff --git a/server/item/firework_shape.go b/server/item/firework_shape.go new file mode 100644 index 0000000..b8dc681 --- /dev/null +++ b/server/item/firework_shape.go @@ -0,0 +1,77 @@ +package item + +// FireworkShape represents a shape of a firework. +type FireworkShape struct { + fireworkShape +} + +// FireworkShapeSmallSphere is a small sphere firework. +func FireworkShapeSmallSphere() FireworkShape { + return FireworkShape{0} +} + +// FireworkShapeHugeSphere is a huge sphere firework. +func FireworkShapeHugeSphere() FireworkShape { + return FireworkShape{1} +} + +// FireworkShapeStar is a star firework. +func FireworkShapeStar() FireworkShape { + return FireworkShape{2} +} + +// FireworkShapeCreeperHead is a creeper head firework. +func FireworkShapeCreeperHead() FireworkShape { + return FireworkShape{3} +} + +// FireworkShapeBurst is a burst firework. +func FireworkShapeBurst() FireworkShape { + return FireworkShape{4} +} + +type fireworkShape uint8 + +// Uint8 returns the firework as a uint8. +func (f fireworkShape) Uint8() uint8 { + return uint8(f) +} + +// Name ... +func (f fireworkShape) Name() string { + switch f { + case 0: + return "Small Sphere" + case 1: + return "Huge Sphere" + case 2: + return "Star" + case 3: + return "Creeper Head" + case 4: + return "Burst" + } + panic("unknown firework type") +} + +// String ... +func (f fireworkShape) String() string { + switch f { + case 0: + return "small_sphere" + case 1: + return "huge_sphere" + case 2: + return "star" + case 3: + return "creeper_head" + case 4: + return "burst" + } + panic("unknown firework type") +} + +// FireworkShapes ... +func FireworkShapes() []FireworkShape { + return []FireworkShape{FireworkShapeSmallSphere(), FireworkShapeHugeSphere(), FireworkShapeStar(), FireworkShapeCreeperHead(), FireworkShapeBurst()} +} diff --git a/server/item/firework_star.go b/server/item/firework_star.go new file mode 100644 index 0000000..00843f7 --- /dev/null +++ b/server/item/firework_star.go @@ -0,0 +1,27 @@ +package item + +// FireworkStar is an item used to determine the color, effect, and shape of firework rockets. +type FireworkStar struct { + FireworkExplosion +} + +// EncodeNBT ... +func (f FireworkStar) EncodeNBT() map[string]any { + return map[string]any{ + "FireworksItem": f.FireworkExplosion.EncodeNBT(), + "customColor": int32FromRGBA(f.Colour.RGBA()), + } +} + +// DecodeNBT ... +func (f FireworkStar) DecodeNBT(data map[string]any) any { + if i, ok := data["FireworksItem"].(map[string]any); ok { + f.FireworkExplosion = f.FireworkExplosion.DecodeNBT(i).(FireworkExplosion) + } + return f +} + +// EncodeItem ... +func (f FireworkStar) EncodeItem() (name string, meta int16) { + return "minecraft:firework_star", invertColour(f.Colour) +} diff --git a/server/item/flint.go b/server/item/flint.go new file mode 100644 index 0000000..e992ba8 --- /dev/null +++ b/server/item/flint.go @@ -0,0 +1,9 @@ +package item + +// Flint is an item dropped rarely by gravel. +type Flint struct{} + +// EncodeItem ... +func (Flint) EncodeItem() (name string, meta int16) { + return "minecraft:flint", 0 +} diff --git a/server/item/flint_and_steel.go b/server/item/flint_and_steel.go new file mode 100644 index 0000000..1e139dc --- /dev/null +++ b/server/item/flint_and_steel.go @@ -0,0 +1,71 @@ +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/sound" + "github.com/go-gl/mathgl/mgl64" + "math/rand/v2" + "time" +) + +// FlintAndSteel is an item used to light blocks on fire. +type FlintAndSteel struct{} + +// MaxCount ... +func (f FlintAndSteel) MaxCount() int { + return 1 +} + +// DurabilityInfo ... +func (f FlintAndSteel) DurabilityInfo() DurabilityInfo { + return DurabilityInfo{ + MaxDurability: 65, + BrokenItem: simpleItem(Stack{}), + } +} + +// ignitable represents a block that can be lit by a fire emitter, such as flint and steel. +type ignitable interface { + // Ignite is called when the block is lit by flint and steel. + Ignite(pos cube.Pos, tx *world.Tx, igniter world.Entity) bool +} + +// UseOnBlock ... +func (f FlintAndSteel) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user User, ctx *UseContext) bool { + ctx.DamageItem(1) + if l, ok := tx.Block(pos).(ignitable); ok && l.Ignite(pos, tx, user) { + return true + } else if s := pos.Side(face); tx.Block(s) == air() { + tx.PlaySound(s.Vec3Centre(), sound.Ignite{}) + + flame := fire() + tx.SetBlock(s, flame, nil) + tx.ScheduleBlockUpdate(s, flame, time.Duration(30+rand.IntN(10))*time.Second/20) + return true + } + return false +} + +// EncodeItem ... +func (f FlintAndSteel) EncodeItem() (name string, meta int16) { + return "minecraft:flint_and_steel", 0 +} + +// 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 +} + +// fire returns a fire block. +func fire() world.Block { + f, ok := world.BlockByName("minecraft:fire", map[string]any{"age": int32(0)}) + if !ok { + panic("could not find fire block") + } + return f +} diff --git a/server/item/ghast_tear.go b/server/item/ghast_tear.go new file mode 100644 index 0000000..b38ec2b --- /dev/null +++ b/server/item/ghast_tear.go @@ -0,0 +1,9 @@ +package item + +// GhastTear is a brewing item dropped by ghasts. +type GhastTear struct{} + +// EncodeItem ... +func (GhastTear) EncodeItem() (name string, meta int16) { + return "minecraft:ghast_tear", 0 +} diff --git a/server/item/glass_bottle.go b/server/item/glass_bottle.go new file mode 100644 index 0000000..70eff88 --- /dev/null +++ b/server/item/glass_bottle.go @@ -0,0 +1,41 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// GlassBottle is an item that can hold various liquids. +type GlassBottle struct{} + +// bottleFiller is implemented by blocks that can fill bottles by clicking on them. +type bottleFiller interface { + // FillBottle fills a GlassBottle by interacting with a block. Blocks that implement this interface return both the + // block that should be placed in the world after filling the bottle, and the item that was produced as a result of + // the filling. + // If the bool returned is false, nothing will happen when using a GlassBottle on the block. + FillBottle() (world.Block, Stack, bool) +} + +// UseOnBlock ... +func (g GlassBottle) UseOnBlock(pos cube.Pos, _ cube.Face, _ mgl64.Vec3, tx *world.Tx, _ User, ctx *UseContext) bool { + bl := tx.Block(pos) + if b, ok := bl.(bottleFiller); ok { + var res world.Block + if res, ctx.NewItem, ok = b.FillBottle(); ok { + ctx.SubtractFromCount(1) + if res != bl { + // Some blocks (think a cauldron) change when using a bottle on it. + tx.SetBlock(pos, res, nil) + } + return true + } + } + return false +} + +// EncodeItem ... +func (g GlassBottle) EncodeItem() (name string, meta int16) { + return "minecraft:glass_bottle", 0 +} diff --git a/server/item/glistering_melon_slice.go b/server/item/glistering_melon_slice.go new file mode 100644 index 0000000..9ca37ee --- /dev/null +++ b/server/item/glistering_melon_slice.go @@ -0,0 +1,10 @@ +package item + +// GlisteringMelonSlice is an inedible item used for brewing potions of healing. It is also one of the many potion +// ingredients that can be used to make mundane potions. +type GlisteringMelonSlice struct{} + +// EncodeItem ... +func (GlisteringMelonSlice) EncodeItem() (name string, meta int16) { + return "minecraft:glistering_melon_slice", 0 +} diff --git a/server/item/glowstone_dust.go b/server/item/glowstone_dust.go new file mode 100644 index 0000000..dafd23b --- /dev/null +++ b/server/item/glowstone_dust.go @@ -0,0 +1,9 @@ +package item + +// GlowstoneDust is dropped when breaking the glowstone block. +type GlowstoneDust struct{} + +// EncodeItem ... +func (g GlowstoneDust) EncodeItem() (name string, meta int16) { + return "minecraft:glowstone_dust", 0 +} diff --git a/server/item/goat_horn.go b/server/item/goat_horn.go new file mode 100644 index 0000000..2a6e175 --- /dev/null +++ b/server/item/goat_horn.go @@ -0,0 +1,58 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "time" +) + +// GoatHorn is an item dropped by goats. It has eight variants, and each plays a unique sound when used which can be +// heard by players in a large radius. +type GoatHorn struct { + nopReleasable + + // Type is the type of the goat horn, determining the sound it plays. + Type sound.Horn +} + +// MaxCount ... +func (GoatHorn) MaxCount() int { + return 1 +} + +// Cooldown ... +func (GoatHorn) Cooldown() time.Duration { + return time.Second * 7 +} + +// Use ... +func (g GoatHorn) Use(tx *world.Tx, user User, _ *UseContext) bool { + tx.PlaySound(user.Position(), sound.GoatHorn{Horn: g.Type}) + time.AfterFunc(time.Second, func() { + user.H().ExecWorld(g.releaseItem) + }) + return true +} + +// releaseItem releases the goat horn item if a user is still using it. +func (g GoatHorn) releaseItem(_ *world.Tx, e world.Entity) { + user := e.(User) + if !user.UsingItem() { + // We aren't using the goat horn anymore. + return + } + held, _ := user.HeldItems() + if _, ok := held.Item().(GoatHorn); !ok { + // We aren't holding the goat horn anymore. + return + } + // The goat horn is forcefully released by the server after a second. If the + // client released the item itself, before a second, this shouldn't do + // anything. + user.ReleaseItem() +} + +// EncodeItem ... +func (g GoatHorn) EncodeItem() (name string, meta int16) { + return "minecraft:goat_horn", int16(g.Type.Uint8()) +} diff --git a/server/item/gold_ingot.go b/server/item/gold_ingot.go new file mode 100644 index 0000000..c01c724 --- /dev/null +++ b/server/item/gold_ingot.go @@ -0,0 +1,26 @@ +package item + +import "github.com/sandertv/gophertunnel/minecraft/text" + +// GoldIngot is a metal ingot melted from raw gold or obtained from loot chests. +type GoldIngot struct{} + +// EncodeItem ... +func (GoldIngot) EncodeItem() (name string, meta int16) { + return "minecraft:gold_ingot", 0 +} + +// TrimMaterial ... +func (GoldIngot) TrimMaterial() string { + return "gold" +} + +// MaterialColour ... +func (GoldIngot) MaterialColour() string { + return text.Gold +} + +// PayableForBeacon ... +func (GoldIngot) PayableForBeacon() bool { + return true +} diff --git a/server/item/gold_nugget.go b/server/item/gold_nugget.go new file mode 100644 index 0000000..232fbf3 --- /dev/null +++ b/server/item/gold_nugget.go @@ -0,0 +1,9 @@ +package item + +// GoldNugget is an item used to craft gold ingots & other various gold items. +type GoldNugget struct{} + +// EncodeItem ... +func (GoldNugget) EncodeItem() (name string, meta int16) { + return "minecraft:gold_nugget", 0 +} diff --git a/server/item/golden_apple.go b/server/item/golden_apple.go new file mode 100644 index 0000000..200766d --- /dev/null +++ b/server/item/golden_apple.go @@ -0,0 +1,35 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/world" + "time" +) + +// GoldenApple is a special food item that bestows beneficial effects. +type GoldenApple struct{} + +// AlwaysConsumable ... +func (e GoldenApple) AlwaysConsumable() bool { + return true +} + +// ConsumeDuration ... +func (e GoldenApple) ConsumeDuration() time.Duration { + return DefaultConsumeDuration +} + +// Consume ... +func (e GoldenApple) Consume(_ *world.Tx, c Consumer) Stack { + c.Saturate(4, 9.6) + prev := c.Absorption() + c.AddEffect(effect.New(effect.Absorption, 1, 2*time.Minute)) + c.SetAbsorption(max(prev, min(prev+4, 16))) + c.AddEffect(effect.New(effect.Regeneration, 2, 5*time.Second)) + return Stack{} +} + +// EncodeItem ... +func (e GoldenApple) EncodeItem() (name string, meta int16) { + return "minecraft:golden_apple", 0 +} diff --git a/server/item/golden_carrot.go b/server/item/golden_carrot.go new file mode 100644 index 0000000..0f51e4a --- /dev/null +++ b/server/item/golden_carrot.go @@ -0,0 +1,20 @@ +package item + +import "github.com/df-mc/dragonfly/server/world" + +// GoldenCarrot is a valuable food item and brewing ingredient. It provides the second most saturation in the game, +// behind Suspicious Stew crafted with either a Dandelion or Blue Orchid. +type GoldenCarrot struct { + defaultFood +} + +// Consume ... +func (GoldenCarrot) Consume(_ *world.Tx, c Consumer) Stack { + c.Saturate(6, 14.4) + return Stack{} +} + +// EncodeItem ... +func (GoldenCarrot) EncodeItem() (name string, meta int16) { + return "minecraft:golden_carrot", 0 +} diff --git a/server/item/gunpowder.go b/server/item/gunpowder.go new file mode 100644 index 0000000..10d5f0d --- /dev/null +++ b/server/item/gunpowder.go @@ -0,0 +1,9 @@ +package item + +// Gunpowder is an item that is used for explosion-related recipes. +type Gunpowder struct{} + +// EncodeItem ... +func (Gunpowder) EncodeItem() (name string, meta int16) { + return "minecraft:gunpowder", 0 +} diff --git a/server/item/heart_of_the_sea.go b/server/item/heart_of_the_sea.go new file mode 100644 index 0000000..ed08054 --- /dev/null +++ b/server/item/heart_of_the_sea.go @@ -0,0 +1,9 @@ +package item + +// HeartOfTheSea is a rare item that can be crafted into a conduit. +type HeartOfTheSea struct{} + +// EncodeItem ... +func (HeartOfTheSea) EncodeItem() (name string, meta int16) { + return "minecraft:heart_of_the_sea", 0 +} diff --git a/server/item/helmet.go b/server/item/helmet.go new file mode 100644 index 0000000..9088208 --- /dev/null +++ b/server/item/helmet.go @@ -0,0 +1,140 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/world" + "image/color" +) + +// Helmet is a defensive item that may be worn in the head slot. It comes in several tiers, each with +// different defence points and armour toughness. +type Helmet struct { + // Tier is the tier of the armour. + Tier ArmourTier + // Trim specifies the trim of the armour. + Trim ArmourTrim +} + +// Use handles the using of a helmet to auto-equip it in an armour slot. +func (h Helmet) Use(_ *world.Tx, _ User, ctx *UseContext) bool { + ctx.SwapHeldWithArmour(0) + return false +} + +// MaxCount always returns 1. +func (h Helmet) MaxCount() int { + return 1 +} + +// DefencePoints ... +func (h Helmet) DefencePoints() float64 { + switch h.Tier.Name() { + case "leather": + return 1 + case "copper", "golden", "chainmail", "iron": + return 2 + case "diamond", "netherite": + return 3 + } + panic("invalid helmet tier") +} + +// KnockBackResistance ... +func (h Helmet) KnockBackResistance() float64 { + return h.Tier.KnockBackResistance() +} + +// Toughness ... +func (h Helmet) Toughness() float64 { + return h.Tier.Toughness() +} + +// EnchantmentValue ... +func (h Helmet) EnchantmentValue() int { + return h.Tier.EnchantmentValue() +} + +// DurabilityInfo ... +func (h Helmet) DurabilityInfo() DurabilityInfo { + return DurabilityInfo{ + MaxDurability: int(h.Tier.BaseDurability()), + BrokenItem: simpleItem(Stack{}), + } +} + +// SmeltInfo ... +func (h Helmet) SmeltInfo() SmeltInfo { + switch h.Tier.(type) { + case ArmourTierIron, ArmourTierChain: + return newOreSmeltInfo(NewStack(IronNugget{}, 1), 0.1) + case ArmourTierGold: + return newOreSmeltInfo(NewStack(GoldNugget{}, 1), 0.1) + case ArmourTierCopper: + return newOreSmeltInfo(NewStack(CopperNugget{}, 1), 0.1) + } + return SmeltInfo{} +} + +// RepairableBy ... +func (h Helmet) RepairableBy(i Stack) bool { + return armourTierRepairable(h.Tier)(i) +} + +// Helmet ... +func (h Helmet) Helmet() bool { + return true +} + +// WithTrim ... +func (h Helmet) WithTrim(trim ArmourTrim) world.Item { + h.Trim = trim + return h +} + +// EncodeItem ... +func (h Helmet) EncodeItem() (name string, meta int16) { + return "minecraft:" + h.Tier.Name() + "_helmet", 0 +} + +// DecodeNBT ... +func (h Helmet) DecodeNBT(data map[string]any) any { + if t, ok := h.Tier.(ArmourTierLeather); ok { + if v, ok := data["customColor"].(int32); ok { + t.Colour = rgbaFromInt32(v) + h.Tier = t + } + } + h.Trim = readTrim(data) + return h +} + +// EncodeNBT ... +func (h Helmet) EncodeNBT() map[string]any { + m := map[string]any{} + if t, ok := h.Tier.(ArmourTierLeather); ok && t.Colour != (color.RGBA{}) { + m["customColor"] = int32FromRGBA(t.Colour) + } + writeTrim(m, h.Trim) + return m +} + +func readTrim(m map[string]any) ArmourTrim { + if trim, ok := m["Trim"].(map[string]any); ok { + material, _ := trim["Material"].(string) + pattern, _ := trim["Pattern"].(string) + template, ok := smithingTemplateFromString(pattern) + trimMaterial, ok2 := trimMaterialFromString(material) + if ok && ok2 { + return ArmourTrim{Template: template, Material: trimMaterial} + } + } + return ArmourTrim{} +} + +func writeTrim(m map[string]any, t ArmourTrim) { + if !t.Zero() { + m["Trim"] = map[string]any{ + "Material": t.Material.TrimMaterial(), + "Pattern": t.Template.String(), + } + } +} diff --git a/server/item/hoe.go b/server/item/hoe.go new file mode 100644 index 0000000..0571902 --- /dev/null +++ b/server/item/hoe.go @@ -0,0 +1,115 @@ +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/sound" + "github.com/go-gl/mathgl/mgl64" + "time" +) + +// Hoe is a tool generally used to till dirt and grass blocks into farmland blocks for planting crops. +// Additionally, a Hoe can be used to break certain types of blocks such as Crimson and Hay Blocks. +type Hoe struct { + Tier ToolTier +} + +// UseOnBlock will turn a dirt or grass block into a farmland if the necessary properties are met. +func (h Hoe) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, _ User, ctx *UseContext) bool { + if b, ok := tx.Block(pos).(tillable); ok { + if res, ok := b.Till(); ok { + if face == cube.FaceDown { + // Tilled land isn't created when the bottom face is clicked. + return false + } + if tx.Block(pos.Side(cube.FaceUp)) != air() { + // Tilled land can only be created if air is above the grass block. + return false + } + tx.SetBlock(pos, res, nil) + tx.PlaySound(pos.Vec3(), sound.ItemUseOn{Block: res}) + ctx.DamageItem(1) + return true + } + } + return false +} + +// tillable represents a block that can be tilled by using a hoe on it. +type tillable interface { + // Till returns a block that results from tilling it. If tilling it does not have a result, the bool returned + // is false. + Till() (world.Block, bool) +} + +// MaxCount ... +func (h Hoe) MaxCount() int { + return 1 +} + +// AttackDamage ... +func (h Hoe) AttackDamage() float64 { + return h.Tier.BaseAttackDamage + 1 +} + +// ToolType ... +func (h Hoe) ToolType() ToolType { + return TypeHoe +} + +// HarvestLevel returns the level that this hoe is able to harvest. If a block has a harvest level above +// this one, this hoe won't be able to harvest it. +func (h Hoe) HarvestLevel() int { + return h.Tier.HarvestLevel +} + +// BaseMiningEfficiency ... +func (h Hoe) BaseMiningEfficiency(world.Block) float64 { + return h.Tier.BaseMiningEfficiency +} + +// EnchantmentValue ... +func (h Hoe) EnchantmentValue() int { + return h.Tier.EnchantmentValue +} + +// DurabilityInfo ... +func (h Hoe) DurabilityInfo() DurabilityInfo { + return DurabilityInfo{ + MaxDurability: h.Tier.Durability, + BrokenItem: simpleItem(Stack{}), + AttackDurability: 2, + BreakDurability: 1, + } +} + +// SmeltInfo ... +func (h Hoe) SmeltInfo() SmeltInfo { + switch h.Tier { + case ToolTierIron: + return newOreSmeltInfo(NewStack(IronNugget{}, 1), 0.1) + case ToolTierGold: + return newOreSmeltInfo(NewStack(GoldNugget{}, 1), 0.1) + case ToolTierCopper: + return newOreSmeltInfo(NewStack(CopperNugget{}, 1), 0.1) + } + return SmeltInfo{} +} + +// FuelInfo ... +func (h Hoe) FuelInfo() FuelInfo { + if h.Tier == ToolTierWood { + return newFuelInfo(time.Second * 10) + } + return FuelInfo{} +} + +// RepairableBy ... +func (h Hoe) RepairableBy(i Stack) bool { + return toolTierRepairable(h.Tier)(i) +} + +// EncodeItem ... +func (h Hoe) EncodeItem() (name string, meta int16) { + return "minecraft:" + h.Tier.Name + "_hoe", 0 +} diff --git a/server/item/honey_bottle.go b/server/item/honey_bottle.go new file mode 100644 index 0000000..5219125 --- /dev/null +++ b/server/item/honey_bottle.go @@ -0,0 +1,39 @@ +package item + +import ( + "time" + + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/world" +) + +// HoneyBottle is a food item obtained from full beehives or beenests using a glass bottle. Consuming it +// restores hunger, removes any active poison and returns an empty glass bottle. +type HoneyBottle struct{} + +// MaxCount ... +func (HoneyBottle) MaxCount() int { + return 16 +} + +// AlwaysConsumable ... +func (HoneyBottle) AlwaysConsumable() bool { + return true +} + +// ConsumeDuration ... +func (HoneyBottle) ConsumeDuration() time.Duration { + return DefaultConsumeDuration * 5 / 4 +} + +// Consume ... +func (HoneyBottle) Consume(_ *world.Tx, c Consumer) Stack { + c.Saturate(6, 1.2) + c.RemoveEffect(effect.Poison) + return NewStack(GlassBottle{}, 1) +} + +// EncodeItem ... +func (HoneyBottle) EncodeItem() (name string, meta int16) { + return "minecraft:honey_bottle", 0 +} diff --git a/server/item/honeycomb.go b/server/item/honeycomb.go new file mode 100644 index 0000000..6938a31 --- /dev/null +++ b/server/item/honeycomb.go @@ -0,0 +1,37 @@ +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/sound" + "github.com/go-gl/mathgl/mgl64" +) + +// Honeycomb is an item obtained from bee nests and beehives. +type Honeycomb struct{} + +// UseOnBlock handles the logic of using an ink sac on a sign. Glowing ink sacs turn the text of these signs glowing, +// whereas normal ink sacs revert them back to non-glowing text. +func (Honeycomb) UseOnBlock(pos cube.Pos, _ cube.Face, _ mgl64.Vec3, tx *world.Tx, user User, ctx *UseContext) bool { + if wa, ok := tx.Block(pos).(waxable); ok { + if res, ok := wa.Wax(pos, user.Position()); ok { + tx.SetBlock(pos, res, nil) + tx.PlaySound(pos.Vec3(), sound.SignWaxed{}) + ctx.SubtractFromCount(1) + return true + } + } + return false +} + +// waxable represents a block that may be waxed. +type waxable interface { + // Wax uses an ink sac on the block, returning the resulting block and a bool specifying if waxing the block was + // successful. + Wax(pos cube.Pos, userPos mgl64.Vec3) (world.Block, bool) +} + +// EncodeItem ... +func (Honeycomb) EncodeItem() (name string, meta int16) { + return "minecraft:honeycomb", 0 +} diff --git a/server/item/ink_sac.go b/server/item/ink_sac.go new file mode 100644 index 0000000..f2e5e8d --- /dev/null +++ b/server/item/ink_sac.go @@ -0,0 +1,43 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// InkSac is an item dropped by a squid upon death used to create black dye, dark prismarine and book and quill. The +// glowing variant, obtained by killing a glow squid, may be used to cause sign text to light up. +type InkSac struct { + // Glowing specifies if the ink sac is that of a glow squid. If true, it may be used on a sign to light up its text. + Glowing bool +} + +// UseOnBlock handles the logic of using an ink sac on a sign. Glowing ink sacs turn the text of these signs glowing, +// whereas normal ink sacs revert them back to non-glowing text. +func (i InkSac) UseOnBlock(pos cube.Pos, _ cube.Face, _ mgl64.Vec3, tx *world.Tx, user User, ctx *UseContext) bool { + if in, ok := tx.Block(pos).(inkable); ok { + if res, ok := in.Ink(pos, user.Position(), i.Glowing); ok { + tx.SetBlock(pos, res, nil) + ctx.SubtractFromCount(1) + return true + } + } + return false +} + +// inkable represents a block that may be inked, either glowing or reverted from glowing, by using a (glow) ink sac +// on it. +type inkable interface { + // Ink uses an ink sac on the block, returning the resulting block and a bool specifying if inking the block was + // successful. + Ink(pos cube.Pos, userPos mgl64.Vec3, glowing bool) (world.Block, bool) +} + +// EncodeItem ... +func (i InkSac) EncodeItem() (name string, meta int16) { + if i.Glowing { + return "minecraft:glow_ink_sac", 0 + } + return "minecraft:ink_sac", 0 +} diff --git a/server/item/inventory/armour.go b/server/item/inventory/armour.go new file mode 100644 index 0000000..87ea2d1 --- /dev/null +++ b/server/item/inventory/armour.go @@ -0,0 +1,249 @@ +package inventory + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/enchantment" + "github.com/df-mc/dragonfly/server/world" + "math" + "math/rand/v2" +) + +// Armour represents an inventory for armour. It has 4 slots, one for a helmet, chestplate, leggings and +// boots respectively. NewArmour() must be used to create a valid armour inventory. +// Armour inventories, like normal Inventories, are safe for concurrent usage. +type Armour struct { + inv *Inventory +} + +// NewArmour returns an armour inventory that is ready to be used. The zero value of an inventory.Armour is +// not valid for usage. +// The function passed is called when a slot is changed. It may be nil to not call anything. +func NewArmour(f func(slot int, before, after item.Stack)) *Armour { + inv := New(4, f) + inv.validator = canAddArmour + return &Armour{inv: inv} +} + +// canAddArmour checks if the item passed can be worn as armour in the slot passed. +func canAddArmour(s item.Stack, slot int) bool { + if s.Empty() { + return true + } + switch slot { + case 0: + if h, ok := s.Item().(item.HelmetType); ok { + return h.Helmet() + } + case 1: + if c, ok := s.Item().(item.ChestplateType); ok { + return c.Chestplate() + } + case 2: + if l, ok := s.Item().(item.LeggingsType); ok { + return l.Leggings() + } + case 3: + if b, ok := s.Item().(item.BootsType); ok { + return b.Boots() + } + } + return false +} + +// Set sets all individual pieces of armour in one go. It is equivalent to calling SetHelmet, SetChestplate, SetLeggings +// and SetBoots sequentially. +func (a *Armour) Set(helmet, chestplate, leggings, boots item.Stack) { + a.SetHelmet(helmet) + a.SetChestplate(chestplate) + a.SetLeggings(leggings) + a.SetBoots(boots) +} + +// SetHelmet sets the item stack passed as the helmet in the inventory. +func (a *Armour) SetHelmet(helmet item.Stack) { + _ = a.inv.SetItem(0, helmet) +} + +// Helmet returns the item stack set as helmet in the inventory. +func (a *Armour) Helmet() item.Stack { + i, _ := a.inv.Item(0) + return i +} + +// SetChestplate sets the item stack passed as the chestplate in the inventory. +func (a *Armour) SetChestplate(chestplate item.Stack) { + _ = a.inv.SetItem(1, chestplate) +} + +// Chestplate returns the item stack set as chestplate in the inventory. +func (a *Armour) Chestplate() item.Stack { + i, _ := a.inv.Item(1) + return i +} + +// SetLeggings sets the item stack passed as the leggings in the inventory. +func (a *Armour) SetLeggings(leggings item.Stack) { + _ = a.inv.SetItem(2, leggings) +} + +// Leggings returns the item stack set as leggings in the inventory. +func (a *Armour) Leggings() item.Stack { + i, _ := a.inv.Item(2) + return i +} + +// SetBoots sets the item stack passed as the boots in the inventory. +func (a *Armour) SetBoots(boots item.Stack) { + _ = a.inv.SetItem(3, boots) +} + +// Boots returns the item stack set as boots in the inventory. +func (a *Armour) Boots() item.Stack { + i, _ := a.inv.Item(3) + return i +} + +// DamageReduction returns the amount of damage that is reduced by the Armour for +// an amount of damage and damage source. The value returned takes into account +// the armour itself and its enchantments. +func (a *Armour) DamageReduction(dmg float64, src world.DamageSource) float64 { + var ( + original = dmg + defencePoints, toughness float64 + enchantments []item.Enchantment + ) + + for _, it := range a.Items() { + enchantments = append(enchantments, it.Enchantments()...) + if armour, ok := it.Item().(item.Armour); ok { + defencePoints += armour.DefencePoints() + toughness += armour.Toughness() + } + } + + dmg -= dmg * enchantment.ProtectionFactor(src, enchantments) + if src.ReducedByArmour() { + // Armour in Bedrock edition reduces the damage taken by 4% for each effective armour point. Effective + // armour point decreases as damage increases, with 1 point lost for every 2 HP of damage. The defence + // reduction is decreased by the toughness armour value. Effective armour points will at minimum be 20% of + // armour points. + dmg -= dmg * 0.04 * math.Max(defencePoints*0.2, defencePoints-dmg/(2+toughness/4)) + } + return original - dmg +} + +// HighestEnchantmentLevel looks up the highest level of an item.EnchantmentType +// that any of the Armour items have and returns it, or 0 if none of the items +// have the enchantment. +func (a *Armour) HighestEnchantmentLevel(t item.EnchantmentType) int { + lvl := 0 + for _, it := range a.Items() { + if e, ok := it.Enchantment(t); ok && e.Level() > lvl { + lvl = e.Level() + } + } + return lvl +} + +// DamageFunc is a function that deals d damage points to an item stack s. The +// resulting item.Stack is returned. Depending on the game mode of a player, +// damage may not be dealt at all. +type DamageFunc func(s item.Stack, d int) item.Stack + +// Damage deals damage (hearts) to Armour. The resulting item damage depends on the +// dmg passed and the DamageFunc used. +func (a *Armour) Damage(dmg float64, f DamageFunc) { + armourDamage := int(math.Max(math.Floor(dmg/4), 1)) + for slot, it := range a.Slots() { + _ = a.inv.SetItem(slot, f(it, armourDamage)) + } +} + +// ThornsDamage checks if any of the Armour items are enchanted with thorns. If +// this is the case and the thorns enchantment activates (15% chance per level), +// a random Armour piece is damaged. The damage to be dealt to the attacker is +// returned. +func (a *Armour) ThornsDamage(f DamageFunc) float64 { + slots := a.Slots() + dmg := 0.0 + + for _, i := range slots { + thorns, _ := i.Enchantment(enchantment.Thorns) + if level := float64(thorns.Level()); rand.Float64() < level*0.15 { + // 15%/level chance of thorns activation per item. Total damage from + // normal thorns armour (max thorns III) should never exceed 4.0 in + // total. + dmg = math.Min(dmg+float64(1+rand.IntN(4)), 4.0) + } + } + if highest := a.HighestEnchantmentLevel(enchantment.Thorns); highest > 10 { + // When we find an armour piece with thorns XI or above, the logic + // changes: We have to find the armour piece with the highest level + // of thorns and subtract 10 from its level to calculate the final + // damage. + dmg = float64(highest - 10) + } + if dmg > 0 { + // Deal 2 damage to one random thorns item. Bedrock Edition and Java Edition + // both have different behaviour here and neither seem to match the expected + // behaviour. Java Edition deals 2 damage to a random thorns item for every + // thorns armour item worn, while Bedrock Edition deals 1 additional damage + // for every thorns item and another 2 for every thorns item when it + // activates. + slot := rand.IntN(len(slots)) + _ = a.Inventory().SetItem(slot, f(slots[slot], 2)) + } + return dmg +} + +// KnockBackResistance returns the combined knock back resistance of all Armour +// items. A value of 0 means normal knock back force, while a value of 1 means +// all knock back is ignored. +func (a *Armour) KnockBackResistance() float64 { + resistance := 0.0 + for _, i := range a.Items() { + if a, ok := i.Item().(item.Armour); ok { + resistance += a.KnockBackResistance() + } + } + return resistance +} + +// Slots returns all items (including) air of the armour inventory in the order of helmet, chestplate, leggings, +// boots. +func (a *Armour) Slots() []item.Stack { + return a.inv.Slots() +} + +// Items returns a slice of all non-empty armour items equipped. +func (a *Armour) Items() []item.Stack { + return a.inv.Items() +} + +// Clear clears the armour inventory, removing all items currently present. +func (a *Armour) Clear() []item.Stack { + return a.inv.Clear() +} + +// String converts the armour to a readable string representation. +func (a *Armour) String() string { + return fmt.Sprintf("(helmet: %v, chestplate: %v, leggings: %v, boots: %v)", a.Helmet(), a.Chestplate(), a.Leggings(), a.Boots()) +} + +// Inventory returns the underlying Inventory instance. +func (a *Armour) Inventory() *Inventory { + return a.inv +} + +// Handle assigns a Handler to an Armour inventory so that its methods are called for the respective events. Nil may be +// passed to set the default NopHandler. +// Handle is the equivalent of calling (*Armour).Inventory().H. +func (a *Armour) Handle(h Handler) { + a.inv.Handle(h) +} + +// Close closes the armour inventory, removing the slot change function. +func (a *Armour) Close() error { + return a.inv.Close() +} diff --git a/server/item/inventory/handler.go b/server/item/inventory/handler.go new file mode 100644 index 0000000..f91eb16 --- /dev/null +++ b/server/item/inventory/handler.go @@ -0,0 +1,33 @@ +package inventory + +import ( + "github.com/df-mc/dragonfly/server/event" + "github.com/df-mc/dragonfly/server/item" +) + +type Holder interface{} + +type Context = event.Context[Holder] + +// Handler is a type that may be used to handle actions performed on an inventory by a player. +type Handler interface { + // HandleTake handles an item.Stack being taken from a slot in the inventory. This item might be the whole stack or + // part of the stack currently present in that slot. + HandleTake(ctx *Context, slot int, it item.Stack) + // HandlePlace handles an item.Stack being placed in a slot of the inventory. It might either be added to an empty + // slot or a slot that contains an item of the same type. + HandlePlace(ctx *Context, slot int, it item.Stack) + // HandleDrop handles the dropping of an item.Stack in a slot out of the inventory. + HandleDrop(ctx *Context, slot int, it item.Stack) +} + +// Check to make sure NopHandler implements Handler. +var _ Handler = NopHandler{} + +// NopHandler is an implementation of Handler that does not run any code in any of its methods. It is the default +// Handler of an Inventory. +type NopHandler struct{} + +func (NopHandler) HandleTake(*Context, int, item.Stack) {} +func (NopHandler) HandlePlace(*Context, int, item.Stack) {} +func (NopHandler) HandleDrop(*Context, int, item.Stack) {} diff --git a/server/item/inventory/inventory.go b/server/item/inventory/inventory.go new file mode 100644 index 0000000..9de0d4c --- /dev/null +++ b/server/item/inventory/inventory.go @@ -0,0 +1,433 @@ +package inventory + +import ( + "errors" + "fmt" + "github.com/df-mc/dragonfly/server/item" + "math" + "slices" + "strings" + "sync" +) + +// Inventory represents an inventory containing items. These inventories may be carried by entities or may be +// held by blocks such as chests. +// The size of an inventory may be specified upon construction, but cannot be changed after. The zero value of +// an inventory is invalid. Use New() to obtain a new inventory. +// Inventory is safe for concurrent usage: Its values are protected by a mutex. +type Inventory struct { + mu sync.RWMutex + h Handler + slots []item.Stack + + f SlotFunc + validator SlotValidatorFunc +} + +// SlotFunc is a function called for each item changed in an Inventory. +type SlotFunc func(slot int, before, after item.Stack) + +// SlotValidatorFunc is a function that limits changes in the Inventory slot. +type SlotValidatorFunc func(s item.Stack, slot int) bool + +// ErrSlotOutOfRange is returned by any methods on inventory when a slot is passed which is not within the +// range of valid values for the inventory. +var ErrSlotOutOfRange = errors.New("slot is out of range: must be in range 0 <= slot < inventory.Size()") + +// New creates a new inventory with the size passed. The inventory size cannot be changed after it has been +// constructed. +// A function may be passed which is called every time a slot is changed. The function may also be nil, if +// nothing needs to be done. +func New(size int, f SlotFunc) *Inventory { + if size <= 0 { + panic("inventory size must be at least 1") + } + if f == nil { + f = func(slot int, before, after item.Stack) {} + } + return &Inventory{h: NopHandler{}, slots: make([]item.Stack, size), f: f, validator: func(s item.Stack, slot int) bool { return true }} +} + +// Clone copies an Inventory and returns it, calling the SlotFunc passed for any +// slots changed in the new inventory. +func (inv *Inventory) Clone(f SlotFunc) *Inventory { + if f == nil { + f = func(slot int, before, after item.Stack) {} + } + return &Inventory{h: NopHandler{}, slots: inv.Slots(), f: f, validator: func(s item.Stack, slot int) bool { return true }} +} + +// SlotFunc changes the function called when a slot in the inventory is changed. +func (inv *Inventory) SlotFunc(f SlotFunc) { + inv.mu.Lock() + defer inv.mu.Unlock() + inv.f = f +} + +// SlotValidatorFunc changes the function that limits item placement in the inventory slot. +func (inv *Inventory) SlotValidatorFunc(f SlotValidatorFunc) { + inv.mu.Lock() + defer inv.mu.Unlock() + inv.validator = f +} + +// Item attempts to obtain an item from a specific slot in the inventory. If an item was present in that slot, +// the item is returned and the error is nil. If no item was present in the slot, a Stack with air as its item +// and a count of 0 is returned. Stack.Empty() may be called to check if this is the case. +// Item only returns an error if the slot passed is out of range. (0 <= slot < inventory.Size()) +func (inv *Inventory) Item(slot int) (item.Stack, error) { + inv.mu.RLock() + defer inv.mu.RUnlock() + + inv.check() + if !inv.validSlot(slot) { + return item.Stack{}, ErrSlotOutOfRange + } + return inv.slots[slot], nil +} + +// SetItem sets a stack of items to a specific slot in the inventory. If an item is already present in the +// slot, that item will be overwritten. +// SetItem will return an error if the slot passed is out of range. (0 <= slot < inventory.Size()) +func (inv *Inventory) SetItem(slot int, item item.Stack) error { + inv.mu.Lock() + + inv.check() + if !inv.validSlot(slot) { + inv.mu.Unlock() + return ErrSlotOutOfRange + } + f := inv.setItem(slot, item) + + inv.mu.Unlock() + + f() + return nil +} + +// Slots returns the all slots in the inventory as a slice. The index in the slice is the slot of the inventory that a +// specific item.Stack is in. Note that this item.Stack might be empty. +func (inv *Inventory) Slots() []item.Stack { + inv.mu.RLock() + defer inv.mu.RUnlock() + return slices.Clone(inv.slots) +} + +// Items returns a list of all contents of the inventory. This method excludes air items, so the method +// only ever returns item stacks which actually represent an item. +func (inv *Inventory) Items() []item.Stack { + inv.mu.RLock() + defer inv.mu.RUnlock() + + items := make([]item.Stack, 0, len(inv.slots)) + for _, it := range inv.slots { + if !it.Empty() { + items = append(items, it) + } + } + return items +} + +// First returns the first slot with an item if found. Second return value describes whether the item was found. +func (inv *Inventory) First(item item.Stack) (int, bool) { + return inv.FirstFunc(item.Comparable) +} + +// FirstFunc finds the first slot with an item.Stack that results in the comparable function passed returning true. The +// function returns false if no such item was found. +func (inv *Inventory) FirstFunc(comparable func(stack item.Stack) bool) (int, bool) { + for slot, it := range inv.Slots() { + if !it.Empty() && comparable(it) { + return slot, true + } + } + return -1, false +} + +// FirstEmpty returns the first empty slot if found. Second return value describes whether an empty slot was found. +func (inv *Inventory) FirstEmpty() (int, bool) { + for slot, it := range inv.Slots() { + if it.Empty() { + return slot, true + } + } + return -1, false +} + +// Swap swaps the items between two slots. Returns an error if either slot A or B are invalid. +func (inv *Inventory) Swap(slotA, slotB int) error { + inv.mu.Lock() + + inv.check() + if !inv.validSlot(slotA) || !inv.validSlot(slotB) { + inv.mu.Unlock() + return ErrSlotOutOfRange + } + a, b := inv.slots[slotA], inv.slots[slotB] + fa, fb := inv.setItem(slotA, b), inv.setItem(slotB, a) + + inv.mu.Unlock() + + fa() + fb() + return nil +} + +// AddItem attempts to add an item to the inventory. It does so in a couple of steps: It first iterates over +// the inventory to make sure no existing stacks of the same type exist. If these stacks do exist, the item +// added is first added on top of those stacks to make sure they are fully filled. +// If no existing stacks with leftover space are left, empty slots will be filled up with the remainder of the +// item added. +// If the item could not be fully added to the inventory, an error is returned along with the count that was +// added to the inventory. +func (inv *Inventory) AddItem(it item.Stack) (n int, err error) { + if it.Empty() { + return 0, nil + } + first := it.Count() + emptySlots := make([]int, 0, 16) + + inv.mu.Lock() + + inv.check() + for slot, invIt := range inv.slots { + if invIt.Empty() { + // This slot was empty, and we should first try to add the item stack to existing stacks. + emptySlots = append(emptySlots, slot) + continue + } + a, b := invIt.AddStack(it) + if it.Count() == b.Count() { + // Count stayed the same, meaning this slot either wasn't equal to this stack or was max size. + continue + } + f := inv.setItem(slot, a) + //noinspection GoDeferInLoop + defer f() + + if it = b; it.Empty() { + inv.mu.Unlock() + // We were able to add the entire stack to existing stacks in the inventory. + return first, nil + } + } + for _, slot := range emptySlots { + a, b := it.Grow(-math.MaxInt32).AddStack(it) + + f := inv.setItem(slot, a) + //noinspection GoDeferInLoop + defer f() + + if it = b; it.Empty() { + inv.mu.Unlock() + // We were able to add the entire stack to empty slots. + return first, nil + } + } + inv.mu.Unlock() + // We were unable to clear out the entire stack to be added to the inventory: There wasn't enough space. + return first - it.Count(), fmt.Errorf("could not add full item stack to inventory") +} + +// RemoveItem attempts to remove an item from the inventory. It will visit all slots in the inventory and +// empties them until it.Count() items have been removed from the inventory. +// If less than it.Count() items were removed from the inventory, an error is returned. +func (inv *Inventory) RemoveItem(it item.Stack) error { + return inv.RemoveItemFunc(it.Count(), it.Comparable) +} + +// RemoveItemFunc removes up to n items from the Inventory. It will visit all slots in the inventory and empties them +// until n items have been removed from the inventory, assuming the comparable function returns true for the slots +// visited. No items will be deducted from slots if the comparable function returns false. +// If less than n items were removed, an error is returned. +func (inv *Inventory) RemoveItemFunc(n int, comparable func(stack item.Stack) bool) error { + inv.mu.Lock() + inv.check() + for slot, slotIt := range inv.slots { + if slotIt.Empty() || !comparable(slotIt) { + continue + } + c := slotIt.Count() - n + + var f func() + if c <= 0 { + f = inv.setItem(slot, item.Stack{}) + } else { + f = inv.setItem(slot, slotIt.Grow(-n)) + } + + //noinspection GoDeferInLoop + defer f() + + if n -= slotIt.Count(); n <= 0 { + break + } + } + inv.mu.Unlock() + + if n > 0 { + return fmt.Errorf("could not remove all items from the inventory") + } + return nil +} + +// ContainsItem checks if the Inventory contains an item.Stack. It will visit all slots in the Inventory until it finds +// at enough items. If enough were found, true is returned. +func (inv *Inventory) ContainsItem(it item.Stack) bool { + return inv.ContainsItemFunc(it.Count(), it.Comparable) +} + +// ContainsItemFunc checks if the Inventory contains at least n items. It will visit all slots in the Inventory until it +// finds n items on which the comparable function returns true. ContainsItemFunc returns true if this is the case. +func (inv *Inventory) ContainsItemFunc(n int, comparable func(stack item.Stack) bool) bool { + inv.mu.Lock() + defer inv.mu.Unlock() + + inv.check() + for _, slotIt := range inv.slots { + if !slotIt.Empty() && comparable(slotIt) { + if n -= slotIt.Count(); n <= 0 { + break + } + } + } + return n <= 0 +} + +// Merge merges two inventories into one. The function passed is called for every slot change in the new inventory. +func (inv *Inventory) Merge(inv2 *Inventory, f func(int, item.Stack, item.Stack)) *Inventory { + inv.mu.RLock() + defer inv.mu.RUnlock() + inv2.mu.RLock() + defer inv2.mu.RUnlock() + + n := New(len(inv.slots)+len(inv2.slots), f) + n.slots = make([]item.Stack, 0, len(inv.slots)+len(inv2.slots)) + n.slots = append(n.slots, inv.slots...) + n.slots = append(n.slots, inv2.slots...) + return n +} + +// Empty checks if the inventory is fully empty: It iterates over the inventory and makes sure every stack in +// it is empty. +func (inv *Inventory) Empty() bool { + inv.mu.RLock() + defer inv.mu.RUnlock() + + inv.check() + for _, it := range inv.slots { + if !it.Empty() { + return false + } + } + return true +} + +// Clear clears the entire inventory. All non-zero items are returned. +func (inv *Inventory) Clear() []item.Stack { + inv.mu.Lock() + + inv.check() + + items := make([]item.Stack, 0, inv.size()) + for slot, i := range inv.slots { + if !i.Empty() { + items = append(items, i) + f := inv.setItem(slot, item.Stack{}) + //noinspection GoDeferInLoop + defer f() + } + } + inv.mu.Unlock() + + return items +} + +// Handle assigns a Handler to an Inventory so that its methods are called for the respective events. Nil may be passed +// to set the default NopHandler. +func (inv *Inventory) Handle(h Handler) { + inv.mu.Lock() + defer inv.mu.Unlock() + + inv.check() + if h == nil { + h = NopHandler{} + } + inv.h = h +} + +// Handler returns the Handler currently assigned to the Inventory. This is the NopHandler by default. +func (inv *Inventory) Handler() Handler { + inv.mu.RLock() + defer inv.mu.RUnlock() + + inv.check() + return inv.h +} + +// setItem sets an item to a specific slot and overwrites the existing item. It calls the function which is +// called for every item change and does so without locking the inventory. +func (inv *Inventory) setItem(slot int, it item.Stack) func() { + if !inv.validator(it, slot) { + return func() {} + } + if it.Count() > it.MaxCount() { + it = it.Grow(it.MaxCount() - it.Count()) + } + before := inv.slots[slot] + inv.slots[slot] = it + return func() { + inv.f(slot, before, it) + } +} + +// Size returns the size of the inventory. It is always the same value as that passed in the call to New() and +// is always at least 1. +func (inv *Inventory) Size() int { + inv.mu.RLock() + defer inv.mu.RUnlock() + return inv.size() +} + +// size returns the size of the inventory without locking. +func (inv *Inventory) size() int { + return len(inv.slots) +} + +// Close closes the inventory, freeing the function called for every slot change. It also clears any items +// that may currently be in the inventory. +// The returned error is always nil. +func (inv *Inventory) Close() error { + inv.mu.Lock() + defer inv.mu.Unlock() + + inv.check() + inv.f = func(int, item.Stack, item.Stack) {} + return nil +} + +// String implements the fmt.Stringer interface. +func (inv *Inventory) String() string { + inv.mu.RLock() + defer inv.mu.RUnlock() + + s := make([]string, 0, inv.size()) + for _, it := range inv.slots { + s = append(s, it.String()) + } + return "(" + strings.Join(s, ", ") + ")" +} + +// validSlot checks if the slot passed is valid for the inventory. It returns false if the slot is either +// smaller than 0 or bigger/equal to the size of the inventory's size. +func (inv *Inventory) validSlot(slot int) bool { + return slot >= 0 && slot < inv.size() +} + +// check panics if the inventory is valid, and panics if it is not. This typically happens if the inventory +// was not created using New(). +func (inv *Inventory) check() { + if inv.size() == 0 { + panic("uninitialised inventory: inventory must be constructed using inventory.New()") + } +} diff --git a/server/item/iron_ingot.go b/server/item/iron_ingot.go new file mode 100644 index 0000000..5928704 --- /dev/null +++ b/server/item/iron_ingot.go @@ -0,0 +1,26 @@ +package item + +import "github.com/sandertv/gophertunnel/minecraft/text" + +// IronIngot is a metal ingot melted from raw iron or obtained from loot chests. +type IronIngot struct{} + +// EncodeItem ... +func (IronIngot) EncodeItem() (name string, meta int16) { + return "minecraft:iron_ingot", 0 +} + +// TrimMaterial ... +func (IronIngot) TrimMaterial() string { + return "iron" +} + +// MaterialColour ... +func (IronIngot) MaterialColour() string { + return text.Iron +} + +// PayableForBeacon ... +func (IronIngot) PayableForBeacon() bool { + return true +} diff --git a/server/item/iron_nugget.go b/server/item/iron_nugget.go new file mode 100644 index 0000000..a3d06c9 --- /dev/null +++ b/server/item/iron_nugget.go @@ -0,0 +1,9 @@ +package item + +// IronNugget is a piece of iron that can be obtained by smelting iron tools/weapons or iron/chainmail armour. +type IronNugget struct{} + +// EncodeItem ... +func (IronNugget) EncodeItem() (name string, meta int16) { + return "minecraft:iron_nugget", 0 +} diff --git a/server/item/item.go b/server/item/item.go new file mode 100644 index 0000000..33a503c --- /dev/null +++ b/server/item/item.go @@ -0,0 +1,272 @@ +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" +) + +// MaxCounter represents an item that has a specific max count. By default, each item will be expected to have +// a maximum count of 64. MaxCounter may be implemented to change this behaviour. +type MaxCounter interface { + // MaxCount returns the maximum number of items that a stack may be composed of. The number returned must + // be positive. + MaxCount() int +} + +// UsableOnBlock represents an item that may be used on a block. If an item implements this interface, the +// UseOnBlock method is called whenever the item is used on a block. +type UsableOnBlock interface { + // UseOnBlock is called when an item is used on a block. The world passed is the world that the item was + // used in. The user passed is the entity that used the item. Usually this entity is a player. + // The position of the block that was clicked, along with the clicked face and the position clicked + // relative to the corner of the block are passed. + // UseOnBlock returns a bool indicating if the item was used successfully. + UseOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3, tx *world.Tx, user User, ctx *UseContext) bool +} + +// UsableOnEntity represents an item that may be used on an entity. If an item implements this interface, the +// UseOnEntity method is called whenever the item is used on an entity. +type UsableOnEntity interface { + // UseOnEntity is called when an item is used on an entity. The world passed is the world that the item is + // used in, and the entity clicked and the user of the item are also passed. + // UseOnEntity returns a bool indicating if the item was used successfully. + UseOnEntity(e world.Entity, tx *world.Tx, user User, ctx *UseContext) bool +} + +// Usable represents an item that may be used 'in the air'. If an item implements this interface, the Use +// method is called whenever the item is used while pointing at the air. (For example, when throwing an egg.) +type Usable interface { + // Use is called when the item is used in the air. The user that used the item and the world that the item + // was used in are passed to the method. + // Use returns a bool indicating if the item was used successfully. + Use(tx *world.Tx, user User, ctx *UseContext) bool +} + +// Throwable represents a custom item that can be thrown such as a projectile. This will only have an effect on +// non-vanilla items. +type Throwable interface { + // SwingAnimation returns true if the client should cause the player's arm to swing when the item is thrown. + SwingAnimation() bool +} + +// OffHand represents an item that can be held in the off hand. +type OffHand interface { + // OffHand returns true if the item can be held in the off hand. + OffHand() bool +} + +// Consumable represents an item that may be consumed by a player. If an item implements this interface, a player +// may use and hold the item to consume it. +type Consumable interface { + // AlwaysConsumable specifies if the item is always consumable. Normal food can generally only be consumed + // when the food bar is not full or when in creative mode. Returning true here means the item can always + // be consumed, like golden apples or potions. + AlwaysConsumable() bool + // ConsumeDuration is the duration consuming the item takes. If the player is using the item for at least + // this duration, the item will be consumed and have its Consume method called. + ConsumeDuration() time.Duration + // Consume consumes one item of the Stack that the Consumable is in. The Stack returned is added back to + // the inventory after consuming the item. For potions, for example, an empty bottle is returned. + Consume(tx *world.Tx, c Consumer) Stack +} + +// Consumer represents a User that is able to consume Consumable items. +type Consumer interface { + User + // Saturate saturates the Consumer's food bar by the amount of food points passed and the saturation by + // up to as many saturation points as passed. The final saturation will never exceed the final food level. + Saturate(food int, saturation float64) + // AddEffect adds an effect.Effect to the Consumer. If the effect is instant, it is applied to the Consumer + // immediately. If not, the effect is applied to the consumer every time the Tick method is called. + // AddEffect will overwrite any effects present if the level of the effect is higher than the existing one, or + // if the effects' levels are equal and the new effect has a longer duration. + AddEffect(e effect.Effect) + // RemoveEffect removes any effect that might currently be active on the Consumer. + RemoveEffect(e effect.Type) + // Effects returns any effect currently applied to the Consumer. The returned effects are guaranteed not to have + // expired when returned. + Effects() []effect.Effect + // Absorption returns the absorption health of the Consumer. + Absorption() float64 + // SetAbsorption sets the absorption health of the Consumer. Absorption health is shown as golden hearts and does + // not regenerate naturally. + SetAbsorption(health float64) +} + +// DefaultConsumeDuration is the default duration that consuming an item takes. Dried kelp takes half this +// time to be consumed. +const DefaultConsumeDuration = (time.Second * 161) / 100 + +// Drinkable represents a custom item that can be drunk. It is used to make the client show the correct drinking +// animation when a player is using an item. This will only have an effect on non-vanilla items. +type Drinkable interface { + // Drinkable returns if the item can be drunk or not. + Drinkable() bool +} + +// Glinted represents a custom item that can have a permanent enchantment glint, this glint is purely cosmetic and +// will show regardless of whether it is actually enchanted. An example of this is the enchanted golden apple. +type Glinted interface { + // Glinted returns whether the item has an enchantment glint. + Glinted() bool +} + +// HandEquipped represents an item that can be 'hand equipped'. This means the item will show up in third person like +// a tool, sword or stick would, giving them a different orientation in the hand and making them slightly bigger. +type HandEquipped interface { + // HandEquipped returns whether the item is hand equipped. + HandEquipped() bool +} + +// Weapon is an item that may be used as a weapon. It has an attack damage which may be different to the 2 +// damage that attacking with an empty hand deals. +type Weapon interface { + // AttackDamage returns the custom attack damage to the weapon. The damage returned must not be negative. + AttackDamage() float64 +} + +// Cooldown represents an item that has a cooldown. +type Cooldown interface { + // Cooldown is the duration of the cooldown. + Cooldown() time.Duration +} + +// nameable represents a block that may be named. These are often containers such as chests, which have a +// name displayed in their interface. +type nameable interface { + // WithName returns the block itself, except with a custom name applied to it. + WithName(a ...any) world.Item +} + +// Releaser represents an entity that can release items, such as bows. +type Releaser interface { + User + // GameMode returns the gamemode of the releaser. + GameMode() world.GameMode + // PlaySound plays a world.Sound that only this Releaser can hear. + PlaySound(sound world.Sound) +} + +// Releasable represents an item that can be released. +type Releasable interface { + // Release is called when an item is released. + Release(releaser Releaser, tx *world.Tx, ctx *UseContext, duration time.Duration) + // Requirements returns the required items to release this item. + Requirements() []Stack +} + +// Chargeable represents an item that can be charged. +type Chargeable interface { + // Charge is called when an item is being used. + Charge(releaser Releaser, tx *world.Tx, ctx *UseContext, duration time.Duration) bool + // ContinueCharge continues the charge. + ContinueCharge(releaser Releaser, tx *world.Tx, ctx *UseContext, duration time.Duration) + // ReleaseCharge is called when an item is being released. + ReleaseCharge(releaser Releaser, tx *world.Tx, ctx *UseContext) bool + // 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, +// which interact with the world using an item. +type User interface { + Carrier + SetHeldItems(mainHand, offHand Stack) + + UsingItem() bool + ReleaseItem() + UseItem() +} + +// Carrier represents an entity that is able to carry an item. +type Carrier interface { + world.Entity + // HeldItems returns the items currently held by the entity. Viewers of the entity will be able to see + // these items. + HeldItems() (mainHand, offHand Stack) +} + +// BeaconPayment represents an item that may be used as payment for a beacon to select effects to be broadcast +// to surrounding players. +type BeaconPayment interface { + PayableForBeacon() bool +} + +// Compostable represents an item that may be used to fill up a composter. +type Compostable interface { + // CompostChance returns the chance the item will produce a layer of compost in the range of 0-1. + CompostChance() float64 +} + +// nopReleasable represents a releasable item that does nothing. +type nopReleasable struct{} + +func (nopReleasable) Release(Releaser, *world.Tx, *UseContext, time.Duration) {} +func (nopReleasable) Requirements() []Stack { + return []Stack{} +} + +// defaultFood represents a consumable item with a default consumption duration. +type defaultFood struct{} + +// AlwaysConsumable ... +func (defaultFood) AlwaysConsumable() bool { + return false +} + +// ConsumeDuration ... +func (d defaultFood) ConsumeDuration() time.Duration { + return DefaultConsumeDuration +} + +// eyePosition returns the position of the eyes of the entity if the entity implements entity.Eyed, or the +// actual position if it doesn't. +func eyePosition(e world.Entity) mgl64.Vec3 { + pos := e.Position() + if eyed, ok := e.(interface{ EyeHeight() float64 }); ok { + pos = pos.Add(mgl64.Vec3{0, eyed.EyeHeight()}) + } + return pos +} + +// torsoPosition returns the position of the torso of the entity if the entity implements entity.Torsoed, or the +// actual position if it doesn't. +func torsoPosition(e world.Entity) mgl64.Vec3 { + pos := e.Position() + if torso, ok := e.(interface{ TorsoHeight() float64 }); ok { + pos = pos.Add(mgl64.Vec3{0, torso.TorsoHeight()}) + } + return pos +} + +// Int32FromRGBA converts a color.RGBA into an int32. These int32s are present in things such as signs and dyed leather armour. +func int32FromRGBA(x color.RGBA) int32 { + if x.R == 0 && x.G == 0 && x.B == 0 { + // Default to black colour. The default (0x000000) is a transparent colour. Text with this colour will not show + // up on the sign. + return int32(-0x1000000) + } + return int32(binary.BigEndian.Uint32([]byte{x.A, x.R, x.G, x.B})) +} + +// rgbaFromInt32 converts an int32 into a color.RGBA. These int32s are present in things such as signs and dyed leather armour. +func rgbaFromInt32(x int32) color.RGBA { + b := make([]byte, 4) + binary.BigEndian.PutUint32(b, uint32(x)) + + return color.RGBA{A: b[0], R: b[1], G: b[2], B: b[3]} +} + +// boolByte returns 1 if the bool passed is true, or 0 if it is false. +func boolByte(b bool) uint8 { + if b { + return 1 + } + return 0 +} diff --git a/server/item/lapis_lazuli.go b/server/item/lapis_lazuli.go new file mode 100644 index 0000000..1c97c43 --- /dev/null +++ b/server/item/lapis_lazuli.go @@ -0,0 +1,21 @@ +package item + +import "github.com/sandertv/gophertunnel/minecraft/text" + +// LapisLazuli is a mineral used for enchanting and decoration. +type LapisLazuli struct{} + +// EncodeItem ... +func (LapisLazuli) EncodeItem() (name string, meta int16) { + return "minecraft:lapis_lazuli", 0 +} + +// TrimMaterial ... +func (LapisLazuli) TrimMaterial() string { + return "lapis" +} + +// MaterialColour ... +func (LapisLazuli) MaterialColour() string { + return text.Lapis +} diff --git a/server/item/leather.go b/server/item/leather.go new file mode 100644 index 0000000..c2492d0 --- /dev/null +++ b/server/item/leather.go @@ -0,0 +1,9 @@ +package item + +// Leather is an animal skin used to make item frames, armour and books. +type Leather struct{} + +// EncodeItem ... +func (Leather) EncodeItem() (name string, meta int16) { + return "minecraft:leather", 0 +} diff --git a/server/item/leggings.go b/server/item/leggings.go new file mode 100644 index 0000000..eea46a4 --- /dev/null +++ b/server/item/leggings.go @@ -0,0 +1,122 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/world" + "image/color" +) + +// Leggings are a defensive item that may be equipped in the leggings armour slot. They come in several tiers, +// like leather, gold, chain, iron and diamond. +type Leggings struct { + // Tier is the tier of the leggings. + Tier ArmourTier + // Trim specifies the trim of the armour. + Trim ArmourTrim +} + +// Use handles the auto-equipping of leggings in an armour slot by using the item. +func (l Leggings) Use(_ *world.Tx, _ User, ctx *UseContext) bool { + ctx.SwapHeldWithArmour(2) + return false +} + +// MaxCount always returns 1. +func (l Leggings) MaxCount() int { + return 1 +} + +// DefencePoints ... +func (l Leggings) DefencePoints() float64 { + switch l.Tier.Name() { + case "leather": + return 2 + case "copper", "golden": + return 3 + case "chainmail": + return 4 + case "iron": + return 5 + case "diamond", "netherite": + return 6 + } + panic("invalid leggings tier") +} + +// Toughness ... +func (l Leggings) Toughness() float64 { + return l.Tier.Toughness() +} + +// KnockBackResistance ... +func (l Leggings) KnockBackResistance() float64 { + return l.Tier.KnockBackResistance() +} + +// EnchantmentValue ... +func (l Leggings) EnchantmentValue() int { + return l.Tier.EnchantmentValue() +} + +// Leggings ... +func (l Leggings) Leggings() bool { + return true +} + +// DurabilityInfo ... +func (l Leggings) DurabilityInfo() DurabilityInfo { + return DurabilityInfo{ + MaxDurability: int(l.Tier.BaseDurability() + l.Tier.BaseDurability()/2.5), + BrokenItem: simpleItem(Stack{}), + } +} + +// RepairableBy ... +func (l Leggings) RepairableBy(i Stack) bool { + return armourTierRepairable(l.Tier)(i) +} + +// SmeltInfo ... +func (l Leggings) SmeltInfo() SmeltInfo { + switch l.Tier.(type) { + case ArmourTierIron, ArmourTierChain: + return newOreSmeltInfo(NewStack(IronNugget{}, 1), 0.1) + case ArmourTierGold: + return newOreSmeltInfo(NewStack(GoldNugget{}, 1), 0.1) + case ArmourTierCopper: + return newOreSmeltInfo(NewStack(CopperNugget{}, 1), 0.1) + } + return SmeltInfo{} +} + +// WithTrim ... +func (l Leggings) WithTrim(trim ArmourTrim) world.Item { + l.Trim = trim + return l +} + +// EncodeItem ... +func (l Leggings) EncodeItem() (name string, meta int16) { + return "minecraft:" + l.Tier.Name() + "_leggings", 0 +} + +// DecodeNBT ... +func (l Leggings) DecodeNBT(data map[string]any) any { + if t, ok := l.Tier.(ArmourTierLeather); ok { + if v, ok := data["customColor"].(int32); ok { + t.Colour = rgbaFromInt32(v) + l.Tier = t + } + } + l.Trim = readTrim(data) + return l +} + +// EncodeNBT ... +func (l Leggings) EncodeNBT() map[string]any { + m := map[string]any{} + if t, ok := l.Tier.(ArmourTierLeather); ok && t.Colour != (color.RGBA{}) { + m["customColor"] = int32FromRGBA(t.Colour) + } + writeTrim(m, l.Trim) + return m +} diff --git a/server/item/lingering_potion.go b/server/item/lingering_potion.go new file mode 100644 index 0000000..d83bac0 --- /dev/null +++ b/server/item/lingering_potion.go @@ -0,0 +1,35 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/item/potion" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" +) + +// LingeringPotion is a variant of a splash potion that can be thrown to leave clouds with status effects that linger on +// the ground in an area. +type LingeringPotion struct { + // Type is the type of lingering potion. + Type potion.Potion +} + +// MaxCount ... +func (l LingeringPotion) MaxCount() int { + return 1 +} + +// Use ... +func (l LingeringPotion) Use(tx *world.Tx, user User, ctx *UseContext) bool { + create := tx.World().EntityRegistry().Config().LingeringPotion + opts := world.EntitySpawnOpts{Position: eyePosition(user), Velocity: throwableOffset(user.Rotation()).Vec3().Mul(0.5)} + tx.AddEntity(create(opts, l.Type, user)) + tx.PlaySound(user.Position(), sound.ItemThrow{}) + + ctx.SubtractFromCount(1) + return true +} + +// EncodeItem ... +func (l LingeringPotion) EncodeItem() (name string, meta int16) { + return "minecraft:lingering_potion", int16(l.Type.Uint8()) +} diff --git a/server/item/magma_cream.go b/server/item/magma_cream.go new file mode 100644 index 0000000..3d1e683 --- /dev/null +++ b/server/item/magma_cream.go @@ -0,0 +1,9 @@ +package item + +// MagmaCream is an item used in brewing to create potions of Fire Resistance, and to build magma blocks. +type MagmaCream struct{} + +// EncodeItem ... +func (m MagmaCream) EncodeItem() (name string, meta int16) { + return "minecraft:magma_cream", 0 +} diff --git a/server/item/melon_slice.go b/server/item/melon_slice.go new file mode 100644 index 0000000..5245243 --- /dev/null +++ b/server/item/melon_slice.go @@ -0,0 +1,35 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/world" + "time" +) + +// MelonSlice is a food item dropped by melon blocks. +type MelonSlice struct{} + +// AlwaysConsumable ... +func (m MelonSlice) AlwaysConsumable() bool { + return false +} + +// ConsumeDuration ... +func (m MelonSlice) ConsumeDuration() time.Duration { + return DefaultConsumeDuration +} + +// Consume ... +func (m MelonSlice) Consume(_ *world.Tx, c Consumer) Stack { + c.Saturate(2, 1.2) + return Stack{} +} + +// CompostChance ... +func (MelonSlice) CompostChance() float64 { + return 0.5 +} + +// EncodeItem ... +func (m MelonSlice) EncodeItem() (name string, meta int16) { + return "minecraft:melon_slice", 0 +} diff --git a/server/item/mushroom_stew.go b/server/item/mushroom_stew.go new file mode 100644 index 0000000..0014ad6 --- /dev/null +++ b/server/item/mushroom_stew.go @@ -0,0 +1,26 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/world" +) + +// MushroomStew is a food item. +type MushroomStew struct { + defaultFood +} + +// MaxCount ... +func (MushroomStew) MaxCount() int { + return 1 +} + +// Consume ... +func (MushroomStew) Consume(_ *world.Tx, c Consumer) Stack { + c.Saturate(6, 7.2) + return NewStack(Bowl{}, 1) +} + +// EncodeItem ... +func (MushroomStew) EncodeItem() (name string, meta int16) { + return "minecraft:mushroom_stew", 0 +} diff --git a/server/item/music_disc.go b/server/item/music_disc.go new file mode 100644 index 0000000..9ccbc41 --- /dev/null +++ b/server/item/music_disc.go @@ -0,0 +1,21 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/world/sound" +) + +// MusicDisc is an item that can be played in jukeboxes. +type MusicDisc struct { + // DiscType is the disc type of the music disc. + DiscType sound.DiscType +} + +// MaxCount always returns 1. +func (MusicDisc) MaxCount() int { + return 1 +} + +// EncodeItem ... +func (m MusicDisc) EncodeItem() (name string, meta int16) { + return "minecraft:music_disc_" + m.DiscType.String(), 0 +} diff --git a/server/item/mutton.go b/server/item/mutton.go new file mode 100644 index 0000000..133b350 --- /dev/null +++ b/server/item/mutton.go @@ -0,0 +1,37 @@ +package item + +import "github.com/df-mc/dragonfly/server/world" + +// Mutton is a food item obtained from sheep. It can be cooked in a furnace, smoker, or campfire. +type Mutton struct { + defaultFood + + // Cooked is whether the mutton is cooked. + Cooked bool +} + +// Consume ... +func (m Mutton) Consume(_ *world.Tx, c Consumer) Stack { + if m.Cooked { + c.Saturate(6, 9.6) + } else { + c.Saturate(2, 1.2) + } + return Stack{} +} + +// SmeltInfo ... +func (m Mutton) SmeltInfo() SmeltInfo { + if m.Cooked { + return SmeltInfo{} + } + return newFoodSmeltInfo(NewStack(Mutton{Cooked: true}, 1), 0.35) +} + +// EncodeItem ... +func (m Mutton) EncodeItem() (name string, meta int16) { + if m.Cooked { + return "minecraft:cooked_mutton", 0 + } + return "minecraft:mutton", 0 +} diff --git a/server/item/nautilus_shell.go b/server/item/nautilus_shell.go new file mode 100644 index 0000000..a065686 --- /dev/null +++ b/server/item/nautilus_shell.go @@ -0,0 +1,14 @@ +package item + +// NautilusShell is an item that is used for crafting conduits. +type NautilusShell struct{} + +// EncodeItem ... +func (NautilusShell) EncodeItem() (name string, meta int16) { + return "minecraft:nautilus_shell", 0 +} + +// OffHand ... +func (NautilusShell) OffHand() bool { + return true +} diff --git a/server/item/nether_brick.go b/server/item/nether_brick.go new file mode 100644 index 0000000..c398cea --- /dev/null +++ b/server/item/nether_brick.go @@ -0,0 +1,9 @@ +package item + +// NetherBrick is an item made by smelting netherrack in a furnace. +type NetherBrick struct{} + +// EncodeItem ... +func (NetherBrick) EncodeItem() (name string, meta int16) { + return "minecraft:netherbrick", 0 +} diff --git a/server/item/nether_quartz.go b/server/item/nether_quartz.go new file mode 100644 index 0000000..6ae11ac --- /dev/null +++ b/server/item/nether_quartz.go @@ -0,0 +1,21 @@ +package item + +import "github.com/sandertv/gophertunnel/minecraft/text" + +// NetherQuartz is a smooth, white mineral found in the Nether. +type NetherQuartz struct{} + +// EncodeItem ... +func (NetherQuartz) EncodeItem() (name string, meta int16) { + return "minecraft:quartz", 0 +} + +// TrimMaterial ... +func (NetherQuartz) TrimMaterial() string { + return "quartz" +} + +// MaterialColour ... +func (NetherQuartz) MaterialColour() string { + return text.Quartz +} diff --git a/server/item/nether_star.go b/server/item/nether_star.go new file mode 100644 index 0000000..cb225f1 --- /dev/null +++ b/server/item/nether_star.go @@ -0,0 +1,14 @@ +package item + +// NetherStar is a rare item dropped by the wither that is used solely to craft beacons. +type NetherStar struct{} + +// EncodeItem ... +func (NetherStar) EncodeItem() (name string, meta int16) { + return "minecraft:nether_star", 0 +} + +// BlastProof indicates that the item will withstand an explosion as an item entity. +func (NetherStar) BlastProof() bool { + return true +} diff --git a/server/item/netherite_ingot.go b/server/item/netherite_ingot.go new file mode 100644 index 0000000..8fd31c8 --- /dev/null +++ b/server/item/netherite_ingot.go @@ -0,0 +1,26 @@ +package item + +import "github.com/sandertv/gophertunnel/minecraft/text" + +// NetheriteIngot is a rare mineral crafted with 4 pieces of netherite scrap and 4 gold ingots. +type NetheriteIngot struct{} + +// EncodeItem ... +func (NetheriteIngot) EncodeItem() (name string, meta int16) { + return "minecraft:netherite_ingot", 0 +} + +// TrimMaterial ... +func (NetheriteIngot) TrimMaterial() string { + return "netherite" +} + +// MaterialColour ... +func (NetheriteIngot) MaterialColour() string { + return text.Netherite +} + +// PayableForBeacon ... +func (NetheriteIngot) PayableForBeacon() bool { + return true +} diff --git a/server/item/netherite_scrap.go b/server/item/netherite_scrap.go new file mode 100644 index 0000000..2c120fc --- /dev/null +++ b/server/item/netherite_scrap.go @@ -0,0 +1,9 @@ +package item + +// NetheriteScrap is a material smelted from ancient debris, which is found in the Nether. +type NetheriteScrap struct{} + +// EncodeItem ... +func (NetheriteScrap) EncodeItem() (name string, meta int16) { + return "minecraft:netherite_scrap", 0 +} diff --git a/server/item/paper.go b/server/item/paper.go new file mode 100644 index 0000000..7f8d86d --- /dev/null +++ b/server/item/paper.go @@ -0,0 +1,9 @@ +package item + +// Paper is an item crafted from sugar cane. +type Paper struct{} + +// EncodeItem ... +func (Paper) EncodeItem() (name string, meta int16) { + return "minecraft:paper", 0 +} diff --git a/server/item/phantom_membrane.go b/server/item/phantom_membrane.go new file mode 100644 index 0000000..e0cc8bb --- /dev/null +++ b/server/item/phantom_membrane.go @@ -0,0 +1,9 @@ +package item + +// PhantomMembrane are leathery skins obtained from killing phantoms. +type PhantomMembrane struct{} + +// EncodeItem ... +func (PhantomMembrane) EncodeItem() (name string, meta int16) { + return "minecraft:phantom_membrane", 0 +} diff --git a/server/item/pickaxe.go b/server/item/pickaxe.go new file mode 100644 index 0000000..b392355 --- /dev/null +++ b/server/item/pickaxe.go @@ -0,0 +1,86 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/world" + "time" +) + +// Pickaxe is a tool generally used for mining stone-like blocks and ores at a higher speed and to obtain +// their drops. +type Pickaxe struct { + // Tier is the tier of the pickaxe. + Tier ToolTier +} + +// ToolType returns the type for pickaxes. +func (p Pickaxe) ToolType() ToolType { + return TypePickaxe +} + +// HarvestLevel returns the level that this pickaxe is able to harvest. If a block has a harvest level above +// this one, this pickaxe won't be able to harvest it. +func (p Pickaxe) HarvestLevel() int { + return p.Tier.HarvestLevel +} + +// BaseMiningEfficiency is the base efficiency of the pickaxe, when it comes to mining blocks. This decides +// the speed with which blocks can be mined. +func (p Pickaxe) BaseMiningEfficiency(world.Block) float64 { + return p.Tier.BaseMiningEfficiency +} + +// MaxCount returns 1. +func (p Pickaxe) MaxCount() int { + return 1 +} + +// AttackDamage returns the attack damage to the pickaxe. +func (p Pickaxe) AttackDamage() float64 { + return p.Tier.BaseAttackDamage + 1 +} + +// EnchantmentValue ... +func (p Pickaxe) EnchantmentValue() int { + return p.Tier.EnchantmentValue +} + +// DurabilityInfo ... +func (p Pickaxe) DurabilityInfo() DurabilityInfo { + return DurabilityInfo{ + MaxDurability: p.Tier.Durability, + BrokenItem: simpleItem(Stack{}), + AttackDurability: 2, + BreakDurability: 1, + } +} + +// RepairableBy ... +func (p Pickaxe) RepairableBy(i Stack) bool { + return toolTierRepairable(p.Tier)(i) +} + +// SmeltInfo ... +func (p Pickaxe) SmeltInfo() SmeltInfo { + switch p.Tier { + case ToolTierIron: + return newOreSmeltInfo(NewStack(IronNugget{}, 1), 0.1) + case ToolTierGold: + return newOreSmeltInfo(NewStack(GoldNugget{}, 1), 0.1) + case ToolTierCopper: + return newOreSmeltInfo(NewStack(CopperNugget{}, 1), 0.1) + } + return SmeltInfo{} +} + +// FuelInfo ... +func (p Pickaxe) FuelInfo() FuelInfo { + if p.Tier == ToolTierWood { + return newFuelInfo(time.Second * 10) + } + return FuelInfo{} +} + +// EncodeItem ... +func (p Pickaxe) EncodeItem() (name string, meta int16) { + return "minecraft:" + p.Tier.Name + "_pickaxe", 0 +} diff --git a/server/item/poisonous_potato.go b/server/item/poisonous_potato.go new file mode 100644 index 0000000..cd89e63 --- /dev/null +++ b/server/item/poisonous_potato.go @@ -0,0 +1,27 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/world" + "math/rand/v2" + "time" +) + +// PoisonousPotato is a type of potato that can poison the player. +type PoisonousPotato struct { + defaultFood +} + +// Consume ... +func (p PoisonousPotato) Consume(_ *world.Tx, c Consumer) Stack { + c.Saturate(2, 1.2) + if rand.Float64() < 0.6 { + c.AddEffect(effect.New(effect.Poison, 1, 5*time.Second)) + } + return Stack{} +} + +// EncodeItem ... +func (p PoisonousPotato) EncodeItem() (name string, meta int16) { + return "minecraft:poisonous_potato", 0 +} diff --git a/server/item/popped_chorus_fruit.go b/server/item/popped_chorus_fruit.go new file mode 100644 index 0000000..3831ad1 --- /dev/null +++ b/server/item/popped_chorus_fruit.go @@ -0,0 +1,10 @@ +package item + +// PoppedChorusFruit is an item obtained by smelting chorus fruit, and used to craft end rods and purpur blocks. +// Unlike raw chorus fruit, the popped fruit is inedible. +type PoppedChorusFruit struct{} + +// EncodeItem ... +func (PoppedChorusFruit) EncodeItem() (name string, meta int16) { + return "minecraft:popped_chorus_fruit", 0 +} diff --git a/server/item/porkchop.go b/server/item/porkchop.go new file mode 100644 index 0000000..310cd10 --- /dev/null +++ b/server/item/porkchop.go @@ -0,0 +1,37 @@ +package item + +import "github.com/df-mc/dragonfly/server/world" + +// Porkchop is a food item obtained from pigs. It can be cooked in a furnace, smoker, or campfire. +type Porkchop struct { + defaultFood + + // Cooked is whether the porkchop is cooked. + Cooked bool +} + +// Consume ... +func (p Porkchop) Consume(_ *world.Tx, c Consumer) Stack { + if p.Cooked { + c.Saturate(8, 12.8) + } else { + c.Saturate(3, 1.8) + } + return Stack{} +} + +// SmeltInfo ... +func (p Porkchop) SmeltInfo() SmeltInfo { + if p.Cooked { + return SmeltInfo{} + } + return newFoodSmeltInfo(NewStack(Porkchop{Cooked: true}, 1), 0.35) +} + +// EncodeItem ... +func (p Porkchop) EncodeItem() (name string, meta int16) { + if p.Cooked { + return "minecraft:cooked_porkchop", 0 + } + return "minecraft:porkchop", 0 +} diff --git a/server/item/potion.go b/server/item/potion.go new file mode 100644 index 0000000..d30983f --- /dev/null +++ b/server/item/potion.go @@ -0,0 +1,41 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/item/potion" + "github.com/df-mc/dragonfly/server/world" + "time" +) + +// Potion is an item that grants effects on consumption. +type Potion struct { + // Type is the type of potion. + Type potion.Potion +} + +// MaxCount ... +func (p Potion) MaxCount() int { + return 1 +} + +// AlwaysConsumable ... +func (p Potion) AlwaysConsumable() bool { + return true +} + +// ConsumeDuration ... +func (p Potion) ConsumeDuration() time.Duration { + return DefaultConsumeDuration +} + +// Consume ... +func (p Potion) Consume(_ *world.Tx, c Consumer) Stack { + for _, effect := range p.Type.Effects() { + c.AddEffect(effect) + } + return NewStack(GlassBottle{}, 1) +} + +// EncodeItem ... +func (p Potion) EncodeItem() (name string, meta int16) { + return "minecraft:potion", int16(p.Type.Uint8()) +} diff --git a/server/item/potion/potion.go b/server/item/potion/potion.go new file mode 100644 index 0000000..8fec98b --- /dev/null +++ b/server/item/potion/potion.go @@ -0,0 +1,343 @@ +package potion + +import ( + "github.com/df-mc/dragonfly/server/entity/effect" + "time" +) + +// Potion holds the effects given by the potion type. +type Potion struct { + potion +} + +// Water ... +func Water() Potion { + return Potion{} +} + +// Mundane ... +func Mundane() Potion { + return Potion{1} +} + +// LongMundane ... +func LongMundane() Potion { + return Potion{2} +} + +// Thick ... +func Thick() Potion { + return Potion{3} +} + +// Awkward ... +func Awkward() Potion { + return Potion{4} +} + +// NightVision ... +func NightVision() Potion { + return Potion{5} +} + +// LongNightVision ... +func LongNightVision() Potion { + return Potion{6} +} + +// Invisibility ... +func Invisibility() Potion { + return Potion{7} +} + +// LongInvisibility ... +func LongInvisibility() Potion { + return Potion{8} +} + +// Leaping ... +func Leaping() Potion { + return Potion{9} +} + +// LongLeaping ... +func LongLeaping() Potion { + return Potion{10} +} + +// StrongLeaping ... +func StrongLeaping() Potion { + return Potion{11} +} + +// FireResistance ... +func FireResistance() Potion { + return Potion{12} +} + +// LongFireResistance ... +func LongFireResistance() Potion { + return Potion{13} +} + +// Swiftness ... +func Swiftness() Potion { + return Potion{14} +} + +// LongSwiftness ... +func LongSwiftness() Potion { + return Potion{15} +} + +// StrongSwiftness ... +func StrongSwiftness() Potion { + return Potion{16} +} + +// Slowness ... +func Slowness() Potion { + return Potion{17} +} + +// LongSlowness ... +func LongSlowness() Potion { + return Potion{18} +} + +// WaterBreathing ... +func WaterBreathing() Potion { + return Potion{19} +} + +// LongWaterBreathing ... +func LongWaterBreathing() Potion { + return Potion{20} +} + +// Healing ... +func Healing() Potion { + return Potion{21} +} + +// StrongHealing ... +func StrongHealing() Potion { + return Potion{22} +} + +// Harming ... +func Harming() Potion { + return Potion{23} +} + +// StrongHarming ... +func StrongHarming() Potion { + return Potion{24} +} + +// Poison ... +func Poison() Potion { + return Potion{25} +} + +// LongPoison ... +func LongPoison() Potion { + return Potion{26} +} + +// StrongPoison ... +func StrongPoison() Potion { + return Potion{27} +} + +// Regeneration ... +func Regeneration() Potion { + return Potion{28} +} + +// LongRegeneration ... +func LongRegeneration() Potion { + return Potion{29} +} + +// StrongRegeneration ... +func StrongRegeneration() Potion { + return Potion{30} +} + +// Strength ... +func Strength() Potion { + return Potion{31} +} + +// LongStrength ... +func LongStrength() Potion { + return Potion{32} +} + +// StrongStrength ... +func StrongStrength() Potion { + return Potion{33} +} + +// Weakness ... +func Weakness() Potion { + return Potion{34} +} + +// LongWeakness ... +func LongWeakness() Potion { + return Potion{35} +} + +// Wither ... +func Wither() Potion { + return Potion{36} +} + +// TurtleMaster ... +func TurtleMaster() Potion { + return Potion{37} +} + +// LongTurtleMaster ... +func LongTurtleMaster() Potion { + return Potion{38} +} + +// StrongTurtleMaster ... +func StrongTurtleMaster() Potion { + return Potion{39} +} + +// SlowFalling ... +func SlowFalling() Potion { + return Potion{40} +} + +// LongSlowFalling ... +func LongSlowFalling() Potion { + return Potion{41} +} + +// StrongSlowness ... +func StrongSlowness() Potion { + return Potion{42} +} + +// From returns a Potion by the ID given. +func From(id int32) Potion { + return Potion{potion(id)} +} + +// Effects returns the effects of the potion. +func (p Potion) Effects() []effect.Effect { + switch p { + case NightVision(): + return []effect.Effect{effect.New(effect.NightVision, 1, 3*time.Minute)} + case LongNightVision(): + return []effect.Effect{effect.New(effect.NightVision, 1, 8*time.Minute)} + case Invisibility(): + return []effect.Effect{effect.New(effect.Invisibility, 1, 3*time.Minute)} + case LongInvisibility(): + return []effect.Effect{effect.New(effect.Invisibility, 1, 8*time.Minute)} + case Leaping(): + return []effect.Effect{effect.New(effect.JumpBoost, 1, 3*time.Minute)} + case LongLeaping(): + return []effect.Effect{effect.New(effect.JumpBoost, 1, 8*time.Minute)} + case StrongLeaping(): + return []effect.Effect{effect.New(effect.JumpBoost, 2, 90*time.Second)} + case FireResistance(): + return []effect.Effect{effect.New(effect.FireResistance, 1, 3*time.Minute)} + case LongFireResistance(): + return []effect.Effect{effect.New(effect.FireResistance, 1, 8*time.Minute)} + case Swiftness(): + return []effect.Effect{effect.New(effect.Speed, 1, 3*time.Minute)} + case LongSwiftness(): + return []effect.Effect{effect.New(effect.Speed, 1, 8*time.Minute)} + case StrongSwiftness(): + return []effect.Effect{effect.New(effect.Speed, 2, 90*time.Second)} + case Slowness(): + return []effect.Effect{effect.New(effect.Slowness, 1, 90*time.Second)} + case LongSlowness(): + return []effect.Effect{effect.New(effect.Slowness, 1, 4*time.Minute)} + case StrongSlowness(): + return []effect.Effect{effect.New(effect.Slowness, 4, 20*time.Second)} + case WaterBreathing(): + return []effect.Effect{effect.New(effect.WaterBreathing, 1, 3*time.Minute)} + case LongWaterBreathing(): + return []effect.Effect{effect.New(effect.WaterBreathing, 1, 8*time.Minute)} + case Healing(): + return []effect.Effect{effect.NewInstant(effect.InstantHealth, 1)} + case StrongHealing(): + return []effect.Effect{effect.NewInstant(effect.InstantHealth, 2)} + case Harming(): + return []effect.Effect{effect.NewInstant(effect.InstantDamage, 1)} + case StrongHarming(): + return []effect.Effect{effect.NewInstant(effect.InstantDamage, 2)} + case Poison(): + return []effect.Effect{effect.New(effect.Poison, 1, 45*time.Second)} + case LongPoison(): + return []effect.Effect{effect.New(effect.Poison, 1, 2*time.Minute)} + case StrongPoison(): + return []effect.Effect{effect.New(effect.Poison, 2, 22500*time.Millisecond)} + case Regeneration(): + return []effect.Effect{effect.New(effect.Regeneration, 1, 45*time.Second)} + case LongRegeneration(): + return []effect.Effect{effect.New(effect.Regeneration, 1, 2*time.Minute)} + case StrongRegeneration(): + return []effect.Effect{effect.New(effect.Regeneration, 2, 22500*time.Millisecond)} + case Strength(): + return []effect.Effect{effect.New(effect.Strength, 1, 3*time.Minute)} + case LongStrength(): + return []effect.Effect{effect.New(effect.Strength, 1, 8*time.Minute)} + case StrongStrength(): + return []effect.Effect{effect.New(effect.Strength, 2, 90*time.Second)} + case Weakness(): + return []effect.Effect{effect.New(effect.Weakness, 1, 90*time.Second)} + case LongWeakness(): + return []effect.Effect{effect.New(effect.Weakness, 1, 4*time.Minute)} + case Wither(): + return []effect.Effect{effect.New(effect.Wither, 1, 40*time.Second)} + case TurtleMaster(): + return []effect.Effect{ + effect.New(effect.Resistance, 3, 20*time.Second), + effect.New(effect.Slowness, 4, 20*time.Second), + } + case LongTurtleMaster(): + return []effect.Effect{ + effect.New(effect.Resistance, 3, 40*time.Second), + effect.New(effect.Slowness, 4, 40*time.Second), + } + case StrongTurtleMaster(): + return []effect.Effect{ + effect.New(effect.Resistance, 5, 20*time.Second), + effect.New(effect.Slowness, 6, 20*time.Second), + } + case SlowFalling(): + return []effect.Effect{effect.New(effect.SlowFalling, 1, 90*time.Second)} + case LongSlowFalling(): + return []effect.Effect{effect.New(effect.SlowFalling, 1, 4*time.Minute)} + } + return []effect.Effect{} +} + +type potion uint8 + +// Uint8 returns the potion type as a uint8. +func (p potion) Uint8() uint8 { + return uint8(p) +} + +// All ... +func All() []Potion { + return []Potion{ + Water(), Mundane(), LongMundane(), Thick(), Awkward(), NightVision(), LongNightVision(), Invisibility(), + LongInvisibility(), Leaping(), LongLeaping(), StrongLeaping(), FireResistance(), LongFireResistance(), + Swiftness(), LongSwiftness(), StrongSwiftness(), Slowness(), LongSlowness(), WaterBreathing(), + LongWaterBreathing(), Healing(), StrongHealing(), Harming(), StrongHarming(), Poison(), LongPoison(), + StrongPoison(), Regeneration(), LongRegeneration(), StrongRegeneration(), Strength(), LongStrength(), + StrongStrength(), Weakness(), LongWeakness(), Wither(), TurtleMaster(), LongTurtleMaster(), StrongTurtleMaster(), + SlowFalling(), LongSlowFalling(), StrongSlowness(), + } +} diff --git a/server/item/pottery_sherd.go b/server/item/pottery_sherd.go new file mode 100644 index 0000000..c2842e2 --- /dev/null +++ b/server/item/pottery_sherd.go @@ -0,0 +1,16 @@ +package item + +// PotterySherd is an item that can be found from brushing suspicious sand or gravel. +type PotterySherd struct { + Type SherdType +} + +// EncodeItem ... +func (s PotterySherd) EncodeItem() (name string, meta int16) { + return "minecraft:" + s.Type.String() + "_pottery_sherd", 0 +} + +// PotDecoration ... +func (PotterySherd) PotDecoration() bool { + return true +} diff --git a/server/item/prismarine_crystal.go b/server/item/prismarine_crystal.go new file mode 100644 index 0000000..8fbbb95 --- /dev/null +++ b/server/item/prismarine_crystal.go @@ -0,0 +1,10 @@ +package item + +// PrismarineCrystals are items obtained by defeating guardians or elder guardians. They are used for crafting sea +// lanterns. +type PrismarineCrystals struct{} + +// EncodeItem ... +func (p PrismarineCrystals) EncodeItem() (name string, meta int16) { + return "minecraft:prismarine_crystals", 0 +} diff --git a/server/item/prismarine_shard.go b/server/item/prismarine_shard.go new file mode 100644 index 0000000..a3502f4 --- /dev/null +++ b/server/item/prismarine_shard.go @@ -0,0 +1,9 @@ +package item + +// PrismarineShard is an item obtained by defeating guardians or elder guardians. +type PrismarineShard struct{} + +// EncodeItem ... +func (PrismarineShard) EncodeItem() (name string, meta int16) { + return "minecraft:prismarine_shard", 0 +} diff --git a/server/item/pufferfish.go b/server/item/pufferfish.go new file mode 100644 index 0000000..63ca7c9 --- /dev/null +++ b/server/item/pufferfish.go @@ -0,0 +1,26 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/world" + "time" +) + +// Pufferfish is a poisonous type of fish that is used to brew water breathing potions. +type Pufferfish struct { + defaultFood +} + +// Consume ... +func (p Pufferfish) Consume(_ *world.Tx, c Consumer) Stack { + c.Saturate(1, 0.2) + c.AddEffect(effect.New(effect.Hunger, 3, 15*time.Second)) + c.AddEffect(effect.New(effect.Poison, 2, time.Minute)) + c.AddEffect(effect.New(effect.Nausea, 2, 15*time.Second)) + return Stack{} +} + +// EncodeItem ... +func (p Pufferfish) EncodeItem() (name string, meta int16) { + return "minecraft:pufferfish", 0 +} diff --git a/server/item/pumpkin_pie.go b/server/item/pumpkin_pie.go new file mode 100644 index 0000000..b3b6272 --- /dev/null +++ b/server/item/pumpkin_pie.go @@ -0,0 +1,24 @@ +package item + +import "github.com/df-mc/dragonfly/server/world" + +// PumpkinPie is a food item that can be eaten by the player. +type PumpkinPie struct { + defaultFood +} + +// Consume ... +func (PumpkinPie) Consume(_ *world.Tx, c Consumer) Stack { + c.Saturate(8, 4.8) + return Stack{} +} + +// CompostChance ... +func (PumpkinPie) CompostChance() float64 { + return 1 +} + +// EncodeItem ... +func (PumpkinPie) EncodeItem() (name string, meta int16) { + return "minecraft:pumpkin_pie", 0 +} diff --git a/server/item/rabbit.go b/server/item/rabbit.go new file mode 100644 index 0000000..3e339b5 --- /dev/null +++ b/server/item/rabbit.go @@ -0,0 +1,37 @@ +package item + +import "github.com/df-mc/dragonfly/server/world" + +// Rabbit is a food item obtained from rabbits. It can be cooked in a furnace, smoker, or campfire. +type Rabbit struct { + defaultFood + + // Cooked is whether the rabbit is cooked. + Cooked bool +} + +// Consume ... +func (r Rabbit) Consume(_ *world.Tx, c Consumer) Stack { + if r.Cooked { + c.Saturate(5, 6) + } else { + c.Saturate(3, 1.8) + } + return Stack{} +} + +// SmeltInfo ... +func (r Rabbit) SmeltInfo() SmeltInfo { + if r.Cooked { + return SmeltInfo{} + } + return newFoodSmeltInfo(NewStack(Rabbit{Cooked: true}, 1), 0.35) +} + +// EncodeItem ... +func (r Rabbit) EncodeItem() (name string, meta int16) { + if r.Cooked { + return "minecraft:cooked_rabbit", 0 + } + return "minecraft:rabbit", 0 +} diff --git a/server/item/rabbit_foot.go b/server/item/rabbit_foot.go new file mode 100644 index 0000000..763888b --- /dev/null +++ b/server/item/rabbit_foot.go @@ -0,0 +1,9 @@ +package item + +// RabbitFoot is a brewing item obtained from rabbits. +type RabbitFoot struct{} + +// EncodeItem ... +func (RabbitFoot) EncodeItem() (name string, meta int16) { + return "minecraft:rabbit_foot", 0 +} diff --git a/server/item/rabbit_hide.go b/server/item/rabbit_hide.go new file mode 100644 index 0000000..bd75931 --- /dev/null +++ b/server/item/rabbit_hide.go @@ -0,0 +1,9 @@ +package item + +// RabbitHide is an item dropped by rabbits. +type RabbitHide struct{} + +// EncodeItem ... +func (RabbitHide) EncodeItem() (name string, meta int16) { + return "minecraft:rabbit_hide", 0 +} diff --git a/server/item/rabbit_stew.go b/server/item/rabbit_stew.go new file mode 100644 index 0000000..1de8a89 --- /dev/null +++ b/server/item/rabbit_stew.go @@ -0,0 +1,26 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/world" +) + +// RabbitStew is a food item that can be eaten by the player. +type RabbitStew struct { + defaultFood +} + +// MaxCount ... +func (RabbitStew) MaxCount() int { + return 1 +} + +// Consume ... +func (RabbitStew) Consume(_ *world.Tx, c Consumer) Stack { + c.Saturate(10, 12) + return NewStack(Bowl{}, 1) +} + +// EncodeItem ... +func (RabbitStew) EncodeItem() (name string, meta int16) { + return "minecraft:rabbit_stew", 0 +} diff --git a/server/item/raw_copper.go b/server/item/raw_copper.go new file mode 100644 index 0000000..f335c68 --- /dev/null +++ b/server/item/raw_copper.go @@ -0,0 +1,14 @@ +package item + +// RawCopper is a raw metal resource obtained from mining copper ore. +type RawCopper struct{} + +// SmeltInfo ... +func (RawCopper) SmeltInfo() SmeltInfo { + return newOreSmeltInfo(NewStack(CopperIngot{}, 1), 0.7) +} + +// EncodeItem ... +func (RawCopper) EncodeItem() (name string, meta int16) { + return "minecraft:raw_copper", 0 +} diff --git a/server/item/raw_gold.go b/server/item/raw_gold.go new file mode 100644 index 0000000..25463d5 --- /dev/null +++ b/server/item/raw_gold.go @@ -0,0 +1,14 @@ +package item + +// RawGold is a raw metal resource obtained from mining gold ore. +type RawGold struct{} + +// SmeltInfo ... +func (RawGold) SmeltInfo() SmeltInfo { + return newOreSmeltInfo(NewStack(GoldIngot{}, 1), 1) +} + +// EncodeItem ... +func (RawGold) EncodeItem() (name string, meta int16) { + return "minecraft:raw_gold", 0 +} diff --git a/server/item/raw_iron.go b/server/item/raw_iron.go new file mode 100644 index 0000000..e5d788f --- /dev/null +++ b/server/item/raw_iron.go @@ -0,0 +1,14 @@ +package item + +// RawIron is a raw metal resource obtained from mining iron ore. +type RawIron struct{} + +// SmeltInfo ... +func (RawIron) SmeltInfo() SmeltInfo { + return newOreSmeltInfo(NewStack(IronIngot{}, 1), 0.7) +} + +// EncodeItem ... +func (RawIron) EncodeItem() (name string, meta int16) { + return "minecraft:raw_iron", 0 +} diff --git a/server/item/recipe/crafting_data.nbt b/server/item/recipe/crafting_data.nbt new file mode 100644 index 0000000..350fc0c Binary files /dev/null and b/server/item/recipe/crafting_data.nbt differ diff --git a/server/item/recipe/dynamic.go b/server/item/recipe/dynamic.go new file mode 100644 index 0000000..02f6531 --- /dev/null +++ b/server/item/recipe/dynamic.go @@ -0,0 +1,120 @@ +package recipe + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// DecoratedPotRecipe is a dynamic recipe for crafting decorated pots. The output depends on which +// pottery sherds or bricks are used in the crafting grid. +type DecoratedPotRecipe struct { + block string +} + +// NewDecoratedPotRecipe creates a new decorated pot recipe. +func NewDecoratedPotRecipe() DecoratedPotRecipe { + return DecoratedPotRecipe{block: "crafting_table"} +} + +// potDecoration is a local interface to check if an item can be used as a pot decoration +// without importing the block package (which would create an import cycle). +type potDecoration interface { + world.Item + PotDecoration() bool +} + +// Match checks if the given input items match the decorated pot recipe pattern. +// The pattern requires exactly 4 PotDecoration items (bricks or pottery sherds) in a diamond/plus shape: +// - Slot 1 (top centre) +// - Slot 3 (middle left) +// - Slot 5 (middle right) +// - Slot 7 (bottom centre) +// All other slots must be empty. +func (r DecoratedPotRecipe) Match(input []Item) (output []item.Stack, ok bool) { + // For a 3x3 crafting grid, we need exactly 9 slots + if len(input) != 9 { + return nil, false + } + + // Define the slots for the diamond pattern (0-indexed) + // Layout: 0 1 2 + // 3 4 5 + // 6 7 8 + // We need items at: 1 (top), 3 (left), 5 (right), 7 (bottom) + // Odd indices should have items, even indices should be empty + + decorations := [4]world.Item{} + decorationIndex := 0 + for i := range input { + it := input[i] + if i%2 == 0 { + // Even slots (0, 2, 4, 6, 8) should be empty + if !it.Empty() { + return nil, false + } + } else { + // Odd slots (1, 3, 5, 7) should have items + if it.Empty() { + return nil, false + } + + // Extract the actual item from the Item interface + var actualItem item.Stack + if v, ok := it.(item.Stack); ok { + actualItem = v + } else { + // ItemTag or other types are not valid for decorated pots + return nil, false + } + + // Check if the item implements PotDecoration + decoration, ok := actualItem.Item().(potDecoration) + if !ok { + return nil, false + } + decorations[decorationIndex] = decoration + decorationIndex++ + } + } + + // Create the decorated pot by encoding the decorations into NBT + // We'll use world.BlockByName to get the DecoratedPot block and set its decorations + // The decorations are ordered: [top, left, right, bottom] in the crafting grid + // For the pot NBT: [back, left, front, right] based on facing direction + + // Get a decorated pot block instance + pot, ok := world.BlockByName("minecraft:decorated_pot", map[string]any{"direction": int32(2)}) + if !ok { + return nil, false + } + + // The pot will be decoded with the decorations through NBT when placed + // For now, we'll create a pot with the decorations in the correct order + // DecoratedPot.DecodeNBT expects sherds in order: [back, left, front, right] + sherds := []any{} + // Order: top -> back, left -> left, bottom -> front, right -> right + for _, idx := range []int{0, 1, 3, 2} { // top, left, bottom, right + name, _ := decorations[idx].EncodeItem() + sherds = append(sherds, name) + } + + // Decode the pot with the sherds NBT data using type assertion + if nbtDecoder, ok := pot.(interface { + DecodeNBT(map[string]any) any + }); ok { + decodedPot := nbtDecoder.DecodeNBT(map[string]any{ + "id": "DecoratedPot", + "sherds": sherds, + }) + if potItem, ok := decodedPot.(world.Item); ok { + return []item.Stack{item.NewStack(potItem, 1)}, true + } + } + + return nil, false +} + +// Block returns the block used to craft this recipe. +func (r DecoratedPotRecipe) Block() string { + return r.block +} diff --git a/server/item/recipe/item.go b/server/item/recipe/item.go new file mode 100644 index 0000000..a18ed11 --- /dev/null +++ b/server/item/recipe/item.go @@ -0,0 +1,116 @@ +package recipe + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "math" +) + +// Item represents an item that can be used as either the input or output of an item. These do not +// necessarily resolve to an actual item, but can be just as simple as a tag etc. +type Item interface { + // Count returns the amount of items that is present on the stack. The count is guaranteed never to be + // negative. + Count() int + // Empty checks if the stack is empty (has a count of 0). + Empty() bool +} + +// 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. + Meta int32 `nbt:"meta"` + // Count is the amount of the item. + Count int32 `nbt:"count"` + // State is included if the output is a block. If it's not included, the meta can be discarded and the output item can be incorrect. + State struct { + Name string `nbt:"name"` + Properties map[string]interface{} `nbt:"states"` + Version int32 `nbt:"version"` + } `nbt:"block"` + // Tag is included if the input item is defined by a tag instead of a specific item. + 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 + } + 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 { + itemInput, ok := i.Item() + if !ok { + return nil, false + } + s = append(s, itemInput) + } + return s, true +} + +// 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. + Meta int32 `nbt:"meta"` + // Count is the amount of the item. + Count int16 `nbt:"count"` + // State is included if the output is a block. If it's not included, the meta can be discarded and the output item can be incorrect. + State struct { + Name string `nbt:"name"` + Properties map[string]interface{} `nbt:"states"` + Version int32 `nbt:"version"` + } `nbt:"block"` + // NBTData contains extra NBTData which may modify the item in other, more discreet ways. + 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 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 { + itemOutput, ok := o.Stack() + if !ok { + return nil, false + } + s = append(s, itemOutput) + } + return s, true +} diff --git a/server/item/recipe/item_tag.go b/server/item/recipe/item_tag.go new file mode 100644 index 0000000..cdce36b --- /dev/null +++ b/server/item/recipe/item_tag.go @@ -0,0 +1,60 @@ +package recipe + +import ( + _ "embed" + "encoding/json" +) + +var ( + //go:embed item_tags.json + itemTagData []byte + itemTags = make(map[string][]string) +) + +func init() { + if err := json.Unmarshal(itemTagData, &itemTags); err != nil { + panic(err) + } +} + +// ItemTag represents a recipe item that is identified by a tag, such as "minecraft:planks" or +// "minecraft:digger" and so on. +type ItemTag struct { + tag string + count int + + items []string +} + +// NewItemTag creates a new item tag with the tag and count passed. +func NewItemTag(tag string, count int) ItemTag { + if count < 0 { + count = 0 + } + return ItemTag{tag: tag, count: count, items: itemTags[tag]} +} + +// Count ... +func (i ItemTag) Count() int { + return i.count +} + +// Empty ... +func (i ItemTag) Empty() bool { + return i.count == 0 || i.tag == "" +} + +// Tag returns the tag of the item. +func (i ItemTag) Tag() string { + return i.tag +} + +// Contains returns true if the item tag contains the item with the name passed. +func (i ItemTag) Contains(name string) bool { + for _, item := range i.items { + if item == name { + return true + } + } + return false +} diff --git a/server/item/recipe/item_tags.json b/server/item/recipe/item_tags.json new file mode 100644 index 0000000..59031ed --- /dev/null +++ b/server/item/recipe/item_tags.json @@ -0,0 +1,829 @@ +{ + "minecraft:arrow": [ + "minecraft:arrow" + ], + "minecraft:banner": [ + "minecraft:banner" + ], + "minecraft:boat": [ + "minecraft:acacia_boat", + "minecraft:acacia_chest_boat", + "minecraft:bamboo_chest_raft", + "minecraft:bamboo_raft", + "minecraft:birch_boat", + "minecraft:birch_chest_boat", + "minecraft:cherry_boat", + "minecraft:cherry_chest_boat", + "minecraft:dark_oak_boat", + "minecraft:dark_oak_chest_boat", + "minecraft:jungle_boat", + "minecraft:jungle_chest_boat", + "minecraft:mangrove_boat", + "minecraft:mangrove_chest_boat", + "minecraft:oak_boat", + "minecraft:oak_chest_boat", + "minecraft:pale_oak_boat", + "minecraft:pale_oak_chest_boat", + "minecraft:spruce_boat", + "minecraft:spruce_chest_boat" + ], + "minecraft:boats": [ + "minecraft:acacia_boat", + "minecraft:acacia_chest_boat", + "minecraft:bamboo_chest_raft", + "minecraft:bamboo_raft", + "minecraft:birch_boat", + "minecraft:birch_chest_boat", + "minecraft:cherry_boat", + "minecraft:cherry_chest_boat", + "minecraft:dark_oak_boat", + "minecraft:dark_oak_chest_boat", + "minecraft:jungle_boat", + "minecraft:jungle_chest_boat", + "minecraft:mangrove_boat", + "minecraft:mangrove_chest_boat", + "minecraft:oak_boat", + "minecraft:oak_chest_boat", + "minecraft:pale_oak_boat", + "minecraft:pale_oak_chest_boat", + "minecraft:spruce_boat", + "minecraft:spruce_chest_boat" + ], + "minecraft:bookshelf_books": [ + "minecraft:book", + "minecraft:enchanted_book", + "minecraft:writable_book", + "minecraft:written_book" + ], + "minecraft:chainmail_tier": [ + "minecraft:chainmail_boots", + "minecraft:chainmail_chestplate", + "minecraft:chainmail_helmet", + "minecraft:chainmail_leggings" + ], + "minecraft:coals": [ + "minecraft:charcoal", + "minecraft:coal" + ], + "minecraft:crimson_stems": [ + "minecraft:crimson_hyphae", + "minecraft:crimson_stem", + "minecraft:stripped_crimson_hyphae", + "minecraft:stripped_crimson_stem" + ], + "minecraft:decorated_pot_sherds": [ + "minecraft:angler_pottery_sherd", + "minecraft:archer_pottery_sherd", + "minecraft:arms_up_pottery_sherd", + "minecraft:blade_pottery_sherd", + "minecraft:brewer_pottery_sherd", + "minecraft:brick", + "minecraft:burn_pottery_sherd", + "minecraft:danger_pottery_sherd", + "minecraft:explorer_pottery_sherd", + "minecraft:flow_pottery_sherd", + "minecraft:friend_pottery_sherd", + "minecraft:guster_pottery_sherd", + "minecraft:heart_pottery_sherd", + "minecraft:heartbreak_pottery_sherd", + "minecraft:howl_pottery_sherd", + "minecraft:miner_pottery_sherd", + "minecraft:mourner_pottery_sherd", + "minecraft:plenty_pottery_sherd", + "minecraft:prize_pottery_sherd", + "minecraft:scrape_pottery_sherd", + "minecraft:sheaf_pottery_sherd", + "minecraft:shelter_pottery_sherd", + "minecraft:skull_pottery_sherd", + "minecraft:snort_pottery_sherd" + ], + "minecraft:diamond_tier": [ + "minecraft:diamond_axe", + "minecraft:diamond_boots", + "minecraft:diamond_chestplate", + "minecraft:diamond_helmet", + "minecraft:diamond_hoe", + "minecraft:diamond_leggings", + "minecraft:diamond_pickaxe", + "minecraft:diamond_shovel", + "minecraft:diamond_sword", + "minecraft:mace" + ], + "minecraft:digger": [ + "minecraft:diamond_axe", + "minecraft:diamond_hoe", + "minecraft:diamond_pickaxe", + "minecraft:diamond_shovel", + "minecraft:golden_axe", + "minecraft:golden_hoe", + "minecraft:golden_pickaxe", + "minecraft:golden_shovel", + "minecraft:iron_axe", + "minecraft:iron_hoe", + "minecraft:iron_pickaxe", + "minecraft:iron_shovel", + "minecraft:netherite_axe", + "minecraft:netherite_hoe", + "minecraft:netherite_pickaxe", + "minecraft:netherite_shovel", + "minecraft:stone_axe", + "minecraft:stone_hoe", + "minecraft:stone_pickaxe", + "minecraft:stone_shovel", + "minecraft:wooden_axe", + "minecraft:wooden_hoe", + "minecraft:wooden_pickaxe", + "minecraft:wooden_shovel" + ], + "minecraft:door": [ + "minecraft:acacia_door", + "minecraft:bamboo_door", + "minecraft:birch_door", + "minecraft:cherry_door", + "minecraft:copper_door", + "minecraft:crimson_door", + "minecraft:dark_oak_door", + "minecraft:exposed_copper_door", + "minecraft:iron_door", + "minecraft:jungle_door", + "minecraft:mangrove_door", + "minecraft:oxidized_copper_door", + "minecraft:pale_oak_door", + "minecraft:spruce_door", + "minecraft:warped_door", + "minecraft:waxed_copper_door", + "minecraft:waxed_exposed_copper_door", + "minecraft:waxed_oxidized_copper_door", + "minecraft:waxed_weathered_copper_door", + "minecraft:weathered_copper_door", + "minecraft:wooden_door" + ], + "minecraft:golden_tier": [ + "minecraft:golden_axe", + "minecraft:golden_boots", + "minecraft:golden_chestplate", + "minecraft:golden_helmet", + "minecraft:golden_hoe", + "minecraft:golden_leggings", + "minecraft:golden_pickaxe", + "minecraft:golden_shovel", + "minecraft:golden_sword" + ], + "minecraft:hanging_actor": [ + "minecraft:painting" + ], + "minecraft:hanging_sign": [ + "minecraft:acacia_hanging_sign", + "minecraft:bamboo_hanging_sign", + "minecraft:birch_hanging_sign", + "minecraft:cherry_hanging_sign", + "minecraft:crimson_hanging_sign", + "minecraft:dark_oak_hanging_sign", + "minecraft:jungle_hanging_sign", + "minecraft:mangrove_hanging_sign", + "minecraft:oak_hanging_sign", + "minecraft:pale_oak_hanging_sign", + "minecraft:spruce_hanging_sign", + "minecraft:warped_hanging_sign" + ], + "minecraft:horse_armor": [ + "minecraft:diamond_horse_armor", + "minecraft:golden_horse_armor", + "minecraft:iron_horse_armor", + "minecraft:leather_horse_armor" + ], + "minecraft:iron_tier": [ + "minecraft:iron_axe", + "minecraft:iron_boots", + "minecraft:iron_chestplate", + "minecraft:iron_helmet", + "minecraft:iron_hoe", + "minecraft:iron_leggings", + "minecraft:iron_pickaxe", + "minecraft:iron_shovel", + "minecraft:iron_sword" + ], + "minecraft:is_armor": [ + "minecraft:chainmail_boots", + "minecraft:chainmail_chestplate", + "minecraft:chainmail_helmet", + "minecraft:chainmail_leggings", + "minecraft:diamond_boots", + "minecraft:diamond_chestplate", + "minecraft:diamond_helmet", + "minecraft:diamond_leggings", + "minecraft:elytra", + "minecraft:golden_boots", + "minecraft:golden_chestplate", + "minecraft:golden_helmet", + "minecraft:golden_leggings", + "minecraft:iron_boots", + "minecraft:iron_chestplate", + "minecraft:iron_helmet", + "minecraft:iron_leggings", + "minecraft:leather_boots", + "minecraft:leather_chestplate", + "minecraft:leather_helmet", + "minecraft:leather_leggings", + "minecraft:netherite_boots", + "minecraft:netherite_chestplate", + "minecraft:netherite_helmet", + "minecraft:netherite_leggings", + "minecraft:turtle_helmet" + ], + "minecraft:is_axe": [ + "minecraft:diamond_axe", + "minecraft:golden_axe", + "minecraft:iron_axe", + "minecraft:netherite_axe", + "minecraft:stone_axe", + "minecraft:wooden_axe" + ], + "minecraft:is_cooked": [ + "minecraft:cooked_beef", + "minecraft:cooked_chicken", + "minecraft:cooked_cod", + "minecraft:cooked_mutton", + "minecraft:cooked_porkchop", + "minecraft:cooked_rabbit", + "minecraft:cooked_salmon", + "minecraft:rabbit_stew" + ], + "minecraft:is_fish": [ + "minecraft:cod", + "minecraft:cooked_cod", + "minecraft:cooked_salmon", + "minecraft:pufferfish", + "minecraft:salmon", + "minecraft:tropical_fish" + ], + "minecraft:is_food": [ + "minecraft:apple", + "minecraft:baked_potato", + "minecraft:beef", + "minecraft:beetroot", + "minecraft:beetroot_soup", + "minecraft:bread", + "minecraft:carrot", + "minecraft:chicken", + "minecraft:cooked_beef", + "minecraft:cooked_chicken", + "minecraft:cooked_mutton", + "minecraft:cooked_porkchop", + "minecraft:cooked_rabbit", + "minecraft:cookie", + "minecraft:dried_kelp", + "minecraft:enchanted_golden_apple", + "minecraft:golden_apple", + "minecraft:golden_carrot", + "minecraft:melon_slice", + "minecraft:mushroom_stew", + "minecraft:mutton", + "minecraft:porkchop", + "minecraft:potato", + "minecraft:pumpkin_pie", + "minecraft:rabbit", + "minecraft:rabbit_stew", + "minecraft:rotten_flesh", + "minecraft:sweet_berries" + ], + "minecraft:is_hoe": [ + "minecraft:diamond_hoe", + "minecraft:golden_hoe", + "minecraft:iron_hoe", + "minecraft:netherite_hoe", + "minecraft:stone_hoe", + "minecraft:wooden_hoe" + ], + "minecraft:is_meat": [ + "minecraft:beef", + "minecraft:chicken", + "minecraft:cooked_beef", + "minecraft:cooked_chicken", + "minecraft:cooked_mutton", + "minecraft:cooked_porkchop", + "minecraft:cooked_rabbit", + "minecraft:mutton", + "minecraft:porkchop", + "minecraft:rabbit", + "minecraft:rabbit_stew", + "minecraft:rotten_flesh" + ], + "minecraft:is_minecart": [ + "minecraft:chest_minecart", + "minecraft:command_block_minecart", + "minecraft:hopper_minecart", + "minecraft:minecart", + "minecraft:tnt_minecart" + ], + "minecraft:is_pickaxe": [ + "minecraft:diamond_pickaxe", + "minecraft:golden_pickaxe", + "minecraft:iron_pickaxe", + "minecraft:netherite_pickaxe", + "minecraft:stone_pickaxe", + "minecraft:wooden_pickaxe" + ], + "minecraft:is_shears": [ + "minecraft:shears" + ], + "minecraft:is_shovel": [ + "minecraft:diamond_shovel", + "minecraft:golden_shovel", + "minecraft:iron_shovel", + "minecraft:netherite_shovel", + "minecraft:stone_shovel", + "minecraft:wooden_shovel" + ], + "minecraft:is_sword": [ + "minecraft:diamond_sword", + "minecraft:golden_sword", + "minecraft:iron_sword", + "minecraft:mace", + "minecraft:netherite_sword", + "minecraft:stone_sword", + "minecraft:wooden_sword" + ], + "minecraft:is_tool": [ + "minecraft:diamond_axe", + "minecraft:diamond_hoe", + "minecraft:diamond_pickaxe", + "minecraft:diamond_shovel", + "minecraft:diamond_sword", + "minecraft:golden_axe", + "minecraft:golden_hoe", + "minecraft:golden_pickaxe", + "minecraft:golden_shovel", + "minecraft:golden_sword", + "minecraft:iron_axe", + "minecraft:iron_hoe", + "minecraft:iron_pickaxe", + "minecraft:iron_shovel", + "minecraft:iron_sword", + "minecraft:mace", + "minecraft:netherite_axe", + "minecraft:netherite_hoe", + "minecraft:netherite_pickaxe", + "minecraft:netherite_shovel", + "minecraft:netherite_sword", + "minecraft:stone_axe", + "minecraft:stone_hoe", + "minecraft:stone_pickaxe", + "minecraft:stone_shovel", + "minecraft:stone_sword", + "minecraft:wooden_axe", + "minecraft:wooden_hoe", + "minecraft:wooden_pickaxe", + "minecraft:wooden_shovel", + "minecraft:wooden_sword" + ], + "minecraft:is_trident": [ + "minecraft:trident" + ], + "minecraft:leather_tier": [ + "minecraft:leather_boots", + "minecraft:leather_chestplate", + "minecraft:leather_helmet", + "minecraft:leather_leggings" + ], + "minecraft:lectern_books": [ + "minecraft:writable_book", + "minecraft:written_book" + ], + "minecraft:logs": [ + "minecraft:acacia_log", + "minecraft:acacia_wood", + "minecraft:birch_log", + "minecraft:birch_wood", + "minecraft:cherry_log", + "minecraft:cherry_wood", + "minecraft:crimson_hyphae", + "minecraft:crimson_stem", + "minecraft:dark_oak_log", + "minecraft:dark_oak_wood", + "minecraft:jungle_log", + "minecraft:jungle_wood", + "minecraft:mangrove_log", + "minecraft:mangrove_wood", + "minecraft:oak_log", + "minecraft:oak_wood", + "minecraft:pale_oak_log", + "minecraft:pale_oak_wood", + "minecraft:spruce_log", + "minecraft:spruce_wood", + "minecraft:stripped_acacia_log", + "minecraft:stripped_acacia_wood", + "minecraft:stripped_birch_log", + "minecraft:stripped_birch_wood", + "minecraft:stripped_cherry_log", + "minecraft:stripped_cherry_wood", + "minecraft:stripped_crimson_hyphae", + "minecraft:stripped_crimson_stem", + "minecraft:stripped_dark_oak_log", + "minecraft:stripped_dark_oak_wood", + "minecraft:stripped_jungle_log", + "minecraft:stripped_jungle_wood", + "minecraft:stripped_mangrove_log", + "minecraft:stripped_mangrove_wood", + "minecraft:stripped_oak_log", + "minecraft:stripped_oak_wood", + "minecraft:stripped_pale_oak_log", + "minecraft:stripped_pale_oak_wood", + "minecraft:stripped_spruce_log", + "minecraft:stripped_spruce_wood", + "minecraft:stripped_warped_hyphae", + "minecraft:stripped_warped_stem", + "minecraft:warped_hyphae", + "minecraft:warped_stem" + ], + "minecraft:logs_that_burn": [ + "minecraft:acacia_log", + "minecraft:acacia_wood", + "minecraft:birch_log", + "minecraft:birch_wood", + "minecraft:cherry_log", + "minecraft:cherry_wood", + "minecraft:dark_oak_log", + "minecraft:dark_oak_wood", + "minecraft:jungle_log", + "minecraft:jungle_wood", + "minecraft:mangrove_log", + "minecraft:mangrove_wood", + "minecraft:oak_log", + "minecraft:oak_wood", + "minecraft:pale_oak_log", + "minecraft:pale_oak_wood", + "minecraft:spruce_log", + "minecraft:spruce_wood", + "minecraft:stripped_acacia_log", + "minecraft:stripped_acacia_wood", + "minecraft:stripped_birch_log", + "minecraft:stripped_birch_wood", + "minecraft:stripped_cherry_log", + "minecraft:stripped_cherry_wood", + "minecraft:stripped_dark_oak_log", + "minecraft:stripped_dark_oak_wood", + "minecraft:stripped_jungle_log", + "minecraft:stripped_jungle_wood", + "minecraft:stripped_mangrove_log", + "minecraft:stripped_mangrove_wood", + "minecraft:stripped_oak_log", + "minecraft:stripped_oak_wood", + "minecraft:stripped_pale_oak_log", + "minecraft:stripped_pale_oak_wood", + "minecraft:stripped_spruce_log", + "minecraft:stripped_spruce_wood" + ], + "minecraft:mangrove_logs": [ + "minecraft:mangrove_log", + "minecraft:mangrove_wood", + "minecraft:stripped_mangrove_log", + "minecraft:stripped_mangrove_wood" + ], + "minecraft:music_disc": [ + "minecraft:music_disc_11", + "minecraft:music_disc_13", + "minecraft:music_disc_5", + "minecraft:music_disc_blocks", + "minecraft:music_disc_cat", + "minecraft:music_disc_chirp", + "minecraft:music_disc_creator", + "minecraft:music_disc_creator_music_box", + "minecraft:music_disc_far", + "minecraft:music_disc_mall", + "minecraft:music_disc_mellohi", + "minecraft:music_disc_otherside", + "minecraft:music_disc_pigstep", + "minecraft:music_disc_precipice", + "minecraft:music_disc_relic", + "minecraft:music_disc_stal", + "minecraft:music_disc_strad", + "minecraft:music_disc_wait", + "minecraft:music_disc_ward" + ], + "minecraft:netherite_tier": [ + "minecraft:netherite_axe", + "minecraft:netherite_boots", + "minecraft:netherite_chestplate", + "minecraft:netherite_helmet", + "minecraft:netherite_hoe", + "minecraft:netherite_leggings", + "minecraft:netherite_pickaxe", + "minecraft:netherite_shovel", + "minecraft:netherite_sword" + ], + "minecraft:planks": [ + "minecraft:acacia_planks", + "minecraft:bamboo_planks", + "minecraft:birch_planks", + "minecraft:cherry_planks", + "minecraft:crimson_planks", + "minecraft:dark_oak_planks", + "minecraft:jungle_planks", + "minecraft:mangrove_planks", + "minecraft:oak_planks", + "minecraft:pale_oak_planks", + "minecraft:spruce_planks", + "minecraft:warped_planks" + ], + "minecraft:sand": [ + "minecraft:red_sand", + "minecraft:sand" + ], + "minecraft:sign": [ + "minecraft:acacia_hanging_sign", + "minecraft:acacia_sign", + "minecraft:bamboo_hanging_sign", + "minecraft:bamboo_sign", + "minecraft:birch_hanging_sign", + "minecraft:birch_sign", + "minecraft:cherry_hanging_sign", + "minecraft:cherry_sign", + "minecraft:crimson_hanging_sign", + "minecraft:crimson_sign", + "minecraft:dark_oak_hanging_sign", + "minecraft:dark_oak_sign", + "minecraft:jungle_hanging_sign", + "minecraft:jungle_sign", + "minecraft:mangrove_hanging_sign", + "minecraft:mangrove_sign", + "minecraft:oak_hanging_sign", + "minecraft:oak_sign", + "minecraft:pale_oak_hanging_sign", + "minecraft:pale_oak_sign", + "minecraft:spruce_hanging_sign", + "minecraft:spruce_sign", + "minecraft:warped_hanging_sign", + "minecraft:warped_sign" + ], + "minecraft:soul_fire_base_blocks": [ + "minecraft:soul_sand", + "minecraft:soul_soil" + ], + "minecraft:spawn_egg": [ + "minecraft:agent_spawn_egg", + "minecraft:allay_spawn_egg", + "minecraft:armadillo_spawn_egg", + "minecraft:axolotl_spawn_egg", + "minecraft:bat_spawn_egg", + "minecraft:bee_spawn_egg", + "minecraft:blaze_spawn_egg", + "minecraft:bogged_spawn_egg", + "minecraft:breeze_spawn_egg", + "minecraft:camel_spawn_egg", + "minecraft:cat_spawn_egg", + "minecraft:cave_spider_spawn_egg", + "minecraft:chicken_spawn_egg", + "minecraft:cod_spawn_egg", + "minecraft:cow_spawn_egg", + "minecraft:creaking_spawn_egg", + "minecraft:creeper_spawn_egg", + "minecraft:dolphin_spawn_egg", + "minecraft:donkey_spawn_egg", + "minecraft:drowned_spawn_egg", + "minecraft:elder_guardian_spawn_egg", + "minecraft:ender_dragon_spawn_egg", + "minecraft:enderman_spawn_egg", + "minecraft:endermite_spawn_egg", + "minecraft:evoker_spawn_egg", + "minecraft:fox_spawn_egg", + "minecraft:frog_spawn_egg", + "minecraft:ghast_spawn_egg", + "minecraft:glow_squid_spawn_egg", + "minecraft:goat_spawn_egg", + "minecraft:guardian_spawn_egg", + "minecraft:hoglin_spawn_egg", + "minecraft:horse_spawn_egg", + "minecraft:husk_spawn_egg", + "minecraft:iron_golem_spawn_egg", + "minecraft:llama_spawn_egg", + "minecraft:magma_cube_spawn_egg", + "minecraft:mooshroom_spawn_egg", + "minecraft:mule_spawn_egg", + "minecraft:npc_spawn_egg", + "minecraft:ocelot_spawn_egg", + "minecraft:panda_spawn_egg", + "minecraft:parrot_spawn_egg", + "minecraft:phantom_spawn_egg", + "minecraft:pig_spawn_egg", + "minecraft:piglin_brute_spawn_egg", + "minecraft:piglin_spawn_egg", + "minecraft:pillager_spawn_egg", + "minecraft:polar_bear_spawn_egg", + "minecraft:pufferfish_spawn_egg", + "minecraft:rabbit_spawn_egg", + "minecraft:ravager_spawn_egg", + "minecraft:salmon_spawn_egg", + "minecraft:sheep_spawn_egg", + "minecraft:shulker_spawn_egg", + "minecraft:silverfish_spawn_egg", + "minecraft:skeleton_horse_spawn_egg", + "minecraft:skeleton_spawn_egg", + "minecraft:slime_spawn_egg", + "minecraft:sniffer_spawn_egg", + "minecraft:snow_golem_spawn_egg", + "minecraft:spawn_egg", + "minecraft:spider_spawn_egg", + "minecraft:squid_spawn_egg", + "minecraft:stray_spawn_egg", + "minecraft:strider_spawn_egg", + "minecraft:tadpole_spawn_egg", + "minecraft:trader_llama_spawn_egg", + "minecraft:tropical_fish_spawn_egg", + "minecraft:turtle_spawn_egg", + "minecraft:vex_spawn_egg", + "minecraft:villager_spawn_egg", + "minecraft:vindicator_spawn_egg", + "minecraft:wandering_trader_spawn_egg", + "minecraft:warden_spawn_egg", + "minecraft:witch_spawn_egg", + "minecraft:wither_skeleton_spawn_egg", + "minecraft:wither_spawn_egg", + "minecraft:wolf_spawn_egg", + "minecraft:zoglin_spawn_egg", + "minecraft:zombie_horse_spawn_egg", + "minecraft:zombie_pigman_spawn_egg", + "minecraft:zombie_spawn_egg", + "minecraft:zombie_villager_spawn_egg" + ], + "minecraft:stone_bricks": [ + "minecraft:chiseled_stone_bricks", + "minecraft:cracked_stone_bricks", + "minecraft:mossy_stone_bricks", + "minecraft:stone_bricks" + ], + "minecraft:stone_crafting_materials": [ + "minecraft:blackstone", + "minecraft:cobbled_deepslate", + "minecraft:cobblestone" + ], + "minecraft:stone_tier": [ + "minecraft:stone_axe", + "minecraft:stone_hoe", + "minecraft:stone_pickaxe", + "minecraft:stone_shovel", + "minecraft:stone_sword" + ], + "minecraft:stone_tool_materials": [ + "minecraft:blackstone", + "minecraft:cobbled_deepslate", + "minecraft:cobblestone" + ], + "minecraft:transform_materials": [ + "minecraft:netherite_ingot" + ], + "minecraft:transform_templates": [ + "minecraft:netherite_upgrade_smithing_template" + ], + "minecraft:transformable_items": [ + "minecraft:diamond_axe", + "minecraft:diamond_boots", + "minecraft:diamond_chestplate", + "minecraft:diamond_helmet", + "minecraft:diamond_hoe", + "minecraft:diamond_leggings", + "minecraft:diamond_pickaxe", + "minecraft:diamond_shovel", + "minecraft:diamond_sword", + "minecraft:golden_boots" + ], + "minecraft:trim_materials": [ + "minecraft:amethyst_shard", + "minecraft:copper_ingot", + "minecraft:diamond", + "minecraft:emerald", + "minecraft:gold_ingot", + "minecraft:iron_ingot", + "minecraft:lapis_lazuli", + "minecraft:netherite_ingot", + "minecraft:quartz", + "minecraft:redstone", + "minecraft:resin_brick" + ], + "minecraft:trim_templates": [ + "minecraft:bolt_armor_trim_smithing_template", + "minecraft:coast_armor_trim_smithing_template", + "minecraft:dune_armor_trim_smithing_template", + "minecraft:eye_armor_trim_smithing_template", + "minecraft:flow_armor_trim_smithing_template", + "minecraft:host_armor_trim_smithing_template", + "minecraft:raiser_armor_trim_smithing_template", + "minecraft:rib_armor_trim_smithing_template", + "minecraft:sentry_armor_trim_smithing_template", + "minecraft:shaper_armor_trim_smithing_template", + "minecraft:silence_armor_trim_smithing_template", + "minecraft:snout_armor_trim_smithing_template", + "minecraft:spire_armor_trim_smithing_template", + "minecraft:tide_armor_trim_smithing_template", + "minecraft:vex_armor_trim_smithing_template", + "minecraft:ward_armor_trim_smithing_template", + "minecraft:wayfinder_armor_trim_smithing_template", + "minecraft:wild_armor_trim_smithing_template" + ], + "minecraft:trimmable_armors": [ + "minecraft:chainmail_boots", + "minecraft:chainmail_chestplate", + "minecraft:chainmail_helmet", + "minecraft:chainmail_leggings", + "minecraft:diamond_boots", + "minecraft:diamond_chestplate", + "minecraft:diamond_helmet", + "minecraft:diamond_leggings", + "minecraft:golden_boots", + "minecraft:golden_chestplate", + "minecraft:golden_helmet", + "minecraft:golden_leggings", + "minecraft:iron_boots", + "minecraft:iron_chestplate", + "minecraft:iron_helmet", + "minecraft:iron_leggings", + "minecraft:leather_boots", + "minecraft:leather_chestplate", + "minecraft:leather_helmet", + "minecraft:leather_leggings", + "minecraft:netherite_boots", + "minecraft:netherite_chestplate", + "minecraft:netherite_helmet", + "minecraft:netherite_leggings", + "minecraft:turtle_helmet" + ], + "minecraft:vibration_damper": [ + "minecraft:black_carpet", + "minecraft:black_wool", + "minecraft:blue_carpet", + "minecraft:blue_wool", + "minecraft:brown_carpet", + "minecraft:brown_wool", + "minecraft:cyan_carpet", + "minecraft:cyan_wool", + "minecraft:gray_carpet", + "minecraft:gray_wool", + "minecraft:green_carpet", + "minecraft:green_wool", + "minecraft:light_blue_carpet", + "minecraft:light_blue_wool", + "minecraft:light_gray_carpet", + "minecraft:light_gray_wool", + "minecraft:lime_carpet", + "minecraft:lime_wool", + "minecraft:magenta_carpet", + "minecraft:magenta_wool", + "minecraft:orange_carpet", + "minecraft:orange_wool", + "minecraft:pink_carpet", + "minecraft:pink_wool", + "minecraft:purple_carpet", + "minecraft:purple_wool", + "minecraft:red_carpet", + "minecraft:red_wool", + "minecraft:white_carpet", + "minecraft:white_wool", + "minecraft:yellow_carpet", + "minecraft:yellow_wool" + ], + "minecraft:warped_stems": [ + "minecraft:stripped_warped_hyphae", + "minecraft:stripped_warped_stem", + "minecraft:warped_hyphae", + "minecraft:warped_stem" + ], + "minecraft:wooden_slabs": [ + "minecraft:acacia_slab", + "minecraft:bamboo_slab", + "minecraft:birch_slab", + "minecraft:cherry_slab", + "minecraft:crimson_slab", + "minecraft:dark_oak_slab", + "minecraft:jungle_slab", + "minecraft:mangrove_slab", + "minecraft:oak_slab", + "minecraft:pale_oak_slab", + "minecraft:spruce_slab", + "minecraft:warped_slab" + ], + "minecraft:wooden_tier": [ + "minecraft:wooden_axe", + "minecraft:wooden_hoe", + "minecraft:wooden_pickaxe", + "minecraft:wooden_shovel", + "minecraft:wooden_sword" + ], + "minecraft:wool": [ + "minecraft:black_wool", + "minecraft:blue_wool", + "minecraft:brown_wool", + "minecraft:cyan_wool", + "minecraft:gray_wool", + "minecraft:green_wool", + "minecraft:light_blue_wool", + "minecraft:light_gray_wool", + "minecraft:lime_wool", + "minecraft:magenta_wool", + "minecraft:orange_wool", + "minecraft:pink_wool", + "minecraft:purple_wool", + "minecraft:red_wool", + "minecraft:white_wool", + "minecraft:yellow_wool" + ] +} diff --git a/server/item/recipe/potion_data.nbt b/server/item/recipe/potion_data.nbt new file mode 100644 index 0000000..3b9f707 Binary files /dev/null and b/server/item/recipe/potion_data.nbt differ diff --git a/server/item/recipe/recipe.go b/server/item/recipe/recipe.go new file mode 100644 index 0000000..63d5b3f --- /dev/null +++ b/server/item/recipe/recipe.go @@ -0,0 +1,163 @@ +package recipe + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// Recipe is implemented by all recipe types. +type Recipe interface { + // Input returns the items required to craft the recipe. + Input() []Item + // Output returns the items that are produced when the recipe is crafted. + Output() []item.Stack + // Block returns the block that is used to craft the recipe. + Block() string + // Priority returns the priority of the recipe. Recipes with lower priority are preferred compared to recipes with + // higher priority. + Priority() uint32 +} + +// DynamicRecipe represents a recipe whose output depends on the specific items used in crafting. +// These recipes are not sent to the client and are validated server-side. +type DynamicRecipe interface { + // Match checks if the given input items match this dynamic recipe pattern. + // It returns true if the pattern matches, along with the computed output items. + Match(input []Item) (output []item.Stack, ok bool) + // Block returns the block that is used to craft the recipe. + Block() string +} + +// Shapeless is a recipe that has no particular shape. +type Shapeless struct { + recipe +} + +// NewShapeless creates a new shapeless recipe and returns it. The recipe can only be crafted on the block passed in the +// parameters. If the block given a crafting table, the recipe can also be crafted in the 2x2 crafting grid in the +// player's inventory. +func NewShapeless(input []Item, output item.Stack, block string) Shapeless { + return Shapeless{recipe: recipe{ + input: input, + output: []item.Stack{output}, + block: block, + }} +} + +// SmithingTransform represents a recipe only craftable on a smithing table. +type SmithingTransform struct { + recipe +} + +// NewSmithingTransform creates a new smithing recipe and returns it. +func NewSmithingTransform(base, addition, template Item, output item.Stack, block string) SmithingTransform { + return SmithingTransform{recipe: recipe{ + input: []Item{base, addition, template}, + output: []item.Stack{output}, + block: block, + }} +} + +// SmithingTrim represents a recipe only craftable on a smithing table using an armour trim. +type SmithingTrim struct { + recipe +} + +// NewSmithingTrim creates a new smithing trim recipe and returns it. This is +// almost identical to SmithingTransform except there is no output item. +func NewSmithingTrim(base, addition, template Item, block string) SmithingTrim { + return SmithingTrim{recipe: recipe{ + input: []Item{base, addition, template}, + block: block, + }} +} + +// PotionContainerChange is a recipe to convert a potion from one type to another, such as from a drinkable potion to a +// splash potion, or from a splash potion to a lingering potion. +type PotionContainerChange struct { + recipe +} + +// NewPotionContainerChange creates a new potion container change recipe and returns it. +func NewPotionContainerChange(input, output world.Item, reagent item.Stack) PotionContainerChange { + return PotionContainerChange{recipe: recipe{ + input: []Item{item.NewStack(input, 1), reagent}, + output: []item.Stack{item.NewStack(output, 1)}, + block: "brewing_stand", + }} +} + +// Potion is a potion mixing recipe which may be used in the brewing stand. +type Potion struct { + recipe +} + +// NewPotion creates a new potion recipe and returns it. +func NewPotion(input, reagent Item, output item.Stack) Potion { + return Potion{recipe: recipe{ + input: []Item{input, reagent}, + output: []item.Stack{output}, + block: "brewing_stand", + }} +} + +// Shaped is a recipe that has a specific shape that must be used to craft the output of the recipe. +type Shaped struct { + recipe + // shape contains the width and height of the shaped recipe. + shape Shape +} + +// NewShaped creates a new shaped recipe and returns it. The recipe can only be crafted on the block passed in the +// parameters. If the block given a crafting table, the recipe can also be crafted in the 2x2 crafting grid in the +// player's inventory. If nil is passed, the block will be autofilled as a crafting table. The inputs must always match +// the width*height of the shape. +func NewShaped(input []Item, output item.Stack, shape Shape, block string) Shaped { + return Shaped{ + shape: shape, + recipe: recipe{ + input: input, + output: []item.Stack{output}, + block: block, + }, + } +} + +// Shape returns the shape of the recipe. +func (r Shaped) Shape() Shape { + return r.shape +} + +// recipe implements the Recipe interface. Structs in this package may embed it to gets its functionality +// out of the box. +type recipe struct { + // input is a list of items that serve as the input of the shaped recipe. These items are the items + // required to craft the output. The amount of input items must be exactly equal to Width * Height. + input []Item + // output contains items that are created as a result of crafting the recipe. + output []item.Stack + // block is the block that is used to craft the recipe. + block string + // priority is the priority of the recipe versus others. + priority uint32 +} + +// Input ... +func (r recipe) Input() []Item { + return r.input +} + +// Output ... +func (r recipe) Output() []item.Stack { + return r.output +} + +// Block ... +func (r recipe) Block() string { + return r.block +} + +// Priority ... +func (r recipe) Priority() uint32 { + return r.priority +} diff --git a/server/item/recipe/register.go b/server/item/recipe/register.go new file mode 100644 index 0000000..f8fe0bc --- /dev/null +++ b/server/item/recipe/register.go @@ -0,0 +1,130 @@ +package recipe + +import ( + "github.com/df-mc/dragonfly/server/internal/sliceutil" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "slices" + "sort" + "strings" + "unsafe" +) + +// recipes is a list of each recipe. +var ( + recipes []Recipe + // dynamicRecipes is a list of each dynamic recipe. + dynamicRecipes []DynamicRecipe + // index maps an input hash to output stacks for each PotionContainerChange and Potion recipe. + index = make(map[string]map[string]Recipe) + // reagent maps the item name and an item.Stack. + reagent = make(map[string]item.Stack) +) + +// Recipes returns each recipe in a slice. +func Recipes() []Recipe { + return slices.Clone(recipes) +} + +// DynamicRecipes returns each dynamic recipe in a slice. +func DynamicRecipes() []DynamicRecipe { + return slices.Clone(dynamicRecipes) +} + +// Register registers a new recipe. +func Register(recipe Recipe) { + recipes = append(recipes, recipe) + + _, ok := recipe.(PotionContainerChange) + p, okTwo := recipe.(Potion) + + if okTwo { + stack := p.Input()[1].(item.Stack) + name, _ := stack.Item().EncodeItem() + reagent[name] = stack + } + + if ok || okTwo { + input := make([]world.Item, len(recipe.Input())) + for i, stack := range recipe.Input() { + if s, ok := stack.(item.Stack); ok { + input[i] = s.Item() + } + } + hash := hashItems(input, !ok) + + block := recipe.Block() + if index[block] == nil { + index[block] = make(map[string]Recipe) + } + index[block][hash] = recipe + } +} + +// Perform performs the recipe with the given block and inputs and returns the outputs. If the inputs do not map to +// any outputs, false is returned for the second return value. +func Perform(block string, input ...world.Item) (output []item.Stack, ok bool) { + blockInd, ok := index[block] + if !ok { + // Block specific index didn't exist. + return nil, false + } + r, ok := blockInd[hashItems(input, true)] + if !ok { + r, ok = blockInd[hashItems(input, false)] + if !ok { + return nil, false + } + } + _, containerChange := r.(PotionContainerChange) + for ind, it := range r.Output() { + if containerChange { + name, _ := it.Item().EncodeItem() + _, meta := input[ind].EncodeItem() + if i, ok := world.ItemByName(name, meta); ok { + it = item.NewStack(i, it.Count()) + } + } + output = append(output, it) + } + return output, ok +} + +// hashItems hashes the given list of item types and returns it. +func hashItems(items []world.Item, useMeta bool) string { + items = sliceutil.Filter(items, func(it world.Item) bool { + return it != nil + }) + sort.Slice(items, func(i, j int) bool { + nameOne, metaOne := items[i].EncodeItem() + nameTwo, metaTwo := items[j].EncodeItem() + if nameOne == nameTwo { + return metaOne < metaTwo + } + return nameOne < nameTwo + }) + + var b strings.Builder + for _, it := range items { + name, meta := it.EncodeItem() + b.WriteString(name) + if useMeta { + a := *(*[2]byte)(unsafe.Pointer(&meta)) + b.Write(a[:]) + } + } + return b.String() +} + +// ValidBrewingReagent checks if the world.Item is a brewing reagent. +func ValidBrewingReagent(i world.Item) bool { + name, _ := i.EncodeItem() + _, exists := reagent[name] + return exists +} + +// RegisterDynamic registers a new dynamic recipe. Dynamic recipes are not sent to the client +// and are validated server-side. +func RegisterDynamic(recipe DynamicRecipe) { + dynamicRecipes = append(dynamicRecipes, recipe) +} diff --git a/server/item/recipe/shape.go b/server/item/recipe/shape.go new file mode 100644 index 0000000..163ffa2 --- /dev/null +++ b/server/item/recipe/shape.go @@ -0,0 +1,19 @@ +package recipe + +// Shape make up the shape of a shaped recipe. It consists of a width and a height. +type Shape [2]int + +// Width returns the width of the shape. +func (s Shape) Width() int { + return s[0] +} + +// Height returns the height of the shape. +func (s Shape) Height() int { + return s[1] +} + +// NewShape creates a new shape using the provided width and height. +func NewShape(width, height int) Shape { + return Shape{width, height} +} diff --git a/server/item/recipe/smithing_data.nbt b/server/item/recipe/smithing_data.nbt new file mode 100644 index 0000000..0c84b8d Binary files /dev/null and b/server/item/recipe/smithing_data.nbt differ diff --git a/server/item/recipe/smithing_trim_data.nbt b/server/item/recipe/smithing_trim_data.nbt new file mode 100644 index 0000000..fc74a83 Binary files /dev/null and b/server/item/recipe/smithing_trim_data.nbt differ diff --git a/server/item/recipe/vanilla.go b/server/item/recipe/vanilla.go new file mode 100644 index 0000000..0b1af9f --- /dev/null +++ b/server/item/recipe/vanilla.go @@ -0,0 +1,181 @@ +package recipe + +import ( + _ "embed" + + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/nbt" +) + +var ( + //go:embed crafting_data.nbt + vanillaCraftingData []byte + //go:embed smithing_data.nbt + vanillaSmithingData []byte + //go:embed smithing_trim_data.nbt + vanillaSmithingTrimData []byte + //go:embed potion_data.nbt + vanillaPotionData []byte +) + +// shapedRecipe is a recipe that must be crafted in a specific shape. +type shapedRecipe struct { + Input inputItems `nbt:"input"` + Output outputItems `nbt:"output"` + Block string `nbt:"block"` + Width int32 `nbt:"width"` + Height int32 `nbt:"height"` + Priority int32 `nbt:"priority"` +} + +// shapelessRecipe is a recipe that may be crafted without a strict shape. +type shapelessRecipe struct { + Input inputItems `nbt:"input"` + Output outputItems `nbt:"output"` + Block string `nbt:"block"` + Priority int32 `nbt:"priority"` +} + +// potionRecipe is a recipe that may be crafted in a brewing stand. +type potionRecipe struct { + Input inputItem `nbt:"input"` + Reagent inputItem `nbt:"reagent"` + Output outputItem `nbt:"output"` +} + +// potionContainerChangeRecipe is a recipe that may be crafted in a brewing stand. +type potionContainerChangeRecipe struct { + Input string `nbt:"input"` + Reagent inputItem `nbt:"reagent"` + Output string `nbt:"output"` +} + +// registerVanilla can be called to register all vanilla recipes from the generated data files. +// noinspection GoUnusedFunction +// +//lint:ignore U1000 Function is used through compiler directives. +func registerVanilla() { + var craftingRecipes struct { + Shaped []shapedRecipe `nbt:"shaped"` + Shapeless []shapelessRecipe `nbt:"shapeless"` + } + if err := nbt.Unmarshal(vanillaCraftingData, &craftingRecipes); err != nil { + panic(err) + } + + for _, s := range craftingRecipes.Shapeless { + input, ok := s.Input.Items() + output, okTwo := s.Output.Stacks() + if !ok || !okTwo { + // This can be expected to happen, as some recipes contain blocks or items that aren't currently implemented. + continue + } + Register(Shapeless{recipe{ + input: input, + output: output, + block: s.Block, + priority: uint32(s.Priority), + }}) + } + + for _, s := range craftingRecipes.Shaped { + input, ok := s.Input.Items() + output, okTwo := s.Output.Stacks() + if !ok || !okTwo { + // This can be expected to happen - refer to the comment above. + continue + } + Register(Shaped{ + shape: Shape{int(s.Width), int(s.Height)}, + recipe: recipe{ + input: input, + output: output, + block: s.Block, + priority: uint32(s.Priority), + }, + }) + } + + var smithingRecipes []shapelessRecipe + if err := nbt.Unmarshal(vanillaSmithingData, &smithingRecipes); err != nil { + panic(err) + } + + for _, s := range smithingRecipes { + input, ok := s.Input.Items() + output, okTwo := s.Output.Stacks() + if !ok || !okTwo { + // This can be expected to happen - refer to the comment above. + continue + } + Register(SmithingTransform{recipe{ + input: input, + output: output, + block: s.Block, + priority: uint32(s.Priority), + }}) + } + + var smithingTrimRecipes []shapelessRecipe + if err := nbt.Unmarshal(vanillaSmithingTrimData, &smithingTrimRecipes); err != nil { + panic(err) + } + + for _, s := range smithingTrimRecipes { + input, ok := s.Input.Items() + if !ok { + // This can be expected to happen - refer to the comment above. + continue + } + Register(SmithingTrim{recipe{ + input: input, + block: s.Block, + priority: uint32(s.Priority), + }}) + } + + var potionRecipes struct { + Potions []potionRecipe `nbt:"potions"` + ContainerChanges []potionContainerChangeRecipe `nbt:"container_changes"` + } + + if err := nbt.Unmarshal(vanillaPotionData, &potionRecipes); err != nil { + panic(err) + } + + for _, r := range potionRecipes.Potions { + input, ok := r.Input.Item() + reagent, okTwo := r.Reagent.Item() + output, okThree := r.Output.Stack() + if !ok || !okTwo || !okThree { + // This can be expected to happen - refer to the comment above. + continue + } + + Register(Potion{recipe{ + input: []Item{input, reagent}, + output: []item.Stack{output}, + block: "brewing_stand", + }}) + } + + for _, c := range potionRecipes.ContainerChanges { + input, ok := world.ItemByName(c.Input, 0) + reagent, okTwo := c.Reagent.Item() + output, okThree := world.ItemByName(c.Output, 0) + if !ok || !okTwo || !okThree { + // This can be expected to happen - refer to the comment above. + continue + } + + Register(PotionContainerChange{recipe{ + input: []Item{item.NewStack(input, 1), reagent}, + output: []item.Stack{item.NewStack(output, 1)}, + block: "brewing_stand", + }}) + } + + // Register dynamic recipes + RegisterDynamic(NewDecoratedPotRecipe()) +} diff --git a/server/item/recovery_compass.go b/server/item/recovery_compass.go new file mode 100644 index 0000000..3f482d9 --- /dev/null +++ b/server/item/recovery_compass.go @@ -0,0 +1,9 @@ +package item + +// RecoveryCompass is an item used to point to the location of the player's last death. +type RecoveryCompass struct{} + +// EncodeItem ... +func (RecoveryCompass) EncodeItem() (name string, meta int16) { + return "minecraft:recovery_compass", 0 +} diff --git a/server/item/redstone_wire.go b/server/item/redstone_wire.go new file mode 100644 index 0000000..1c8e4c1 --- /dev/null +++ b/server/item/redstone_wire.go @@ -0,0 +1,20 @@ +package item + +import "github.com/sandertv/gophertunnel/minecraft/text" + +type RedstoneWire struct{} + +// EncodeItem ... +func (RedstoneWire) EncodeItem() (name string, meta int16) { + return "minecraft:redstone", 0 +} + +// TrimMaterial ... +func (RedstoneWire) TrimMaterial() string { + return "redstone" +} + +// MaterialColour ... +func (RedstoneWire) MaterialColour() string { + return text.Redstone +} diff --git a/server/item/register.go b/server/item/register.go new file mode 100644 index 0000000..786af85 --- /dev/null +++ b/server/item/register.go @@ -0,0 +1,172 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/item/potion" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" +) + +// noinspection SpellCheckingInspection +func init() { + world.RegisterItem(AmethystShard{}) + world.RegisterItem(Apple{}) + world.RegisterItem(Arrow{}) + world.RegisterItem(BakedPotato{}) + world.RegisterItem(Beef{Cooked: true}) + world.RegisterItem(Beef{}) + world.RegisterItem(BeetrootSoup{}) + world.RegisterItem(Beetroot{}) + world.RegisterItem(BlazePowder{}) + world.RegisterItem(BlazeRod{}) + world.RegisterItem(BoneMeal{}) + world.RegisterItem(Bone{}) + world.RegisterItem(BookAndQuill{}) + world.RegisterItem(Book{}) + world.RegisterItem(BottleOfEnchanting{}) + world.RegisterItem(Bowl{}) + world.RegisterItem(Bow{}) + world.RegisterItem(Bread{}) + world.RegisterItem(Brick{}) + world.RegisterItem(Bucket{}) + world.RegisterItem(CarrotOnAStick{}) + world.RegisterItem(Charcoal{}) + world.RegisterItem(Chicken{Cooked: true}) + world.RegisterItem(Chicken{}) + world.RegisterItem(ClayBall{}) + world.RegisterItem(Clock{}) + world.RegisterItem(Coal{}) + world.RegisterItem(Cod{Cooked: true}) + world.RegisterItem(Cod{}) + world.RegisterItem(Compass{}) + world.RegisterItem(Cookie{}) + world.RegisterItem(CopperIngot{}) + world.RegisterItem(CopperNugget{}) + world.RegisterItem(Crossbow{}) + world.RegisterItem(Diamond{}) + world.RegisterItem(DiscFragment{}) + world.RegisterItem(DragonBreath{}) + world.RegisterItem(DriedKelp{}) + world.RegisterItem(EchoShard{}) + world.RegisterItem(Egg{}) + world.RegisterItem(Elytra{}) + world.RegisterItem(Emerald{}) + world.RegisterItem(EnchantedApple{}) + world.RegisterItem(EnchantedBook{}) + world.RegisterItem(EnderPearl{}) + world.RegisterItem(Feather{}) + world.RegisterItem(FermentedSpiderEye{}) + world.RegisterItem(FireCharge{}) + world.RegisterItem(Firework{}) + world.RegisterItem(FlintAndSteel{}) + world.RegisterItem(Flint{}) + world.RegisterItem(GhastTear{}) + world.RegisterItem(GlassBottle{}) + world.RegisterItem(GlisteringMelonSlice{}) + world.RegisterItem(GlowstoneDust{}) + world.RegisterItem(GoldIngot{}) + world.RegisterItem(GoldNugget{}) + world.RegisterItem(GoldenApple{}) + world.RegisterItem(GoldenCarrot{}) + world.RegisterItem(Gunpowder{}) + world.RegisterItem(HeartOfTheSea{}) + world.RegisterItem(HoneyBottle{}) + world.RegisterItem(Honeycomb{}) + world.RegisterItem(InkSac{Glowing: true}) + world.RegisterItem(InkSac{}) + world.RegisterItem(IronIngot{}) + world.RegisterItem(IronNugget{}) + world.RegisterItem(LapisLazuli{}) + world.RegisterItem(Leather{}) + world.RegisterItem(MagmaCream{}) + world.RegisterItem(MelonSlice{}) + world.RegisterItem(MushroomStew{}) + world.RegisterItem(Mutton{Cooked: true}) + world.RegisterItem(Mutton{}) + world.RegisterItem(NautilusShell{}) + world.RegisterItem(NetherBrick{}) + world.RegisterItem(NetherQuartz{}) + world.RegisterItem(NetherStar{}) + world.RegisterItem(NetheriteIngot{}) + world.RegisterItem(NetheriteScrap{}) + world.RegisterItem(Paper{}) + world.RegisterItem(PhantomMembrane{}) + world.RegisterItem(PoisonousPotato{}) + world.RegisterItem(PoppedChorusFruit{}) + world.RegisterItem(Porkchop{Cooked: true}) + world.RegisterItem(Porkchop{}) + world.RegisterItem(PrismarineCrystals{}) + world.RegisterItem(PrismarineShard{}) + world.RegisterItem(Pufferfish{}) + world.RegisterItem(PumpkinPie{}) + world.RegisterItem(RabbitFoot{}) + world.RegisterItem(RabbitHide{}) + world.RegisterItem(RabbitStew{}) + world.RegisterItem(Rabbit{Cooked: true}) + world.RegisterItem(Rabbit{}) + world.RegisterItem(RawCopper{}) + world.RegisterItem(RawGold{}) + world.RegisterItem(RawIron{}) + world.RegisterItem(RecoveryCompass{}) + world.RegisterItem(ResinBrick{}) + world.RegisterItem(RottenFlesh{}) + world.RegisterItem(Salmon{Cooked: true}) + world.RegisterItem(Salmon{}) + world.RegisterItem(Scute{}) + world.RegisterItem(Shears{}) + world.RegisterItem(ShulkerShell{}) + world.RegisterItem(Slimeball{}) + world.RegisterItem(Snowball{}) + world.RegisterItem(SpiderEye{}) + world.RegisterItem(Spyglass{}) + world.RegisterItem(Stick{}) + world.RegisterItem(Sugar{}) + world.RegisterItem(Totem{}) + world.RegisterItem(TropicalFish{}) + world.RegisterItem(TurtleShell{}) + world.RegisterItem(WarpedFungusOnAStick{}) + world.RegisterItem(Wheat{}) + world.RegisterItem(WrittenBook{}) + for _, t := range ArmourTiers() { + world.RegisterItem(Helmet{Tier: t}) + world.RegisterItem(Chestplate{Tier: t}) + world.RegisterItem(Leggings{Tier: t}) + world.RegisterItem(Boots{Tier: t}) + } + for _, t := range SmithingTemplates() { + world.RegisterItem(SmithingTemplate{Template: t}) + } + for _, pattern := range BannerPatterns() { + world.RegisterItem(BannerPattern{Type: pattern}) + } + for _, c := range Colours() { + world.RegisterItem(Dye{Colour: c}) + world.RegisterItem(FireworkStar{FireworkExplosion: FireworkExplosion{Colour: c}}) + } + for _, horn := range sound.GoatHorns() { + world.RegisterItem(GoatHorn{Type: horn}) + } + for i, p := range potion.All() { + if i > 4 { + world.RegisterItem(Arrow{Tip: p}) + } + world.RegisterItem(LingeringPotion{Type: p}) + world.RegisterItem(SplashPotion{Type: p}) + world.RegisterItem(Potion{Type: p}) + } + for _, t := range ToolTiers() { + world.RegisterItem(Pickaxe{Tier: t}) + world.RegisterItem(Axe{Tier: t}) + world.RegisterItem(Shovel{Tier: t}) + world.RegisterItem(Sword{Tier: t}) + world.RegisterItem(Hoe{Tier: t}) + } + for _, disc := range sound.MusicDiscs() { + world.RegisterItem(MusicDisc{DiscType: disc}) + } + for _, stew := range StewTypes() { + world.RegisterItem(SuspiciousStew{Type: stew}) + } + for _, sherd := range SherdTypes() { + world.RegisterItem(PotterySherd{Type: sherd}) + } +} diff --git a/server/item/resin_brick.go b/server/item/resin_brick.go new file mode 100644 index 0000000..b039f0e --- /dev/null +++ b/server/item/resin_brick.go @@ -0,0 +1,22 @@ +package item + +import "github.com/sandertv/gophertunnel/minecraft/text" + +// ResinBrick is an item used to create resin bricks. It can also be used as a +// smithing ingredient, giving orange details to pieces of armour. +type ResinBrick struct{} + +// EncodeItem ... +func (ResinBrick) EncodeItem() (name string, meta int16) { + return "minecraft:resin_brick", 0 +} + +// TrimMaterial ... +func (ResinBrick) TrimMaterial() string { + return "resin" +} + +// MaterialColour ... +func (ResinBrick) MaterialColour() string { + return text.Resin +} diff --git a/server/item/rotten_flesh.go b/server/item/rotten_flesh.go new file mode 100644 index 0000000..ff05790 --- /dev/null +++ b/server/item/rotten_flesh.go @@ -0,0 +1,27 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/world" + "math/rand/v2" + "time" +) + +// RottenFlesh is a food item that can be eaten by the player, at the high risk of inflicting hunger. +type RottenFlesh struct { + defaultFood +} + +// Consume ... +func (RottenFlesh) Consume(_ *world.Tx, c Consumer) Stack { + c.Saturate(4, 0.8) + if rand.Float64() < 0.8 { + c.AddEffect(effect.New(effect.Hunger, 1, 30*time.Second)) + } + return Stack{} +} + +// EncodeItem ... +func (RottenFlesh) EncodeItem() (name string, meta int16) { + return "minecraft:rotten_flesh", 0 +} diff --git a/server/item/salmon.go b/server/item/salmon.go new file mode 100644 index 0000000..b31b959 --- /dev/null +++ b/server/item/salmon.go @@ -0,0 +1,37 @@ +package item + +import "github.com/df-mc/dragonfly/server/world" + +// Salmon is a food item obtained from salmons. It can be cooked in a furnace, smoker, or campfire. +type Salmon struct { + defaultFood + + // Cooked is whether the salmon is cooked. + Cooked bool +} + +// Consume ... +func (s Salmon) Consume(_ *world.Tx, c Consumer) Stack { + if s.Cooked { + c.Saturate(6, 9.6) + } else { + c.Saturate(2, 0.4) + } + return Stack{} +} + +// SmeltInfo ... +func (s Salmon) SmeltInfo() SmeltInfo { + if s.Cooked { + return SmeltInfo{} + } + return newFoodSmeltInfo(NewStack(Salmon{Cooked: true}, 1), 0.35) +} + +// EncodeItem ... +func (s Salmon) EncodeItem() (name string, meta int16) { + if s.Cooked { + return "minecraft:cooked_salmon", 0 + } + return "minecraft:salmon", 0 +} diff --git a/server/item/scute.go b/server/item/scute.go new file mode 100644 index 0000000..1130f9f --- /dev/null +++ b/server/item/scute.go @@ -0,0 +1,9 @@ +package item + +// Scute is an item that baby turtles drop when they grow into adults. +type Scute struct{} + +// EncodeItem ... +func (Scute) EncodeItem() (name string, meta int16) { + return "minecraft:turtle_scute", 0 +} diff --git a/server/item/shears.go b/server/item/shears.go new file mode 100644 index 0000000..a3b20d1 --- /dev/null +++ b/server/item/shears.go @@ -0,0 +1,69 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// Shears is a tool used to shear sheep, mine a few types of blocks, and carve pumpkins. +type Shears struct{} + +// UseOnBlock ... +func (s Shears) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, _ User, ctx *UseContext) bool { + if face == cube.FaceUp || face == cube.FaceDown { + // Pumpkins can only be carved when one of the horizontal faces is clicked. + return false + } + if c, ok := tx.Block(pos).(carvable); ok { + if res, ok := c.Carve(face); ok { + // TODO: Drop pumpkin seeds. + tx.SetBlock(pos, res, nil) + + ctx.DamageItem(1) + return true + } + } + return false +} + +// carvable represents a block that may be carved by using shears on it. +type carvable interface { + // Carve returns the resulting block of carving this block. If carving it has no result, Carve returns false. + Carve(f cube.Face) (world.Block, bool) +} + +// ToolType ... +func (s Shears) ToolType() ToolType { + return TypeShears +} + +// HarvestLevel ... +func (s Shears) HarvestLevel() int { + return 1 +} + +// BaseMiningEfficiency ... +func (s Shears) BaseMiningEfficiency(world.Block) float64 { + return 1.5 +} + +// DurabilityInfo ... +func (s Shears) DurabilityInfo() DurabilityInfo { + return DurabilityInfo{ + MaxDurability: 238, + BrokenItem: simpleItem(Stack{}), + AttackDurability: 0, + BreakDurability: 1, + } +} + +// MaxCount ... +func (s Shears) MaxCount() int { + return 1 +} + +// EncodeItem ... +func (s Shears) EncodeItem() (name string, meta int16) { + return "minecraft:shears", 0 +} diff --git a/server/item/sherd_type.go b/server/item/sherd_type.go new file mode 100644 index 0000000..587a518 --- /dev/null +++ b/server/item/sherd_type.go @@ -0,0 +1,192 @@ +package item + +// SherdType represents the sherdType of a block. +type SherdType struct { + sherdType +} + +// SherdTypeAngler returns the angler sherd type. +func SherdTypeAngler() SherdType { + return SherdType{0} +} + +// SherdTypeArcher returns the archer sherd type. +func SherdTypeArcher() SherdType { + return SherdType{1} +} + +// SherdTypeArmsUp returns the arms up sherd type. +func SherdTypeArmsUp() SherdType { + return SherdType{2} +} + +// SherdTypeBlade returns the blade sherd type. +func SherdTypeBlade() SherdType { + return SherdType{3} +} + +// SherdTypeBrewer returns the brewer sherd type. +func SherdTypeBrewer() SherdType { + return SherdType{4} +} + +// SherdTypeBurn returns the burn sherd type. +func SherdTypeBurn() SherdType { + return SherdType{5} +} + +// SherdTypeDanger returns the danger sherd type. +func SherdTypeDanger() SherdType { + return SherdType{6} +} + +// SherdTypeExplorer returns the explorer sherd type. +func SherdTypeExplorer() SherdType { + return SherdType{7} +} + +// SherdTypeFriend returns the friend sherd type. +func SherdTypeFriend() SherdType { + return SherdType{8} +} + +// SherdTypeHeart returns the heart sherd type. +func SherdTypeHeart() SherdType { + return SherdType{9} +} + +// SherdTypeHeartbreak returns the heartbreak sherd type. +func SherdTypeHeartbreak() SherdType { + return SherdType{10} +} + +// SherdTypeHowl returns the howl sherd type. +func SherdTypeHowl() SherdType { + return SherdType{11} +} + +// SherdTypeMiner returns the miner sherd type. +func SherdTypeMiner() SherdType { + return SherdType{12} +} + +// SherdTypeMourner returns the mourner sherd type. +func SherdTypeMourner() SherdType { + return SherdType{13} +} + +// SherdTypePlenty returns the plenty sherd type. +func SherdTypePlenty() SherdType { + return SherdType{14} +} + +// SherdTypePrize returns the prize sherd type. +func SherdTypePrize() SherdType { + return SherdType{15} +} + +// SherdTypeSheaf returns the sheaf sherd type. +func SherdTypeSheaf() SherdType { + return SherdType{16} +} + +// SherdTypeShelter returns the shelter sherd type. +func SherdTypeShelter() SherdType { + return SherdType{17} +} + +// SherdTypeSkull returns the skull sherd type. +func SherdTypeSkull() SherdType { + return SherdType{18} +} + +// SherdTypeSnort returns the snort sherd type. +func SherdTypeSnort() SherdType { + return SherdType{19} +} + +// SherdTypeFlow returns the flow sherd type. +func SherdTypeFlow() SherdType { + return SherdType{20} +} + +// SherdTypeGuster returns the guster sherd type. +func SherdTypeGuster() SherdType { + return SherdType{21} +} + +// SherdTypeScrape returns the scrape sherd type. +func SherdTypeScrape() SherdType { + return SherdType{22} +} + +// SherdTypes returns a list of all existing sherd types. +func SherdTypes() []SherdType { + return []SherdType{ + SherdTypeAngler(), SherdTypeArcher(), SherdTypeArmsUp(), SherdTypeBlade(), SherdTypeBrewer(), SherdTypeBurn(), + SherdTypeDanger(), SherdTypeExplorer(), SherdTypeFriend(), SherdTypeHeart(), SherdTypeHeartbreak(), SherdTypeHowl(), + SherdTypeMiner(), SherdTypeMourner(), SherdTypePlenty(), SherdTypePrize(), SherdTypeSheaf(), SherdTypeShelter(), + SherdTypeSkull(), SherdTypeSnort(), SherdTypeFlow(), SherdTypeGuster(), SherdTypeScrape(), + } +} + +// sherdType is the underlying value of a SherdType struct. +type sherdType uint8 + +// String ... +func (c sherdType) String() string { + switch c { + case 0: + return "angler" + case 1: + return "archer" + case 2: + return "arms_up" + case 3: + return "blade" + case 4: + return "brewer" + case 5: + return "burn" + case 6: + return "danger" + case 7: + return "explorer" + case 8: + return "friend" + case 9: + return "heart" + case 10: + return "heartbreak" + case 11: + return "howl" + case 12: + return "miner" + case 13: + return "mourner" + case 14: + return "plenty" + case 15: + return "prize" + case 16: + return "sheaf" + case 17: + return "shelter" + case 18: + return "skull" + case 19: + return "snort" + case 20: + return "flow" + case 21: + return "guster" + case 22: + return "scrape" + } + panic("unknown sherd type") +} + +// Uint8 ... +func (c sherdType) Uint8() uint8 { + return uint8(c) +} diff --git a/server/item/shovel.go b/server/item/shovel.go new file mode 100644 index 0000000..800a30e --- /dev/null +++ b/server/item/shovel.go @@ -0,0 +1,116 @@ +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/sound" + "github.com/go-gl/mathgl/mgl64" + "time" +) + +// Shovel is a tool generally used for mining ground-like blocks, such as sand, gravel and dirt. Additionally, +// shovels may be used to turn grass into dirt paths. +type Shovel struct { + // Tier is the tier of the shovel. + Tier ToolTier +} + +// UseOnBlock handles the creation of dirt path blocks from dirt or grass blocks. +func (s Shovel) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, _ User, ctx *UseContext) bool { + if b, ok := tx.Block(pos).(shovellable); ok { + if res, ok := b.Shovel(); ok { + if face == cube.FaceDown { + // Dirt paths are not created when the bottom face is clicked. + return false + } + if tx.Block(pos.Side(cube.FaceUp)) != air() { + // Dirt paths can only be created if air is above the grass block. + return false + } + tx.SetBlock(pos, res, nil) + tx.PlaySound(pos.Vec3(), sound.ItemUseOn{Block: res}) + + ctx.DamageItem(1) + return true + } + } + return false +} + +// shovellable represents a block that can be changed by using a shovel on it. +type shovellable interface { + // Shovel returns a block that results from using a shovel on it, or false if it could not be changed using + // a shovel. + Shovel() (world.Block, bool) +} + +// MaxCount always returns 1. +func (s Shovel) MaxCount() int { + return 1 +} + +// AttackDamage returns the attack damage to the shovel. +func (s Shovel) AttackDamage() float64 { + return s.Tier.BaseAttackDamage +} + +// ToolType returns the tool type for shovels. +func (s Shovel) ToolType() ToolType { + return TypeShovel +} + +// HarvestLevel ... +func (s Shovel) HarvestLevel() int { + return s.Tier.HarvestLevel +} + +// BaseMiningEfficiency ... +func (s Shovel) BaseMiningEfficiency(world.Block) float64 { + return s.Tier.BaseMiningEfficiency +} + +// EnchantmentValue ... +func (s Shovel) EnchantmentValue() int { + return s.Tier.EnchantmentValue +} + +// DurabilityInfo ... +func (s Shovel) DurabilityInfo() DurabilityInfo { + return DurabilityInfo{ + MaxDurability: s.Tier.Durability, + BrokenItem: simpleItem(Stack{}), + AttackDurability: 2, + BreakDurability: 1, + } +} + +// SmeltInfo ... +func (s Shovel) SmeltInfo() SmeltInfo { + switch s.Tier { + case ToolTierIron: + return newOreSmeltInfo(NewStack(IronNugget{}, 1), 0.1) + case ToolTierGold: + return newOreSmeltInfo(NewStack(GoldNugget{}, 1), 0.1) + case ToolTierCopper: + return newOreSmeltInfo(NewStack(CopperNugget{}, 1), 0.1) + } + return SmeltInfo{} +} + +// FuelInfo ... +func (s Shovel) FuelInfo() FuelInfo { + if s.Tier == ToolTierWood { + return newFuelInfo(time.Second * 10) + } + return FuelInfo{} +} + +// RepairableBy ... +func (s Shovel) RepairableBy(i Stack) bool { + return toolTierRepairable(s.Tier)(i) +} + +// EncodeItem ... +func (s Shovel) EncodeItem() (name string, meta int16) { + return "minecraft:" + s.Tier.Name + "_shovel", 0 +} diff --git a/server/item/shulker_shell.go b/server/item/shulker_shell.go new file mode 100644 index 0000000..5499b76 --- /dev/null +++ b/server/item/shulker_shell.go @@ -0,0 +1,9 @@ +package item + +// ShulkerShell are items dropped by shulkers that are used solely to craft shulker boxes. +type ShulkerShell struct{} + +// EncodeItem ... +func (ShulkerShell) EncodeItem() (name string, meta int16) { + return "minecraft:shulker_shell", 0 +} diff --git a/server/item/slimeball.go b/server/item/slimeball.go new file mode 100644 index 0000000..9fa0d5d --- /dev/null +++ b/server/item/slimeball.go @@ -0,0 +1,9 @@ +package item + +// Slimeball is a crafting ingredient commonly dropped by slimes, and can be sneezed out by pandas. +type Slimeball struct{} + +// EncodeItem ... +func (Slimeball) EncodeItem() (name string, meta int16) { + return "minecraft:slime_ball", 0 +} diff --git a/server/item/smelting_info.go b/server/item/smelting_info.go new file mode 100644 index 0000000..848112a --- /dev/null +++ b/server/item/smelting_info.go @@ -0,0 +1,77 @@ +package item + +import ( + "time" +) + +// Smeltable represents an item that can be input into a smelter, such as a blast furnace, furnace, or smoker, to cook and +// transform it into a different item. +type Smeltable interface { + // SmeltInfo returns information of the item related to it's smelting capabilities. + SmeltInfo() SmeltInfo +} + +// Fuel represents an item that can be used as fuel in a smelter, such as a blast furnace, furnace, or smoker. +type Fuel interface { + // FuelInfo returns information of the item related to its fuel capabilities. + FuelInfo() FuelInfo +} + +// SmeltInfo is a struct returned by items that implement Smeltable. It contains information about the product, experience +// gained, and more. +type SmeltInfo struct { + // Product returns the resulting item stack from smelting the item. + Product Stack + // Experience returns the experience gained from performing the smelt, alongside the Product. + Experience float64 + // Food returns true if the smelt is food, for smelters such as smokers or regular furnaces. + Food bool + // Ores returns true if the smelt is ores, for smelters such as blast furnaces or regular furnaces. + Ores bool +} + +// newSmeltInfo returns a new SmeltInfo with the given values. +func newSmeltInfo(product Stack, experience float64) SmeltInfo { + return SmeltInfo{ + Product: product, + Experience: experience, + } +} + +// newFoodSmeltInfo returns a new SmeltInfo with the given values that allows smelting in a smelter. +func newFoodSmeltInfo(product Stack, experience float64) SmeltInfo { + return SmeltInfo{ + Product: product, + Experience: experience, + Food: true, + } +} + +// newOreSmeltInfo returns a new SmeltInfo with the given values that allows smelting in a blast furnace. +func newOreSmeltInfo(product Stack, experience float64) SmeltInfo { + return SmeltInfo{ + Product: product, + Experience: experience, + Ores: true, + } +} + +// FuelInfo is a struct returned by items that implement Fuel. It contains information about the amount of fuel time +// it gives, and the residue created from burning the fuel. +type FuelInfo struct { + // Duration returns the amount of time the fuel can be used to burn an input in a smelter. + Duration time.Duration + // Residue is the resulting item from burning the fuel in a smelter. + Residue Stack +} + +// WithResidue returns a new FuelInfo with a residue. +func (f FuelInfo) WithResidue(residue Stack) FuelInfo { + f.Residue = residue + return f +} + +// newFuelInfo returns a new FuelInfo with the given values. +func newFuelInfo(duration time.Duration) FuelInfo { + return FuelInfo{Duration: duration} +} diff --git a/server/item/smithing_template.go b/server/item/smithing_template.go new file mode 100644 index 0000000..1ea72d5 --- /dev/null +++ b/server/item/smithing_template.go @@ -0,0 +1,17 @@ +package item + +// SmithingTemplate is an item used in smithing tables to alter tools and +// armour. They are consumed when used, but can be duplicated using an existing +// template, its material and diamonds. +type SmithingTemplate struct { + // Template the upgrade item used in smithing tables. + Template SmithingTemplateType +} + +// EncodeItem ... +func (t SmithingTemplate) EncodeItem() (name string, meta int16) { + if t.Template == TemplateNetheriteUpgrade() { + return "minecraft:netherite_upgrade_smithing_template", 0 + } + return "minecraft:" + t.Template.String() + "_armor_trim_smithing_template", 0 +} diff --git a/server/item/smithing_template_type.go b/server/item/smithing_template_type.go new file mode 100644 index 0000000..0456512 --- /dev/null +++ b/server/item/smithing_template_type.go @@ -0,0 +1,219 @@ +package item + +type SmithingTemplateType struct { + smithingTemplateType +} + +// TemplateNetheriteUpgrade returns the Netherite Upgrade Template +func TemplateNetheriteUpgrade() SmithingTemplateType { + return SmithingTemplateType{0} +} + +// TemplateSentry returns the Sentry Template. +func TemplateSentry() SmithingTemplateType { + return SmithingTemplateType{1} +} + +// TemplateVex returns the Vex Template. +func TemplateVex() SmithingTemplateType { + return SmithingTemplateType{2} +} + +// TemplateWild returns the Wild Template. +func TemplateWild() SmithingTemplateType { + return SmithingTemplateType{3} +} + +// TemplateCoast returns the Coast Template. +func TemplateCoast() SmithingTemplateType { + return SmithingTemplateType{4} +} + +// TemplateDune returns the Dune Template. +func TemplateDune() SmithingTemplateType { + return SmithingTemplateType{5} +} + +// TemplateWayFinder returns the WayFinder Template. +func TemplateWayFinder() SmithingTemplateType { + return SmithingTemplateType{6} +} + +// TemplateRaiser returns the Raiser Template. +func TemplateRaiser() SmithingTemplateType { + return SmithingTemplateType{7} +} + +// TemplateShaper returns the Shaper Template. +func TemplateShaper() SmithingTemplateType { + return SmithingTemplateType{8} +} + +// TemplateHost returns the Host Template. +func TemplateHost() SmithingTemplateType { + return SmithingTemplateType{9} +} + +// TemplateWard returns the Ward Template. +func TemplateWard() SmithingTemplateType { + return SmithingTemplateType{10} +} + +// TemplateSilence returns the Silence Template. +func TemplateSilence() SmithingTemplateType { + return SmithingTemplateType{11} +} + +// TemplateTide returns the Tide Template. +func TemplateTide() SmithingTemplateType { + return SmithingTemplateType{12} +} + +// TemplateSnout returns the Snout Template. +func TemplateSnout() SmithingTemplateType { + return SmithingTemplateType{13} +} + +// TemplateRib returns the Rib Template. +func TemplateRib() SmithingTemplateType { + return SmithingTemplateType{14} +} + +// TemplateEye returns the Eye Template. +func TemplateEye() SmithingTemplateType { + return SmithingTemplateType{15} +} + +// TemplateSpire returns the Spire Template. +func TemplateSpire() SmithingTemplateType { + return SmithingTemplateType{16} +} + +// TemplateFlow returns the Flow Template. +func TemplateFlow() SmithingTemplateType { + return SmithingTemplateType{17} +} + +// TemplateBolt returns the Bolt Template. +func TemplateBolt() SmithingTemplateType { + return SmithingTemplateType{18} +} + +// SmithingTemplates returns all the ArmourSmithingTemplates +func SmithingTemplates() []SmithingTemplateType { + return []SmithingTemplateType{ + TemplateNetheriteUpgrade(), + TemplateSentry(), + TemplateVex(), + TemplateWild(), + TemplateCoast(), + TemplateDune(), + TemplateWayFinder(), + TemplateRaiser(), + TemplateShaper(), + TemplateHost(), + TemplateWard(), + TemplateSilence(), + TemplateTide(), + TemplateSnout(), + TemplateRib(), + TemplateEye(), + TemplateSpire(), + TemplateFlow(), + TemplateBolt(), + } +} + +type smithingTemplateType uint8 + +// String ... +func (s smithingTemplateType) String() string { + switch s { + case 0: + return "netherite_upgrade" + case 1: + return "sentry" + case 2: + return "vex" + case 3: + return "wild" + case 4: + return "coast" + case 5: + return "dune" + case 6: + return "wayfinder" + case 7: + return "raiser" + case 8: + return "shaper" + case 9: + return "host" + case 10: + return "ward" + case 11: + return "silence" + case 12: + return "tide" + case 13: + return "snout" + case 14: + return "rib" + case 15: + return "eye" + case 16: + return "spire" + case 17: + return "flow" + case 18: + return "bolt" + } + + panic("should never happen") +} + +// smithingTemplateFromString returns an armour smithing template based on a string. +func smithingTemplateFromString(name string) (SmithingTemplateType, bool) { + switch name { + case "netherite_upgrade": + return TemplateNetheriteUpgrade(), true + case "sentry": + return TemplateSentry(), true + case "vex": + return TemplateVex(), true + case "wild": + return TemplateWild(), true + case "coast": + return TemplateCoast(), true + case "dune": + return TemplateDune(), true + case "wayfinder": + return TemplateWayFinder(), true + case "raiser": + return TemplateRaiser(), true + case "shaper": + return TemplateShaper(), true + case "host": + return TemplateHost(), true + case "ward": + return TemplateWard(), true + case "silence": + return TemplateSilence(), true + case "tide": + return TemplateTide(), true + case "snout": + return TemplateSnout(), true + case "rib": + return TemplateRib(), true + case "eye": + return TemplateEye(), true + case "spire": + return TemplateSpire(), true + case "flow": + return TemplateFlow(), true + case "bolt": + return TemplateBolt(), true + default: + return SmithingTemplateType{}, false + } +} diff --git a/server/item/snowball.go b/server/item/snowball.go new file mode 100644 index 0000000..a3a66e7 --- /dev/null +++ b/server/item/snowball.go @@ -0,0 +1,30 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" +) + +// Snowball is a throwable combat item obtained through shovelling snow. +type Snowball struct{} + +// MaxCount ... +func (s Snowball) MaxCount() int { + return 16 +} + +// Use ... +func (s Snowball) Use(tx *world.Tx, user User, ctx *UseContext) bool { + create := tx.World().EntityRegistry().Config().Snowball + opts := world.EntitySpawnOpts{Position: eyePosition(user), Velocity: user.Rotation().Vec3().Mul(1.5)} + tx.AddEntity(create(opts, user)) + tx.PlaySound(user.Position(), sound.ItemThrow{}) + + ctx.SubtractFromCount(1) + return true +} + +// EncodeItem ... +func (s Snowball) EncodeItem() (name string, meta int16) { + return "minecraft:snowball", 0 +} diff --git a/server/item/spider_eye.go b/server/item/spider_eye.go new file mode 100644 index 0000000..507c9a7 --- /dev/null +++ b/server/item/spider_eye.go @@ -0,0 +1,24 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/world" + "time" +) + +// SpiderEye is a poisonous food and brewing item. +type SpiderEye struct { + defaultFood +} + +// Consume ... +func (SpiderEye) Consume(_ *world.Tx, c Consumer) Stack { + c.Saturate(2, 3.2) + c.AddEffect(effect.New(effect.Poison, 1, time.Second*5)) + return Stack{} +} + +// EncodeItem ... +func (SpiderEye) EncodeItem() (name string, meta int16) { + return "minecraft:spider_eye", 0 +} diff --git a/server/item/splash_potion.go b/server/item/splash_potion.go new file mode 100644 index 0000000..23311ee --- /dev/null +++ b/server/item/splash_potion.go @@ -0,0 +1,49 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/item/potion" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "math" +) + +// SplashPotion is an item that grants effects when thrown. +type SplashPotion struct { + // Type is the type of splash potion. + Type potion.Potion +} + +// MaxCount ... +func (s SplashPotion) MaxCount() int { + return 1 +} + +// Use ... +func (s SplashPotion) Use(tx *world.Tx, user User, ctx *UseContext) bool { + create := tx.World().EntityRegistry().Config().SplashPotion + opts := world.EntitySpawnOpts{Position: eyePosition(user), Velocity: throwableOffset(user.Rotation()).Vec3().Mul(0.5)} + tx.AddEntity(create(opts, s.Type, user)) + tx.PlaySound(user.Position(), sound.ItemThrow{}) + + ctx.SubtractFromCount(1) + return true +} + +// throwableOffset adds an upwards offset pitch to a throwable entity. +// In vanilla, items such as Splash Potions, Lingering Potions, and +// Bottle o' Enchanting are thrown at a higher angle than where the +// player is looking at. +// The added offset is an ellipse-like shape based on what the input pitch is. +func throwableOffset(r cube.Rotation) cube.Rotation { + r[1] = max(min(r[1], 89.9), -89.9) + r[1] -= math.Sqrt(89.9*89.9-r[1]*r[1]) * (26.5 / 89.9) + r[1] = max(min(r[1], 89.9), -89.9) + + return r +} + +// EncodeItem ... +func (s SplashPotion) EncodeItem() (name string, meta int16) { + return "minecraft:splash_potion", int16(s.Type.Uint8()) +} diff --git a/server/item/spyglass.go b/server/item/spyglass.go new file mode 100644 index 0000000..9fa3e0c --- /dev/null +++ b/server/item/spyglass.go @@ -0,0 +1,16 @@ +package item + +// Spyglass is an item that zooms in on an area the player is looking at, like a telescope. +type Spyglass struct { + nopReleasable +} + +// MaxCount always returns 1. +func (Spyglass) MaxCount() int { + return 1 +} + +// EncodeItem ... +func (Spyglass) EncodeItem() (name string, meta int16) { + return "minecraft:spyglass", 0 +} diff --git a/server/item/stack.go b/server/item/stack.go new file mode 100644 index 0000000..10d5d3f --- /dev/null +++ b/server/item/stack.go @@ -0,0 +1,493 @@ +package item + +import ( + "fmt" + "maps" + "reflect" + "slices" + "sort" + "strings" + "sync/atomic" + + "github.com/df-mc/dragonfly/server/world" +) + +// Stack represents a stack of items. The stack shares the same item type and has a count which specifies the +// size of the stack. +type Stack struct { + id int32 + + item world.Item + count int + + customName string + lore []string + + damage int + unbreakable bool + + anvilCost int + + data map[string]any + + enchantments map[EnchantmentType]Enchantment +} + +// NewStack returns a new stack using the item type and the count passed. NewStack panics if the count passed +// is negative or if the item type passed is nil. +func NewStack(t world.Item, count int) Stack { + if count < 0 { + panic("cannot use negative count for item stack") + } + if t == nil { + panic("cannot have a stack with item type nil") + } + return Stack{item: t, count: count, id: newID()} +} + +// Count returns the amount of items that is present on the stack. The count is guaranteed never to be +// negative. +func (s Stack) Count() int { + return s.count +} + +// MaxCount returns the maximum count that the stack is able to hold when added to an inventory or when added +// to an item entity. +func (s Stack) MaxCount() int { + if counter, ok := s.item.(MaxCounter); ok { + return counter.MaxCount() + } + return 64 +} + +// Grow grows the Stack's count by n, returning the resulting Stack. If a positive number is passed, the stack +// is grown, whereas if a negative size is passed, the resulting Stack will have a lower count. The count of +// the returned Stack will never be negative. +func (s Stack) Grow(n int) Stack { + s.count += n + if s.count < 0 { + s.count = 0 + } + s.id = newID() + return s +} + +// Durability returns the current durability of the item stack. If the item is not one that implements the +// Durable interface, BaseDurability will always return -1. +// The closer the durability returned is to 0, the closer the item is to being broken. +func (s Stack) Durability() int { + if durable, ok := s.Item().(Durable); ok { + return durable.DurabilityInfo().MaxDurability - s.damage + } + return -1 +} + +// MaxDurability returns the maximum durability that the item stack is able to have. If the item does not +// implement the Durable interface, MaxDurability will always return -1. +func (s Stack) MaxDurability() int { + if durable, ok := s.Item().(Durable); ok { + return durable.DurabilityInfo().MaxDurability + } + return -1 +} + +// Damage returns a new stack that is damaged by the amount passed. (Meaning, its durability lowered by the +// amount passed.) If the item does not implement the Durable interface, the original stack is returned. +// The damage passed may be negative to add durability. +// If the final durability reaches 0 or below, the item returned is the resulting item of the breaking of the +// item. If the final durability reaches a number higher than the maximum durability, the stack returned will +// get the maximum durability. +func (s Stack) Damage(d int) Stack { + durable, ok := s.Item().(Durable) + if !ok || s.unbreakable { + return s + } + durability := s.Durability() + info := durable.DurabilityInfo() + if durability-d <= 0 { + if info.Persistent { + // Persistent items can't be broken. + return s + } + // A durability of 0, so the item is broken. + return info.BrokenItem() + } + if durability-d > info.MaxDurability { + // We've passed the maximum durability, so we just need to make sure the final durability of the item + // will be equal to the max. + s.damage, d = 0, 0 + } + s.damage += d + return s +} + +// WithDurability returns a new item stack with the durability passed. If the item does not implement the +// Durable interface, the original stack is returned. +// The closer the durability d is to 0, the closer the item is to being broken. If a durability of 0 is passed, +// a stack with the item type of the BrokenItem is returned. If a durability is passed that exceeds the +// maximum durability, the stack returned will have the maximum durability. +func (s Stack) WithDurability(d int) Stack { + durable, ok := s.Item().(Durable) + if !ok { + return s + } + maxDurability := durable.DurabilityInfo().MaxDurability + if d > maxDurability { + // A durability bigger than the max, so the item has no damage at all. + s.damage = 0 + return s + } + if d == 0 { + // A durability of 0, so the item is broken. + return durable.DurabilityInfo().BrokenItem() + } + s.damage = maxDurability - d + return s +} + +// Unbreakable checks if the item stack is unbreakable. +func (s Stack) Unbreakable() bool { + return s.unbreakable +} + +// AsUnbreakable returns a copy of the Stack with the unbreakable tag set. If the item does not implement the +// Durable interface, the original stack is returned. +func (s Stack) AsUnbreakable() Stack { + if _, ok := s.Item().(Durable); !ok { + return s + } + s.unbreakable = true + return s +} + +// AsBreakable returns a copy of the Stack without the unbreakable tag set. If the item does not implement the +// Durable interface, the original stack is returned. +func (s Stack) AsBreakable() Stack { + if _, ok := s.Item().(Durable); !ok { + return s + } + s.unbreakable = false + return s +} + +// Empty checks if the stack is empty (has a count of 0). +func (s Stack) Empty() bool { + if s.Count() == 0 || s.item == nil { + return true + } + name, _ := s.item.EncodeItem() + return name == "minecraft:air" +} + +// Item returns the item that the stack holds. If the stack is considered empty (Stack.Empty()), Item will +// always return nil. +func (s Stack) Item() world.Item { + if s.Empty() || s.item == nil { + return nil + } + return s.item +} + +// AttackDamage returns the attack damage to the stack. By default, the value returned is 1.0. If the item +// held implements the item.Weapon interface, this damage may be different. +func (s Stack) AttackDamage() float64 { + if weapon, ok := s.Item().(Weapon); ok { + // Bonus attack damage from weapons is a bit quirky in Bedrock Edition: Even though tools say they + // have, for example, + 5 Attack Damage, it is actually 1 + 5, while punching with a hand in Bedrock + // Edition deals 2 damage, not 1 like in Java Edition. + // The tooltip displayed in-game is therefore not exactly correct. + return weapon.AttackDamage() + 1 + } + return 1.0 +} + +// WithCustomName returns a copy of the Stack with the custom name passed. The custom name is formatted +// according to the rules of fmt.Sprintln. +func (s Stack) WithCustomName(a ...any) Stack { + s.customName = format(a) + if nameable, ok := s.Item().(nameable); ok { + s.item = nameable.WithName(a...) + } + return s +} + +// CustomName returns the custom name set for the Stack. An empty string is returned if the Stack has no +// custom name set. +func (s Stack) CustomName() string { + return s.customName +} + +// WithLore returns a copy of the Stack with the lore passed. Each string passed is put on a different line, +// where the first string is at the top and the last at the bottom. +// The lore may be cleared by passing no lines into the Stack. +func (s Stack) WithLore(lines ...string) Stack { + s.lore = lines + return s +} + +// Lore returns the lore set for the Stack. If no lore is present, the slice returned has a len of 0. +func (s Stack) Lore() []string { + if s.Empty() { + return nil + } + return s.lore +} + +// WithValue returns the current Stack with a value set at a specific key. This method may be used to +// associate custom data with the item stack, which will persist through server restarts. +// The value stored may later be obtained by making a call to Stack.Value(). +// +// WithValue may be called with a nil value, in which case the value at the key will be cleared. +// +// WithValue stores Values by encoding them using the encoding/gob package. Users of WithValue must ensure +// that their value is valid for encoding with this package. +func (s Stack) WithValue(key string, val any) Stack { + s.data = cloneMap(s.data) + if val != nil { + s.data[key] = val + } else { + delete(s.data, key) + if len(s.data) == 0 { + s.data = nil + } + } + return s +} + +// Value attempts to return a value set to the Stack using Stack.WithValue(). If a value is found by the key +// passed, it is returned and ok is true. If not found, the value returned is nil and ok is false. +func (s Stack) Value(key string) (val any, ok bool) { + if s.Empty() { + return nil, false + } + val, ok = s.data[key] + return val, ok +} + +// WithEnchantments returns the current stack with the passed enchantments. If an enchantment is not compatible +// with the item stack, it will not be applied. +func (s Stack) WithEnchantments(enchants ...Enchantment) Stack { + if _, ok := s.item.(Book); ok { + s.item = EnchantedBook{} + } + s.enchantments = cloneMap(s.enchantments) + for _, enchant := range enchants { + if _, ok := s.Item().(EnchantedBook); !ok && !enchant.t.CompatibleWithItem(s.item) { + // Enchantment is not compatible with the item. + continue + } + compatible := true + for _, otherEnchant := range s.enchantments { + addingType := enchant.t + existingType := otherEnchant.Type() + addingAcceptsExisting := addingType.CompatibleWithEnchantment(existingType) + existingAcceptsAdding := existingType.CompatibleWithEnchantment(addingType) + if addingType != existingType && (!addingAcceptsExisting || !existingAcceptsAdding) { + compatible = false + break + } + } + if !compatible { + // Enchantment is not compatible with another enchantment on the item. + continue + } + s.enchantments[enchant.t] = enchant + } + return s +} + +// WithForcedEnchantments returns the current stack with the passed enchantments applied, +// bypassing compatibility checks that would normally prevent incompatible enchantments +// from being applied together. +func (s Stack) WithForcedEnchantments(enchants ...Enchantment) Stack { + s.enchantments = cloneMap(s.enchantments) + for _, enchant := range enchants { + s.enchantments[enchant.t] = enchant + } + return s +} + +// WithoutEnchantments returns the current stack but with the passed enchantments removed. +func (s Stack) WithoutEnchantments(enchants ...EnchantmentType) Stack { + s.enchantments = cloneMap(s.enchantments) + for _, enchant := range enchants { + delete(s.enchantments, enchant) + } + if _, ok := s.item.(EnchantedBook); ok && len(s.enchantments) == 0 { + s.item = Book{} + } + return s +} + +// Enchantment attempts to return an Enchantment set to the Stack using Stack.WithEnchantment(). If an Enchantment +// is found by the EnchantmentType, the enchantment and the bool true is returned. +func (s Stack) Enchantment(enchant EnchantmentType) (Enchantment, bool) { + if s.Empty() { + return Enchantment{}, false + } + ench, ok := s.enchantments[enchant] + return ench, ok +} + +// Enchantments returns an array of all Enchantments on the item. Enchantments returns the enchantments of a Stack in a +// deterministic order. +func (s Stack) Enchantments() []Enchantment { + if s.Empty() { + return nil + } + e := slices.Collect(maps.Values(s.enchantments)) + sort.Slice(e, func(i, j int) bool { + id1, _ := EnchantmentID(e[i].t) + id2, _ := EnchantmentID(e[j].t) + return id1 < id2 + }) + return e +} + +// AnvilCost returns the number of experience levels to add to the base level cost when repairing, combining, or +// renaming this item with an anvil. +func (s Stack) AnvilCost() int { + return s.anvilCost +} + +// WithAnvilCost returns the current Stack with the anvil cost set to the passed value. +func (s Stack) WithAnvilCost(anvilCost int) Stack { + i := s.Item() + _, repairable := i.(Repairable) + _, enchantedBook := i.(EnchantedBook) + if !repairable && !enchantedBook { + // This item can't have a repair cost. + return s + } + s.anvilCost = anvilCost + return s +} + +// WithItem returns a Stack with the item type passed, copying all the +// properties from s to the new stack. Damage to an item, enchantments and anvil +// costs are only copied if they are still applicable to the new item type. +func (s Stack) WithItem(t world.Item) Stack { + cp := NewStack(t, s.count). + Damage(s.damage). + WithCustomName(s.customName). + WithLore(s.lore...). + WithEnchantments(s.Enchantments()...). + WithAnvilCost(s.anvilCost) + cp.unbreakable = s.unbreakable && s.MaxDurability() != -1 + cp.data = s.data + return cp +} + +// AddStack adds another stack to the stack and returns both stacks. The first stack returned will have as +// many items in it as possible to fit in the stack, according to a max count of either 64 or otherwise as +// returned by Item.MaxCount(). The second stack will have the leftover items: It may be empty if the count of +// both stacks together don't exceed the max count. +// If the two stacks are not comparable, AddStack will return both the original stack and the stack passed. +func (s Stack) AddStack(s2 Stack) (a, b Stack) { + if s.Count() >= s.MaxCount() { + // No more items could be added to the original stack. + return s, s2 + } + if !s.Comparable(s2) { + // The items are not comparable and thus cannot be stacked together. + return s, s2 + } + diff := s.MaxCount() - s.Count() + if s2.Count() < diff { + diff = s2.Count() + } + + s.count, s2.count = s.count+diff, s2.count-diff + s.id, s2.id = newID(), newID() + return s, s2 +} + +// Equal checks if the two stacks are equal. Equal is equivalent to a Stack.Comparable check while also +// checking the count and durability. +func (s Stack) Equal(s2 Stack) bool { + return s.Comparable(s2) && s.count == s2.count && s.damage == s2.damage +} + +// Comparable checks if two stacks can be considered comparable. True is returned if the two stacks have an +// equal item type and have equal enchantments, lore and custom names, or if one of the stacks is empty. +// Comparable does not check if the two stacks have the same durability. +func (s Stack) Comparable(s2 Stack) bool { + if s.Empty() || s2.Empty() { + return true + } + + name, meta := s.Item().EncodeItem() + name2, meta2 := s2.Item().EncodeItem() + if name != name2 || meta != meta2 || s.anvilCost != s2.anvilCost || s.customName != s2.customName { + return false + } + for !slices.Equal(s.lore, s2.lore) { + return false + } + if len(s.enchantments) != len(s2.enchantments) { + return false + } + for i := range s.enchantments { + if s.enchantments[i] != s2.enchantments[i] { + return false + } + } + if !reflect.DeepEqual(s.data, s2.data) { + return false + } + if nbt, ok := s.Item().(world.NBTer); ok { + nbt2, ok := s2.Item().(world.NBTer) + return ok && reflect.DeepEqual(nbt.EncodeNBT(), nbt2.EncodeNBT()) + } + return true +} + +// String implements the fmt.Stringer interface. +func (s Stack) String() string { + if s.item == nil { + return fmt.Sprintf("Stack x%v", s.count) + } + return fmt.Sprintf("Stack<%T%+v>(custom name='%v', lore='%v', damage=%v, anvilCost=%v) x%v", s.item, s.item, s.customName, s.lore, s.damage, s.anvilCost, s.count) +} + +// Values returns all values associated with the stack by users. The map returned is a copy of the original: +// Modifying it will not modify the item stack. +func (s Stack) Values() map[string]any { + if s.Empty() { + return nil + } + return maps.Clone(s.data) +} + +// cloneMap calls maps.Clone, but initialises m if it does not yet exist. +func cloneMap[M ~map[K]V, K comparable, V any](m M) M { + if m == nil { + m = make(M) + } + return maps.Clone(m) +} + +// stackID is a counter for unique stack IDs. +var stackID = new(int32) + +// newID returns a new unique stack ID. +func newID() int32 { + return atomic.AddInt32(stackID, 1) +} + +// id returns the unique ID of the stack passed. +// noinspection GoUnusedFunction +// +//lint:ignore U1000 Function is used through compiler directives. +func id(s Stack) int32 { + if s.Empty() { + return 0 + } + return s.id +} + +// format is a utility function to format a list of Values to have spaces between them, but no newline at the +// end, which is typically used for sending messages, popups and tips. +func format(a []any) string { + return strings.TrimSuffix(fmt.Sprintln(a...), "\n") +} diff --git a/server/item/stick.go b/server/item/stick.go new file mode 100644 index 0000000..396418c --- /dev/null +++ b/server/item/stick.go @@ -0,0 +1,16 @@ +package item + +import "time" + +// Stick is one of the most abundant resources used for crafting many tools and items. +type Stick struct{} + +// FuelInfo ... +func (Stick) FuelInfo() FuelInfo { + return newFuelInfo(time.Second * 5) +} + +// EncodeItem ... +func (s Stick) EncodeItem() (name string, meta int16) { + return "minecraft:stick", 0 +} diff --git a/server/item/sugar.go b/server/item/sugar.go new file mode 100644 index 0000000..407d170 --- /dev/null +++ b/server/item/sugar.go @@ -0,0 +1,9 @@ +package item + +// Sugar is a food ingredient and brewing ingredient made from sugar canes. +type Sugar struct{} + +// EncodeItem ... +func (Sugar) EncodeItem() (name string, meta int16) { + return "minecraft:sugar", 0 +} diff --git a/server/item/suspicious_stew.go b/server/item/suspicious_stew.go new file mode 100644 index 0000000..60e5d83 --- /dev/null +++ b/server/item/suspicious_stew.go @@ -0,0 +1,38 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/world" +) + +// SuspiciousStew is a food item that can give the player a status effect that depends on the flower used to craft it. +type SuspiciousStew struct { + defaultFood + + // Type specifies the type of effect will be given to the player + Type StewType +} + +// MaxCount ... +func (SuspiciousStew) MaxCount() int { + return 1 +} + +// AlwaysConsumable ... +func (SuspiciousStew) AlwaysConsumable() bool { + return true +} + +// EncodeItem ... +func (s SuspiciousStew) EncodeItem() (name string, meta int16) { + return "minecraft:suspicious_stew", int16(s.Type.Uint8()) +} + +// Consume ... +func (s SuspiciousStew) Consume(_ *world.Tx, c Consumer) Stack { + for _, effect := range s.Type.Effects() { + c.AddEffect(effect) + } + c.Saturate(6, 7.2) + + return NewStack(Bowl{}, 1) +} diff --git a/server/item/suspicious_stew_type.go b/server/item/suspicious_stew_type.go new file mode 100644 index 0000000..e4489f2 --- /dev/null +++ b/server/item/suspicious_stew_type.go @@ -0,0 +1,120 @@ +package item + +import ( + "time" + + "github.com/df-mc/dragonfly/server/entity/effect" +) + +// StewType represents a type of suspicious stew. +type StewType struct { + stewType +} + +// NightVisionPoppyStew returns suspicious stew night vision effect. +func NightVisionPoppyStew() StewType { + return StewType{0} +} + +// JumpBoostStew returns suspicious stew jump boost effect. +func JumpBoostStew() StewType { + return StewType{1} +} + +// WeaknessStew returns suspicious stew weakness effect. +func WeaknessStew() StewType { + return StewType{2} +} + +// BlindnessBluetStew returns suspicious stew blindness effect. +func BlindnessBluetStew() StewType { + return StewType{3} +} + +// PoisonStew returns suspicious stew poison effect. +func PoisonStew() StewType { + return StewType{4} +} + +// SaturationDandelionStew returns suspicious stew saturation effect. +func SaturationDandelionStew() StewType { + return StewType{5} +} + +// SaturationOrchidStew returns suspicious stew saturation effect. +func SaturationOrchidStew() StewType { + return StewType{6} +} + +// FireResistanceStew returns suspicious stew fire resistance effect. +func FireResistanceStew() StewType { + return StewType{7} +} + +// RegenerationStew returns suspicious stew regeneration effect. +func RegenerationStew() StewType { + return StewType{8} +} + +// WitherStew returns suspicious stew wither effect. +func WitherStew() StewType { + return StewType{9} +} + +// NightVisionTorchflowerStew returns suspicious stew night vision effect. +func NightVisionTorchflowerStew() StewType { + return StewType{10} +} + +// BlindnessEyeblossomStew returns suspicious stew blindness effect. +func BlindnessEyeblossomStew() StewType { + return StewType{11} +} + +// NauseaStew returns suspicious stew nausea effect. +func NauseaStew() StewType { + return StewType{12} +} + +// StewTypes ... +func StewTypes() []StewType { + return []StewType{NightVisionPoppyStew(), JumpBoostStew(), WeaknessStew(), BlindnessBluetStew(), PoisonStew(), SaturationDandelionStew(), SaturationOrchidStew(), FireResistanceStew(), RegenerationStew(), WitherStew(), NightVisionTorchflowerStew(), BlindnessEyeblossomStew(), NauseaStew()} +} + +type stewType uint8 + +// Uint8 returns the stew as a uint8. +func (s stewType) Uint8() uint8 { + return uint8(s) +} + +// Effects returns suspicious stew effects. +func (s stewType) Effects() []effect.Effect { + var effects []effect.Effect + switch s.Uint8() { + case 0, 10: + effects = append(effects, effect.New(effect.NightVision, 1, time.Second*5)) + case 1: + effects = append(effects, effect.New(effect.JumpBoost, 1, time.Second*5)) + case 2: + effects = append(effects, effect.New(effect.Weakness, 1, time.Second*7)) + case 3, 11: + effects = append(effects, effect.New(effect.Blindness, 1, time.Second*6)) + case 4: + effects = append(effects, effect.New(effect.Poison, 1, time.Second*11)) + case 5, 6: + effects = append(effects, effect.New(effect.Saturation, 1, time.Second*3/10)) + case 7: + effects = append(effects, effect.New(effect.FireResistance, 1, time.Second*3)) + case 8: + effects = append(effects, effect.New(effect.Regeneration, 1, time.Second*7)) + case 9: + effects = append(effects, effect.New(effect.Wither, 1, time.Second*7)) + case 12: + effects = append(effects, effect.New(effect.Nausea, 1, time.Second*7)) + default: + panic("should never happen") + } + + return effects +} diff --git a/server/item/sword.go b/server/item/sword.go new file mode 100644 index 0000000..6115fe9 --- /dev/null +++ b/server/item/sword.go @@ -0,0 +1,88 @@ +package item + +import ( + "time" + + "github.com/df-mc/dragonfly/server/world" +) + +// Sword is a tool generally used to attack enemies. In addition, it may be used to mine any block slightly +// faster than without tool and to break cobwebs rapidly. +type Sword struct { + // Tier is the tier of the sword. + Tier ToolTier +} + +// AttackDamage returns the attack damage to the sword. +func (s Sword) AttackDamage() float64 { + return s.Tier.BaseAttackDamage + 3 +} + +// MaxCount always returns 1. +func (s Sword) MaxCount() int { + return 1 +} + +// ToolType returns the tool type for swords. +func (s Sword) ToolType() ToolType { + return TypeSword +} + +// HarvestLevel returns the harvest level of the sword tier. +func (s Sword) HarvestLevel() int { + return s.Tier.HarvestLevel +} + +// EnchantmentValue ... +func (s Sword) EnchantmentValue() int { + return s.Tier.EnchantmentValue +} + +// BaseMiningEfficiency always returns 1.5, unless the block passed is cobweb, in which case 15 is returned. +func (s Sword) BaseMiningEfficiency(b world.Block) float64 { + if _, ok := b.(interface{ Cobweb() }); ok { + return 15 + } + return 1.5 +} + +// DurabilityInfo ... +func (s Sword) DurabilityInfo() DurabilityInfo { + return DurabilityInfo{ + MaxDurability: s.Tier.Durability, + BrokenItem: simpleItem(Stack{}), + AttackDurability: 1, + BreakDurability: 2, + } +} + +// SmeltInfo ... +func (s Sword) SmeltInfo() SmeltInfo { + switch s.Tier { + case ToolTierIron: + return newOreSmeltInfo(NewStack(IronNugget{}, 1), 0.1) + case ToolTierGold: + return newOreSmeltInfo(NewStack(GoldNugget{}, 1), 0.1) + case ToolTierCopper: + return newOreSmeltInfo(NewStack(CopperNugget{}, 1), 0.1) + } + return SmeltInfo{} +} + +// FuelInfo ... +func (s Sword) FuelInfo() FuelInfo { + if s.Tier == ToolTierWood { + return newFuelInfo(time.Second * 10) + } + return FuelInfo{} +} + +// RepairableBy ... +func (s Sword) RepairableBy(i Stack) bool { + return toolTierRepairable(s.Tier)(i) +} + +// EncodeItem ... +func (s Sword) EncodeItem() (name string, meta int16) { + return "minecraft:" + s.Tier.Name + "_sword", 0 +} diff --git a/server/item/tool.go b/server/item/tool.go new file mode 100644 index 0000000..ff8f010 --- /dev/null +++ b/server/item/tool.go @@ -0,0 +1,125 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/world" +) + +var ( + // TypeNone is the ToolType of items that are not tools. + TypeNone = ToolType{-1} + // TypePickaxe is the ToolType for pickaxes. + TypePickaxe = ToolType{0} + // TypeAxe is the ToolType for axes. + TypeAxe = ToolType{1} + // TypeHoe is the ToolType for hoes. + TypeHoe = ToolType{2} + // TypeShovel is the ToolType for shovels. + TypeShovel = ToolType{3} + // TypeShears is the ToolType for shears. + TypeShears = ToolType{4} + // TypeSword is the ToolType for swords. + TypeSword = ToolType{5} + + // ToolTierWood is the ToolTier of wood tools. This is the lowest possible tier. + ToolTierWood = ToolTier{HarvestLevel: 1, Durability: 59, BaseMiningEfficiency: 2, BaseAttackDamage: 1, EnchantmentValue: 15, Name: "wooden"} + // ToolTierGold is the ToolTier of gold tools. + ToolTierGold = ToolTier{HarvestLevel: 1, Durability: 32, BaseMiningEfficiency: 12, BaseAttackDamage: 1, EnchantmentValue: 22, Name: "golden"} + // ToolTierStone is the ToolTier of stone tools. + ToolTierStone = ToolTier{HarvestLevel: 2, Durability: 131, BaseMiningEfficiency: 4, BaseAttackDamage: 2, EnchantmentValue: 5, Name: "stone"} + // ToolTierCopper is the ToolTier of copper tools. + ToolTierCopper = ToolTier{HarvestLevel: 2, Durability: 190, BaseMiningEfficiency: 5, BaseAttackDamage: 2, EnchantmentValue: 13, Name: "copper"} + // ToolTierIron is the ToolTier of iron tools. + ToolTierIron = ToolTier{HarvestLevel: 3, Durability: 250, BaseMiningEfficiency: 6, BaseAttackDamage: 3, EnchantmentValue: 14, Name: "iron"} + // ToolTierDiamond is the ToolTier of diamond tools. + ToolTierDiamond = ToolTier{HarvestLevel: 4, Durability: 1561, BaseMiningEfficiency: 8, BaseAttackDamage: 4, EnchantmentValue: 10, Name: "diamond"} + // ToolTierNetherite is the ToolTier of netherite tools. This is the highest possible tier. + ToolTierNetherite = ToolTier{HarvestLevel: 4, Durability: 2031, BaseMiningEfficiency: 9, BaseAttackDamage: 5, EnchantmentValue: 15, Name: "netherite"} +) + +type ( + // Tool represents an item that may be used as a tool. + Tool interface { + // ToolType returns the type of the tool. The blocks that can be mined with this tool depend on this + // tool type. + ToolType() ToolType + // HarvestLevel returns the level that this tool is able to harvest. If a block has a harvest level above + // this one, this tool won't be able to harvest it. + HarvestLevel() int + // BaseMiningEfficiency is the base efficiency of the tool, when it comes to mining blocks. This decides + // the speed with which blocks can be mined. + // Some tools have a mining efficiency that depends on the block (swords, shears). The block mined is + // passed for this behaviour. + BaseMiningEfficiency(b world.Block) float64 + } + // ToolTier represents the tier, or material, that a Tool is made of. + ToolTier struct { + // HarvestLevel is the level that this tier of tools is able to harvest. If a block has a harvest level + // above this one, a tool with this tier won't be able to harvest it. + HarvestLevel int + // BaseMiningEfficiency is the base efficiency of the tier, when it comes to mining blocks. This is + // specifically used for tools such as pickaxes. + BaseMiningEfficiency float64 + // BaseAttackDamage is the base attack damage to tools with this tier. All tools have a constant value + // that is added on top of this. + BaseAttackDamage float64 + // EnchantmentValue is the enchantment value of the tool used when selecting pseudo-random enchantments for + // enchanting tables. + EnchantmentValue int + // BaseDurability returns the maximum durability that a tool with this tier has. + Durability int + // Name is the name of the tier. + Name string + } + // ToolType represents the type of tool. This decides the type of blocks that the tool is used for. + ToolType struct{ t } + t int + + // ToolNone is a ToolType typically used in functions for items that do not function as tools. + ToolNone struct{} +) + +// ToolTiers returns a ToolTier slice containing all available tiers. +func ToolTiers() []ToolTier { + return []ToolTier{ToolTierWood, ToolTierGold, ToolTierStone, ToolTierCopper, ToolTierIron, ToolTierDiamond, ToolTierNetherite} +} + +// ToolType ... +func (n ToolNone) ToolType() ToolType { return TypeNone } + +// HarvestLevel ... +func (n ToolNone) HarvestLevel() int { return 0 } + +// BaseMiningEfficiency ... +func (n ToolNone) BaseMiningEfficiency(world.Block) float64 { return 1 } + +// toolTierRepairable returns true if the ToolTier passed is repairable. +func toolTierRepairable(tier ToolTier) func(Stack) bool { + return func(stack Stack) bool { + switch tier { + case ToolTierWood: + if planks, ok := stack.Item().(interface{ RepairsWoodTools() bool }); ok { + return planks.RepairsWoodTools() + } + case ToolTierStone: + if cobblestone, ok := stack.Item().(interface{ RepairsStoneTools() bool }); ok { + return cobblestone.RepairsStoneTools() + } + case ToolTierGold: + _, ok := stack.Item().(GoldIngot) + return ok + case ToolTierCopper: + _, ok := stack.Item().(CopperIngot) + return ok + case ToolTierIron: + _, ok := stack.Item().(IronIngot) + return ok + case ToolTierDiamond: + _, ok := stack.Item().(Diamond) + return ok + case ToolTierNetherite: + _, ok := stack.Item().(NetheriteIngot) + return ok + } + return false + } +} diff --git a/server/item/totem.go b/server/item/totem.go new file mode 100644 index 0000000..1696d2f --- /dev/null +++ b/server/item/totem.go @@ -0,0 +1,19 @@ +package item + +// Totem is an uncommon combat item that can save holders from death. +type Totem struct{} + +// MaxCount always returns 1. +func (Totem) MaxCount() int { + return 1 +} + +// EncodeItem ... +func (Totem) EncodeItem() (name string, meta int16) { + return "minecraft:totem_of_undying", 0 +} + +// OffHand ... +func (Totem) OffHand() bool { + return true +} diff --git a/server/item/tropical_fish.go b/server/item/tropical_fish.go new file mode 100644 index 0000000..c33d8a6 --- /dev/null +++ b/server/item/tropical_fish.go @@ -0,0 +1,19 @@ +package item + +import "github.com/df-mc/dragonfly/server/world" + +// TropicalFish is a food item that cannot be cooked. +type TropicalFish struct { + defaultFood +} + +// Consume ... +func (TropicalFish) Consume(_ *world.Tx, c Consumer) Stack { + c.Saturate(1, 0.2) + return Stack{} +} + +// EncodeItem ... +func (TropicalFish) EncodeItem() (name string, meta int16) { + return "minecraft:tropical_fish", 0 +} diff --git a/server/item/turtle_shell.go b/server/item/turtle_shell.go new file mode 100644 index 0000000..42ec892 --- /dev/null +++ b/server/item/turtle_shell.go @@ -0,0 +1,62 @@ +package item + +import "github.com/df-mc/dragonfly/server/world" + +// TurtleShell are items that are used for brewing or as a helmet to give the player the Water Breathing +// status effect. +type TurtleShell struct{} + +// Use handles the using of a turtle shell to auto-equip it in an armour slot. +func (TurtleShell) Use(_ *world.Tx, _ User, ctx *UseContext) bool { + ctx.SwapHeldWithArmour(0) + return false +} + +// DurabilityInfo ... +func (TurtleShell) DurabilityInfo() DurabilityInfo { + return DurabilityInfo{ + MaxDurability: 276, + BrokenItem: simpleItem(Stack{}), + } +} + +// RepairableBy ... +func (TurtleShell) RepairableBy(i Stack) bool { + _, ok := i.Item().(Scute) + return ok +} + +// MaxCount always returns 1. +func (TurtleShell) MaxCount() int { + return 1 +} + +// DefencePoints ... +func (TurtleShell) DefencePoints() float64 { + return 2 +} + +// Toughness ... +func (TurtleShell) Toughness() float64 { + return 0 +} + +// KnockBackResistance ... +func (TurtleShell) KnockBackResistance() float64 { + return 0 +} + +// EnchantmentValue ... +func (TurtleShell) EnchantmentValue() int { + return 9 +} + +// Helmet ... +func (TurtleShell) Helmet() bool { + return true +} + +// EncodeItem ... +func (TurtleShell) EncodeItem() (name string, meta int16) { + return "minecraft:turtle_helmet", 0 +} diff --git a/server/item/use.go b/server/item/use.go new file mode 100644 index 0000000..f1cd2eb --- /dev/null +++ b/server/item/use.go @@ -0,0 +1,38 @@ +package item + +// UseContext is passed to every item Use methods. It may be used to subtract items or to deal damage to them +// after the action is complete. +type UseContext struct { + // Damage is the amount of damage that should be dealt to the item as a result of using it. + Damage int + // CountSub is how much of the count should be subtracted after using the item. + CountSub int + // IgnoreBBox specifies if placing the item should ignore the BBox of the player placing this. This is the case for + // items such as cocoa beans. + IgnoreBBox bool + // NewItem is the item that is added after the item is used. If the player no longer has an item in the + // hand, it'll be added there. + NewItem Stack + // ConsumedItems contains a list of items that were consumed in the process of using the item. + ConsumedItems []Stack + // NewItemSurvivalOnly will add any new items only in survival mode. + NewItemSurvivalOnly bool + + // FirstFunc returns the first item in the context holder's inventory if found. The second return value describes + // whether the item was found. The comparable function is used to compare the item to the given item. + FirstFunc func(comparable func(Stack) bool) (Stack, bool) + + // SwapHeldWithArmour holds a function that swaps the item currently held by a User with armour slot i. + SwapHeldWithArmour func(i int) +} + +// Consume consumes the provided item when the context is handled. +func (ctx *UseContext) Consume(s Stack) { + ctx.ConsumedItems = append(ctx.ConsumedItems, s) +} + +// DamageItem damages the item used by d points. +func (ctx *UseContext) DamageItem(d int) { ctx.Damage += d } + +// SubtractFromCount subtracts d from the count of the item stack used. +func (ctx *UseContext) SubtractFromCount(d int) { ctx.CountSub += d } diff --git a/server/item/warped_fungus_on_a_stick.go b/server/item/warped_fungus_on_a_stick.go new file mode 100644 index 0000000..e1b3c12 --- /dev/null +++ b/server/item/warped_fungus_on_a_stick.go @@ -0,0 +1,14 @@ +package item + +// WarpedFungusOnAStick is an item that can be used to control saddled striders. +type WarpedFungusOnAStick struct{} + +// MaxCount ... +func (WarpedFungusOnAStick) MaxCount() int { + return 1 +} + +// EncodeItem ... +func (WarpedFungusOnAStick) EncodeItem() (name string, meta int16) { + return "minecraft:warped_fungus_on_a_stick", 0 +} diff --git a/server/item/wheat.go b/server/item/wheat.go new file mode 100644 index 0000000..aea6c1f --- /dev/null +++ b/server/item/wheat.go @@ -0,0 +1,14 @@ +package item + +// Wheat is a crop used to craft bread, cake, & cookies. +type Wheat struct{} + +// CompostChance ... +func (Wheat) CompostChance() float64 { + return 0.65 +} + +// EncodeItem ... +func (w Wheat) EncodeItem() (name string, meta int16) { + return "minecraft:wheat", 0 +} diff --git a/server/item/written_book.go b/server/item/written_book.go new file mode 100644 index 0000000..fb65857 --- /dev/null +++ b/server/item/written_book.go @@ -0,0 +1,76 @@ +package item + +// WrittenBook is the item created after a book and quill is signed. It appears the same as a regular book, but +// without the quill, and has an enchanted-looking glint. +type WrittenBook struct { + // Title is the title of the book. + Title string + // Author is the author of the book. + Author string + // Generation is the copy tier of the book. 0 = original, 1 = copy of original, + // 2 = copy of copy. + Generation WrittenBookGeneration + // Pages represents the pages within the book. + Pages []string +} + +// MaxCount always returns 16. +func (WrittenBook) MaxCount() int { + return 16 +} + +// TotalPages returns the total number of pages in the book. +func (w WrittenBook) TotalPages() int { + return len(w.Pages) +} + +// Page returns a specific page from the book and true when the page exists. It will otherwise return an empty string +// and false. +func (w WrittenBook) Page(page int) (string, bool) { + if page < 0 || len(w.Pages) <= page { + return "", false + } + return w.Pages[page], true +} + +// DecodeNBT ... +func (w WrittenBook) DecodeNBT(data map[string]any) any { + if pages, ok := data["pages"].([]any); ok { + w.Pages = make([]string, len(pages)) + for i, page := range pages { + w.Pages[i] = page.(map[string]any)["text"].(string) + } + } + w.Title, _ = data["title"].(string) + w.Author, _ = data["author"].(string) + if v, ok := data["generation"].(uint8); ok { + switch v { + case 0: + w.Generation = OriginalGeneration() + case 1: + w.Generation = CopyGeneration() + case 2: + w.Generation = CopyOfCopyGeneration() + } + } + return w +} + +// EncodeNBT ... +func (w WrittenBook) EncodeNBT() map[string]any { + pages := make([]any, 0, len(w.Pages)) + for _, page := range w.Pages { + pages = append(pages, map[string]any{"text": page}) + } + return map[string]any{ + "pages": pages, + "author": w.Author, + "title": w.Title, + "generation": w.Generation.Uint8(), + } +} + +// EncodeItem ... +func (WrittenBook) EncodeItem() (name string, meta int16) { + return "minecraft:written_book", 0 +} diff --git a/server/item/written_book_generation.go b/server/item/written_book_generation.go new file mode 100644 index 0000000..8e7700a --- /dev/null +++ b/server/item/written_book_generation.go @@ -0,0 +1,41 @@ +package item + +// WrittenBookGeneration represents a WrittenBook generation. +type WrittenBookGeneration struct { + generation +} + +type generation uint8 + +// OriginalGeneration is the original WrittenBook. +func OriginalGeneration() WrittenBookGeneration { + return WrittenBookGeneration{0} +} + +// CopyGeneration is a copy of the original WrittenBook. +func CopyGeneration() WrittenBookGeneration { + return WrittenBookGeneration{1} +} + +// CopyOfCopyGeneration is a copy of a copy of the original WrittenBook. +func CopyOfCopyGeneration() WrittenBookGeneration { + return WrittenBookGeneration{2} +} + +// Uint8 returns the generation as a uint8. +func (g generation) Uint8() uint8 { + return uint8(g) +} + +// String ... +func (g generation) String() string { + switch g { + case 0: + return "original" + case 1: + return "copy of original" + case 2: + return "copy of copy" + } + panic("unknown written book generation") +} diff --git a/server/listener.go b/server/listener.go new file mode 100644 index 0000000..0c7e46a --- /dev/null +++ b/server/listener.go @@ -0,0 +1,65 @@ +package server + +import ( + "context" + "fmt" + "io" + "log/slog" + + "github.com/df-mc/dragonfly/server/session" + "github.com/sandertv/gophertunnel/minecraft" +) + +// Listener is a source for connections that may be listened on by a Server using Server.listen. Proxies can use this to +// provide players from a different source. +type Listener interface { + // Accept blocks until the next connection is established and returns it. An error is returned if the Listener was + // closed using Close. + Accept() (session.Conn, error) + // Disconnect disconnects a connection from the Listener with a reason. + Disconnect(conn session.Conn, reason string) error + io.Closer +} + +// listenerFunc may be used to return a *minecraft.Listener using a Config. It +// is the standard listener used when UserConfig.Config() is called. +func (uc UserConfig) listenerFunc(conf Config) (Listener, error) { + cfg := minecraft.ListenConfig{ + MaximumPlayers: conf.MaxPlayers, + StatusProvider: conf.StatusProvider, + AuthenticationDisabled: conf.AuthDisabled, + ResourcePacks: conf.Resources, + TexturePacksRequired: conf.ResourcesRequired, + Compression: conf.Compression, + } + if conf.Log.Enabled(context.Background(), slog.LevelDebug) { + cfg.ErrorLog = conf.Log.With("net origin", "gophertunnel") + } + l, err := cfg.Listen("raknet", uc.Network.Address) + if err != nil { + return nil, fmt.Errorf("create minecraft listener: %w", err) + } + conf.Log.Info("Listener running.", "addr", l.Addr()) + return listener{l}, nil +} + +// listener is a Listener implementation that wraps around a minecraft.Listener so that it can be listened on by +// Server. +type listener struct { + *minecraft.Listener +} + +// Accept blocks until the next connection is established and returns it. An error is returned if the Listener was +// closed using Close. +func (l listener) Accept() (session.Conn, error) { + conn, err := l.Listener.Accept() + if err != nil { + return nil, err + } + return conn.(session.Conn), err +} + +// Disconnect disconnects a connection from the Listener with a reason. +func (l listener) Disconnect(conn session.Conn, reason string) error { + return l.Listener.Disconnect(conn.(*minecraft.Conn), reason) +} diff --git a/server/player/bossbar/bossbar.go b/server/player/bossbar/bossbar.go new file mode 100644 index 0000000..4143ff4 --- /dev/null +++ b/server/player/bossbar/bossbar.go @@ -0,0 +1,61 @@ +package bossbar + +import ( + "fmt" + "strings" +) + +// BossBar represents a boss bar that may be sent to a player. It is shown as a purple bar with text above +// it. The health shown by the bar may be changed. +type BossBar struct { + text string + health float64 + c Colour +} + +// New creates a new boss bar with the text passed. The text is formatted according to the rules of +// fmt.Sprintln. +// By default, the boss bar will have a full health bar. To change this, use BossBar.WithHealthPercentage(). +// The default colour of the BossBar is Purple. This can be changed using BossBar.WithColour. +func New(text ...any) BossBar { + return BossBar{text: format(text), health: 1, c: Purple()} +} + +// Text returns the text of the boss bar: The text passed when creating the bar using New(). +func (bar BossBar) Text() string { + return bar.text +} + +// WithHealthPercentage sets the health percentage of the boss bar. The value passed must be between 0 and 1. +// If a value out of that range is passed, WithHealthPercentage panics. +// The new BossBar with the changed health percentage is returned. +func (bar BossBar) WithHealthPercentage(v float64) BossBar { + if v < 0 || v > 1 { + panic("boss bar: value out of range: health percentage must be between 0.0 and 1.0") + } + bar.health = v + return bar +} + +// WithColour returns a copy of the BossBar with the Colour passed. +func (bar BossBar) WithColour(c Colour) BossBar { + bar.c = c + return bar +} + +// HealthPercentage returns the health percentage of the boss bar. The number returned is a value between 0 +// and 1, with 0 being an empty boss bar and 1 being a full one. +func (bar BossBar) HealthPercentage() float64 { + return bar.health +} + +// Colour returns the colour of the BossBar. +func (bar BossBar) Colour() Colour { + return bar.c +} + +// format is a utility function to format a list of values to have spaces between them, but no newline at the +// end, which is typically used for sending messages, popups and tips. +func format(a []any) string { + return strings.TrimSuffix(fmt.Sprintln(a...), "\n") +} diff --git a/server/player/bossbar/colour.go b/server/player/bossbar/colour.go new file mode 100644 index 0000000..56ef674 --- /dev/null +++ b/server/player/bossbar/colour.go @@ -0,0 +1,50 @@ +package bossbar + +// Colour is the colour of a BossBar. +type Colour struct{ colour } + +// Pink is the colour for a pink boss bar. +func Pink() Colour { + return Colour{colour(0)} +} + +// Blue is the colour for a blue boss bar. +func Blue() Colour { + return Colour{colour(1)} +} + +// Red is the colour for a red boss bar. +func Red() Colour { + return Colour{colour(2)} +} + +// Green is the colour for a green boss bar. +func Green() Colour { + return Colour{colour(3)} +} + +// Yellow is the colour for a yellow boss bar. +func Yellow() Colour { + return Colour{colour(4)} +} + +// Purple is the colour for a purple boss bar. +func Purple() Colour { + return Colour{colour(5)} +} + +// RebeccaPurple is the colour for a rebecca purple boss bar. +func RebeccaPurple() Colour { + return Colour{colour(6)} +} + +// White is the colour for a white boss bar. +func White() Colour { + return Colour{colour(7)} +} + +type colour uint8 + +func (c colour) Uint8() uint8 { + return uint8(c) +} diff --git a/server/player/chat/chat.go b/server/player/chat/chat.go new file mode 100644 index 0000000..fb6bf52 --- /dev/null +++ b/server/player/chat/chat.go @@ -0,0 +1,89 @@ +package chat + +import ( + "github.com/google/uuid" + "sync" +) + +// Global represents a global chat. Players will write in this chat by default +// when they send any message in the chat. +var Global = New() + +// Chat represents the in-game chat. Messages may be written to it to send a +// message to all subscribers. +// Methods on Chat may be called from multiple goroutines concurrently. Chat +// implements the io.Writer and io.StringWriter interfaces. fmt.Fprintf and +// fmt.Fprint may be used to write formatted messages to the chat. +type Chat struct { + m sync.Mutex + subscribers map[uuid.UUID]Subscriber +} + +// New returns a new chat. +func New() *Chat { + return &Chat{subscribers: map[uuid.UUID]Subscriber{}} +} + +// Write writes the byte slice p as a string to the chat. It is equivalent to +// calling Chat.WriteString(string(p)). +func (chat *Chat) Write(p []byte) (n int, err error) { + return chat.WriteString(string(p)) +} + +// WriteString writes a string s to the chat. +func (chat *Chat) WriteString(s string) (n int, err error) { + chat.m.Lock() + defer chat.m.Unlock() + for _, subscriber := range chat.subscribers { + subscriber.Message(s) + } + return len(s), nil +} + +// Writet writes a Translation message to a Chat, parameterising the message +// using the arguments passed. Messages are translated according to the locale +// of subscribers if they implement Translator. Subscribers that do not +// implement Translator have the fallback message sent. +func (chat *Chat) Writet(t Translation, a ...any) { + chat.m.Lock() + defer chat.m.Unlock() + for _, subscriber := range chat.subscribers { + if translator, ok := subscriber.(Translator); ok { + translator.Messaget(t, a...) + continue + } + subscriber.Message(t.F(a...).String()) + } +} + +// Subscribe adds a subscriber to the chat, sending it every message written to +// the chat. In order to remove it again, use Chat.Unsubscribe(). +func (chat *Chat) Subscribe(s Subscriber) { + chat.m.Lock() + defer chat.m.Unlock() + chat.subscribers[s.UUID()] = s +} + +// Subscribed checks if a subscriber is currently subscribed to the chat. +func (chat *Chat) Subscribed(s Subscriber) bool { + chat.m.Lock() + defer chat.m.Unlock() + _, ok := chat.subscribers[s.UUID()] + return ok +} + +// Unsubscribe removes a subscriber from the chat, so that messages written to +// the chat will no longer be sent to it. +func (chat *Chat) Unsubscribe(s Subscriber) { + chat.m.Lock() + defer chat.m.Unlock() + delete(chat.subscribers, s.UUID()) +} + +// Close closes the chat, removing all subscribers from it. +func (chat *Chat) Close() error { + chat.m.Lock() + chat.subscribers = nil + chat.m.Unlock() + return nil +} diff --git a/server/player/chat/subscriber.go b/server/player/chat/subscriber.go new file mode 100644 index 0000000..a45c025 --- /dev/null +++ b/server/player/chat/subscriber.go @@ -0,0 +1,51 @@ +package chat + +import ( + "fmt" + "github.com/google/uuid" + "github.com/sandertv/gophertunnel/minecraft/text" + "strings" +) + +// Subscriber represents an entity that may subscribe to a Chat. In order to do +// so, the Subscriber must implement methods to send messages to it. +type Subscriber interface { + // UUID returns a unique ID for the Subscriber. + UUID() uuid.UUID + // Message sends a formatted message to the subscriber. The message is + // formatted as it would when using fmt.Println. + Message(a ...any) +} + +// Translator is a Subscriber that is able to translate messages to their own +// locale. +type Translator interface { + // Messaget sends a Translation message to the Translator, using the + // arguments passed to fill out any translation parameters. + Messaget(t Translation, a ...any) +} + +// StdoutSubscriber is an implementation of Subscriber that forwards messages +// sent to the chat to the stdout. +type StdoutSubscriber struct{} + +var id = uuid.New() + +// UUID ... +func (c StdoutSubscriber) UUID() uuid.UUID { + return id +} + +// Message ... +func (c StdoutSubscriber) Message(a ...any) { + s := make([]string, len(a)) + for i, b := range a { + s[i] = fmt.Sprint(b) + } + t := text.ANSI(strings.Join(s, " ")) + if !strings.HasSuffix(t, "\n") { + fmt.Println(t) + return + } + fmt.Print(t) +} diff --git a/server/player/chat/translate.go b/server/player/chat/translate.go new file mode 100644 index 0000000..5a263e8 --- /dev/null +++ b/server/player/chat/translate.go @@ -0,0 +1,130 @@ +package chat + +import ( + "fmt" + + "github.com/sandertv/gophertunnel/minecraft/text" + "golang.org/x/text/language" +) + +// https://github.com/Mojang/bedrock-samples/blob/main/resource_pack/texts/en_GB.lang + +var MessageJoin = Translate(str("%multiplayer.player.joined"), 1, `%v joined the game`).Enc("%v") +var MessageQuit = Translate(str("%multiplayer.player.left"), 1, `%v left the game`).Enc("%v") +var MessageServerDisconnect = Translate(str("%disconnect.disconnected"), 0, `Disconnected by Server`).Enc("%v") + +var MessageBedTooFar = Translate(str("%tile.bed.tooFar"), 0, `Bed is too far away`).Enc("%v") +var MessageBedObstructed = Translate(str("%tile.bed.obstructed"), 0, `Bed is obstructed`).Enc("%v") +var MessageRespawnPointSet = Translate(str("%tile.bed.respawnSet"), 0, `Respawn point set`).Enc("%v") +var MessageNoSleep = Translate(str("%tile.bed.noSleep"), 0, `You can only sleep at night and during thunderstorms`).Enc("%v") +var MessageBedIsOccupied = Translate(str("%tile.bed.occupied"), 0, `This bed is occupied`).Enc("%v") +var MessageSleeping = Translate(str("%chat.type.sleeping"), 2, `%v is sleeping in a bed. To skip to dawn, %v more users need to sleep in beds at the same time.`) +var MessageBedNotValid = Translate(str("%tile.bed.notValid"), 0, `Your home bed was missing or obstructed`) + +type str string + +// Resolve returns the translation identifier as a string. +func (s str) Resolve(language.Tag) string { return string(s) } + +// TranslationString is a value that can resolve a translated version of itself +// for a language.Tag passed. +type TranslationString interface { + // Resolve finds a suitable translated version for a translation string for + // a specific language.Tag. + Resolve(l language.Tag) string +} + +// Translate returns a Translation for a TranslationString. The required number +// of parameters specifies how many arguments may be passed to Translation.F. +// The fallback string should be a 'standard' translation of the string, which +// is used when translation.String is called on the translation that results +// from a call to Translation.F. This fallback string should have as many +// formatting identifiers (like in fmt.Sprintf) as the number of params. +func Translate(str TranslationString, params int, fallback string) Translation { + return Translation{str: str, params: params, fallback: fallback, format: "%v"} +} + +// Translation represents a TranslationString with additional formatting, that +// may be filled out by calling F on it with a list of arguments for the +// translation. +type Translation struct { + str TranslationString + format string + params int + fallback string +} + +// Zero returns false if a Translation was not created using Translate or +// Untranslated. +func (t Translation) Zero() bool { + return t.format == "" +} + +// Enc encapsulates the translation string into the format passed. This format +// should have exactly one formatting identifier, %v, to specify where the +// translation string should go, such as 'translation: %v'. +// Enc accepts colouring formats parsed by text.Colourf. +func (t Translation) Enc(format string) Translation { + t.format = format + return t +} + +// Resolve passes 0 arguments to the translation and resolves the translation +// string for the language passed. It is equal to calling t.F().Resolve(l). +// Resolve panics if the Translation requires at least 1 argument. +func (t Translation) Resolve(l language.Tag) string { + return t.F().Resolve(l) +} + +// F takes arguments for a translation string passed and returns a filled out +// translation that may be sent to players. The number of arguments passed must +// be exactly equal to the number specified in Translate. If not, F will panic. +// Arguments passed are converted to strings using fmt.Sprint(). Exceptions are +// made for argument values of the type TranslationString, Translation and +// translation, which are resolved based on the Translator's language. +// Translations used as arguments should not require any parameters. +func (t Translation) F(a ...any) translation { + if len(a) != t.params { + panic(fmt.Sprintf("translation '%v' requires exactly %v parameters, got %v", t.format, t.params, len(a))) + } + return translation{t: t, params: a} +} + +// translation is a translation string with its arguments filled out. Resolve may +// be called to obtain the translated version of the translation string and +// Params may be called to obtain the parameters passed in Translation.F. +// translation implements the fmt.Stringer and error interfaces. +type translation struct { + t Translation + params []any +} + +// Resolve translates the TranslationString of the translation to the language +// passed and returns it. +func (t translation) Resolve(l language.Tag) string { + return text.Colourf(t.t.format, t.t.str.Resolve(l)) +} + +// Params returns a slice of values that are used to parameterise the +// translation returned by Resolve. +func (t translation) Params(l language.Tag) []string { + params := make([]string, len(t.params)) + for i, arg := range t.params { + if str, ok := arg.(TranslationString); ok { + params[i] = str.Resolve(l) + continue + } + params[i] = fmt.Sprint(arg) + } + return params +} + +// String formats and returns the fallback value of the translation. +func (t translation) String() string { + return fmt.Sprintf(text.Colourf(t.t.format, t.t.fallback), t.params...) +} + +// Error formats and returns the fallback value of the translation. +func (t translation) Error() string { + return t.String() +} diff --git a/server/player/conf.go b/server/player/conf.go new file mode 100644 index 0000000..194156c --- /dev/null +++ b/server/player/conf.go @@ -0,0 +1,126 @@ +package player + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/entity" + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/item/inventory" + "github.com/df-mc/dragonfly/server/player/skin" + "github.com/df-mc/dragonfly/server/session" + "github.com/df-mc/dragonfly/server/world" + "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. +type Config struct { + Session *session.Session + Skin skin.Skin + XUID string + UUID uuid.UUID + Name string + Locale language.Tag + GameMode world.GameMode + + Position mgl64.Vec3 + Rotation cube.Rotation + Velocity mgl64.Vec3 + Health float64 + MaxHealth float64 + FoodTick int + Food int + Exhaustion, Saturation float64 + AirSupply int + MaxAirSupply int + EnchantmentSeed int64 + Experience int + HeldSlot int + Inventory *inventory.Inventory + OffHand *inventory.Inventory + Armour *inventory.Armour + EnderChestInventory *inventory.Inventory + FireTicks int64 + FallDistance float64 + Effects []effect.Effect +} + +// Apply applies fields from a Config to a world.EntityData, filling out empty +// fields with reasonable defaults. +func (cfg Config) Apply(data *world.EntityData) { + conf := fillDefaults(cfg) + + data.Name, data.Pos, data.Rot = conf.Name, conf.Position, conf.Rotation + slot := uint32(conf.HeldSlot) + pdata := &playerData{ + xuid: conf.XUID, + ui: inventory.New(54, nil), + inv: conf.Inventory, + enderChest: conf.EnderChestInventory, + offHand: conf.OffHand, + armour: conf.Armour, + hunger: newHungerManager(), + health: entity.NewHealthManager(conf.Health, conf.MaxHealth), // 20, 20 + experience: entity.NewExperienceManager(), + effects: entity.NewEffectManager(conf.Effects...), + locale: conf.Locale, + cooldowns: make(map[string]time.Time), + mc: &entity.MovementComputer{Gravity: 0.08, Drag: 0.02, DragBeforeGravity: true}, + heldSlot: &slot, + gameMode: conf.GameMode, + skin: conf.Skin, + enchantSeed: conf.EnchantmentSeed, + s: conf.Session, + h: NopHandler{}, + speed: 0.1, + flightSpeed: 0.05, + verticalFlightSpeed: 1.0, + scale: 1.0, + airSupplyTicks: conf.AirSupply, + maxAirSupplyTicks: conf.MaxAirSupply, + breathing: true, + nameTag: conf.Name, + fireTicks: conf.FireTicks, + fallDistance: conf.FallDistance, + } + 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 +} + +// fillDefaults fills empty fields in a Config with reasonable default values. +func fillDefaults(conf Config) Config { + if (conf.Locale == language.Tag{}) { + conf.Locale = language.BritishEnglish + } + if conf.Inventory == nil { + conf.Inventory = inventory.New(36, nil) + } + if conf.EnderChestInventory == nil { + conf.EnderChestInventory = inventory.New(27, nil) + } + if conf.OffHand == nil { + conf.OffHand = inventory.New(1, nil) + } + if conf.Armour == nil { + conf.Armour = inventory.NewArmour(nil) + } + if conf.Food == 0 && conf.FoodTick == 0 && conf.Exhaustion == 0 && conf.Saturation == 0 { + conf.Food, conf.Saturation = 20, 5 + } + if conf.EnchantmentSeed == 0 { + conf.EnchantmentSeed = rand.Int64() + } + if conf.MaxAirSupply == 0 { + conf.AirSupply, conf.MaxAirSupply = 300, 300 + } + if conf.MaxHealth == 0 { + conf.MaxHealth, conf.Health = 20, 20 + } + if conf.GameMode == nil { + conf.GameMode = world.GameModeSurvival + } + return conf +} diff --git a/server/player/debug/renderer.go b/server/player/debug/renderer.go new file mode 100644 index 0000000..0116f5a --- /dev/null +++ b/server/player/debug/renderer.go @@ -0,0 +1,14 @@ +package debug + +// Renderer represents an interface for a renderer that can manage debug shapes. +type Renderer interface { + // AddDebugShape adds a debug shape to the renderer, which should be rendered to the player. If the shape + // already exists, it will be updated with the new information. + AddDebugShape(shape Shape) + // RemoveDebugShape removes a debug shape from the renderer by its unique identifier. + RemoveDebugShape(shape Shape) + // VisibleDebugShapes returns a slice of all debug shapes that are currently being shown by the renderer. + VisibleDebugShapes() []Shape + // RemoveAllDebugShapes clears all debug shapes from the renderer, removing them from the view of the player. + RemoveAllDebugShapes() +} diff --git a/server/player/debug/shape.go b/server/player/debug/shape.go new file mode 100644 index 0000000..66bcc9b --- /dev/null +++ b/server/player/debug/shape.go @@ -0,0 +1,255 @@ +package debug + +import ( + "image/color" + "sync/atomic" + + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +var nextShapeID atomic.Int32 + +// Shape represents a shape that can be drawn to a player from any point in the world. +type Shape interface { + // ShapeID returns the unique identifier of the shape. This is used to either update or remove the shape + // after it has been sent to the player. + ShapeID() int +} + +// shape is a base type for all shapes that implements the Shape interface. It contains a unique identifier +// that is lazily initialised when the ShapeID method is called for the first time. +type shape struct { + id atomic.Int32 +} + +// ShapeID ... +func (s *shape) ShapeID() int { + if id := s.id.Load(); id != 0 { + return int(id) + } + s.id.CompareAndSwap(0, nextShapeID.Add(1)) + return int(s.id.Load()) +} + +// Arrow represents an arrow shape that can be drawn at any point in the world. It has a head which can also +// be positioned anywhere, and the length, radius and number of segments can be changed. +type Arrow struct { + shape + + // Colour is the colour that will be used for the line and head. If empty, it will default to white. + Colour color.RGBA + // Position is the origin position of the shape in the world. + Position mgl64.Vec3 + // EndPosition is the end position of the arrow in the world. The arrow will be drawn from Position to + // EndPosition, with the head being drawn at EndPosition. + EndPosition mgl64.Vec3 + // HeadLength is the length of the head to be drawn at the end of the arrow. If zero, it will default + // to 1.0. + HeadLength float64 + // HeadRadius is the radius of the head to be drawn at the end of the arrow. If zero, it will default + // to 0.5. + HeadRadius float64 + // HeadSegments is the number of segments that the head of the arrow will be drawn with. The more + // segments, the smoother the head will look. If zero, it will default to 4. + HeadSegments int + // Entity is an optional entity handle to attach the shape to. + Entity *world.EntityHandle +} + +// Box represents a hollow box that can be drawn at any point in the world, with a bounds that can be set. +type Box 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 + // Bounds is the size of the box in the world, acting as an offset from the Position. If empty, + // it will default to a 1x1x1 box. + Bounds mgl64.Vec3 + // Entity is an optional entity handle to attach the shape to. + Entity *world.EntityHandle +} + +// Circle represents a hollow circle that can be drawn at any point in the world, with the scale being used +// to control the radius. +type Circle 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 radius of the circle. If zero, it will default to 1.0. + Scale float64 + // Segments is the number of segments that the circle will be drawn with. The more segments, the smoother + // the circle will look. If empty, it will default to 20. + Segments int + // Entity is an optional entity handle to attach the shape to. + Entity *world.EntityHandle +} + +// Line represents a line that can be drawn at any point in the world, with a start and end position. +type Line struct { + shape + + // Colour is the colour that will be used for the line. If empty, it will default to white. + Colour color.RGBA + // Position is the origin position of the shape in the world. + Position mgl64.Vec3 + // EndPosition is the end position of the line in the world. The line will be drawn from Position to + // EndPosition. + EndPosition mgl64.Vec3 + // Entity is an optional entity handle to attach the shape to. + Entity *world.EntityHandle +} + +// Sphere represents a hollow sphere that can be drawn at any point in the world, with one line in each axis. +// The scale is used to control the radius of the sphere. +type Sphere 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 radius of the sphere. If zero, it will default to 1.0. + Scale float64 + // Segments is the number of segments that the circle will be drawn with. The more segments, the smoother + // the circle will look. If empty, it will default to 20. + Segments int + // Entity is an optional entity handle to attach the shape to. + Entity *world.EntityHandle +} + +// Text represents text that can be drawn at any point in the world, looking like a normal entity nametag +// without actually being attached to an entity. +type Text struct { + shape + + // Colour is the colour that will be used for the actual text. If empty, the text will default to white. + Colour color.RGBA + // BackgroundColour is the colour used for the text background. If empty, it will default to a + // translucent black. + BackgroundColour color.RGBA + // HideBackground specifies whether the text background should be hidden entirely. Takes precedence + // over BackgroundColour when set. + HideBackground bool + // Position is the origin position of the shape in the world. + Position mgl64.Vec3 + // Rotation is the rotation of the shape, applied only when LockRotation is set. + Rotation mgl64.Vec3 + // Scale is the size of the text. If zero, it will default to 1.0. + Scale float64 + // Text is the text to be displayed on the shape. The background automatically scales to fit the text. + Text string + // LockRotation specifies whether the text should be locked to the orientation set by Rotation. + // If false, the text will rotate to always face the camera. + LockRotation bool + // DisableDepthTest specifies whether the text should show through walls. If false, the text + // will be occluded by geometry in front of it. + DisableDepthTest bool + // HideBackface specifies whether the background should be hidden on the back side of the shape. + // Has no visible effect unless LockRotation is also set. + HideBackface bool + // HideBackfaceText specifies whether the text should be hidden on the back side of the shape. + // Has no visible effect unless LockRotation is also set. + HideBackfaceText bool + // 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/dialogue/button.go b/server/player/dialogue/button.go new file mode 100644 index 0000000..5d1e47a --- /dev/null +++ b/server/player/dialogue/button.go @@ -0,0 +1,21 @@ +package dialogue + +import "encoding/json" + +// Button represents a button added to a dialogue menu and consists of just +// text. +type Button struct { + // Text holds the text displayed on the button. It may use Minecraft + // formatting codes. + Text string +} + +// MarshalJSON ... +func (b Button) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]any{ + "button_name": b.Text, + "text": "", + "mode": 0, // "Click" activation + "type": 1, // "Command" type + }) +} diff --git a/server/player/dialogue/dialogue.go b/server/player/dialogue/dialogue.go new file mode 100644 index 0000000..98018f2 --- /dev/null +++ b/server/player/dialogue/dialogue.go @@ -0,0 +1,142 @@ +package dialogue + +import ( + "encoding/json" + "fmt" + "github.com/df-mc/dragonfly/server/world" + "reflect" + "strings" +) + +// Dialogue represents a dialogue menu. This menu can consist of a title, a +// body and up to 6 different buttons. The menu also shows a 3D render of the +// entity that is sending the dialogue. +type Dialogue struct { + title, body string + submittable Submittable + buttons []Button + display DisplaySettings +} + +// New creates a new Dialogue menu using the Submittable passed to handle the +// dialogue interactions. The title passed is formatted following the rules of +// fmt.Sprintln. +func New(submittable Submittable, title ...any) Dialogue { + t := reflect.TypeOf(submittable) + if t.Kind() != reflect.Struct { + panic("submittable must be struct") + } + m := Dialogue{title: format(title), submittable: submittable} + m.verify() + return m +} + +// MarshalJSON ... +func (m Dialogue) MarshalJSON() ([]byte, error) { + return json.Marshal(m.Buttons()) +} + +// WithBody creates a copy of the Dialogue and changes its body to the body +// passed, after which the new Dialogue is returned. The text is formatted +// following the rules of fmt.Sprintln. +func (m Dialogue) WithBody(body ...any) Dialogue { + m.body = format(body) + return m +} + +// WithDisplay returns a new Dialogue with the DisplaySettings passed. +func (m Dialogue) WithDisplay(display DisplaySettings) Dialogue { + m.display = display + return m +} + +// WithButtons creates a copy of the Dialogue and appends the buttons passed to +// the existing buttons, after which the new Dialogue is returned. +func (m Dialogue) WithButtons(buttons ...Button) Dialogue { + m.buttons = append(m.buttons, buttons...) + m.verify() + return m +} + +// Title returns the formatted title passed to the dialogue upon construction +// using New(). +func (m Dialogue) Title() string { + return m.title +} + +// Body returns the formatted text in the body passed to the menu using +// WithBody(). +func (m Dialogue) Body() string { + return m.body +} + +// Display returns the DisplaySettings of the Dialogue as specified using +// WithDisplay(). +func (m Dialogue) Display() DisplaySettings { + return m.display +} + +// Buttons returns a slice of buttons of the Submittable. It parses them from +// the fields using reflection and returns them. +func (m Dialogue) Buttons() []Button { + v := reflect.New(reflect.TypeOf(m.submittable)).Elem() + v.Set(reflect.ValueOf(m.submittable)) + + buttons := make([]Button, 0) + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + if !field.CanSet() { + continue + } + // Each exported field is guaranteed to be of type Button. + buttons = append(buttons, field.Interface().(Button)) + } + buttons = append(buttons, m.buttons...) + return buttons +} + +// Submit submits an index of the pressed button to the Submittable. If the +// index is invalid, an error is returned. +func (m Dialogue) Submit(index uint, submitter Submitter, tx *world.Tx) error { + buttons := m.Buttons() + if index >= uint(len(buttons)) { + return fmt.Errorf("button index points to inexistent button: %v (only %v buttons present)", index, len(buttons)) + } + m.submittable.Submit(submitter, buttons[index], tx) + return nil +} + +// Close closes the dialogue, calling the Close method on the Submittable if it +// implements the Closer interface. +func (m Dialogue) Close(submitter Submitter, tx *world.Tx) { + if closer, ok := m.submittable.(Closer); ok { + closer.Close(submitter, tx) + } +} + +// verify verifies if the dialogue is valid, checking all fields are of the +// type Button and there are no more than 6 buttons in total. It panics if the +// dialogue is invalid. +func (m Dialogue) verify() { + v := reflect.New(reflect.TypeOf(m.submittable)).Elem() + v.Set(reflect.ValueOf(m.submittable)) + var buttons int + for i := 0; i < v.NumField(); i++ { + if !v.Field(i).CanSet() { + continue + } + if _, ok := v.Field(i).Interface().(Button); !ok { + panic("all exported fields must be of the type dialogue.Button") + } + buttons++ + } + if buttons+len(m.buttons) > 6 { + panic("maximum of 6 buttons allowed") + } +} + +// format is a utility function to format a list of values to have spaces +// between them, but no newline at the end. +func format(a []any) string { + return strings.TrimSuffix(strings.TrimSuffix(fmt.Sprintln(a...), "\n"), "\n") +} diff --git a/server/player/dialogue/display.go b/server/player/dialogue/display.go new file mode 100644 index 0000000..0ebca3a --- /dev/null +++ b/server/player/dialogue/display.go @@ -0,0 +1,38 @@ +package dialogue + +import ( + "encoding/json" + "github.com/go-gl/mathgl/mgl64" +) + +// DisplaySettings holds optional fields that change the way the dialogue, +// particularly the entity shown in it, is displayed. +type DisplaySettings struct { + // EntityScale specifies the scale of the entity displayed in the dialogue. + EntityScale mgl64.Vec3 + // EntityOffset specifies the offset of the entity shown in the dialogue. + EntityOffset mgl64.Vec3 + // EntityRotation is the rotation of the entity shown in the dialogue. This + // rotation functions a bit differently to the normal entity rotation in + // Minecraft: The values are still degrees, but pitch (rot[1]) values are + // whole-body pitch instead of head-specific, and rot[2] is whole-body roll. + EntityRotation mgl64.Vec3 +} + +// MarshalJSON encodes the DisplaySettings to JSON. +func (d DisplaySettings) MarshalJSON() ([]byte, error) { + // Yaw and pitch are swapped in this display. + d.EntityRotation[0], d.EntityRotation[1] = d.EntityRotation[1], d.EntityRotation[0]-32 + m := map[string]any{ + // Translate needs to be multiplied by -32 to get a rough block + // equivalent. + "translate": d.EntityOffset.Mul(-32), + // Entity is rotated by 32 degrees by default. + "rotate": d.EntityRotation, + "scale": [3]float64{1, 1, 1}, + } + if (d.EntityScale != mgl64.Vec3{}) { + m["scale"] = d.EntityScale + } + return json.Marshal(m) +} diff --git a/server/player/dialogue/submit.go b/server/player/dialogue/submit.go new file mode 100644 index 0000000..c1ed1c1 --- /dev/null +++ b/server/player/dialogue/submit.go @@ -0,0 +1,30 @@ +package dialogue + +import "github.com/df-mc/dragonfly/server/world" + +// Submittable is a structure which may be submitted by sending it as a dialogue +// using dialogue.New(). The struct will have its Submit method called with the +// button pressed. A struct that implements the Submittable interface must only +// have exported fields with the type dialogue.Button. +type Submittable interface { + // Submit is called when the Submitter submits the dialogue sent to it. The + // method is called with the button that was pressed. It may be compared + // with buttons in the Submittable struct to check which button was pressed. + // Additionally, the world.Tx of the Submitter is passed. + Submit(submitter Submitter, pressed Button, tx *world.Tx) +} + +// Submitter is an entity that is able to submit a dialogue sent to it. It is +// able to interact with the buttons in the dialogue. The Submitter is also +// able to close the dialogue. +type Submitter interface { + SendDialogue(d Dialogue, e world.Entity) + CloseDialogue() +} + +// Closer represents a dialogue which has special logic when being closed by a +// Submitter. +type Closer interface { + // Close is called when the Submitter closes a dialogue. + Close(submitter Submitter, tx *world.Tx) +} diff --git a/server/player/form/element.go b/server/player/form/element.go new file mode 100644 index 0000000..9e0bd84 --- /dev/null +++ b/server/player/form/element.go @@ -0,0 +1,344 @@ +package form + +import ( + "encoding/json" + "strings" +) + +// Element represents an element that may be added to a Form. Any of the types in this package that implement +// the element interface may be used as struct fields when passing the form structure to form.New(). +type Element interface { + json.Marshaler + elem() +} + +// MenuElement represents an element that may be added to a Menu form. This includes buttons, dividers, +// headers, and labels. +type MenuElement interface { + json.Marshaler + menuElem() +} + +// Divider represents a visual separator element on a form. It displays a horizontal line. +type Divider struct{} + +// MarshalJSON ... +func (d Divider) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]any{ + "type": "divider", + "text": "", + }) +} + +// Header represents a header element on a form. It displays larger, emphasised text for section titles. +type Header struct { + // Text is the text held by the header. The text may contain Minecraft formatting codes. + Text string +} + +// NewHeader creates and returns a new Header with the text passed. +func NewHeader(text string) Header { + return Header{Text: text} +} + +// MarshalJSON ... +func (h Header) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]any{ + "type": "header", + "text": h.Text, + }) +} + +// Label represents a static label on a form. It serves only to display a box of text, and users cannot +// submit values to it. +type Label struct { + // Text is the text held by the label. The text may contain Minecraft formatting codes. + Text string +} + +// NewLabel creates and returns a new Label with the values passed. +func NewLabel(text string) Label { + return Label{Text: text} +} + +// MarshalJSON ... +func (l Label) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]any{ + "type": "label", + "text": l.Text, + }) +} + +// Input represents a text input box element. Submitters may write any text in these boxes with no specific +// length. +type Input struct { + // Text is the text displayed over the input element. The text may contain Minecraft formatting codes. + Text string + // Default is the default value filled out in the input. The user may remove this value and fill out its + // own text. The text may contain Minecraft formatting codes. + Default string + // Placeholder is the text displayed in the input box if it does not contain any text filled out by the + // user. The text may contain Minecraft formatting codes. + Placeholder string + // Tooltip is an optional text displayed when hovering over the element's info icon. The icon only + // appears when a tooltip is set. + Tooltip string + + value string +} + +// NewInput creates and returns a new Input with the values passed. +func NewInput(text, defaultValue, placeholder string) Input { + return Input{Text: text, Default: defaultValue, Placeholder: placeholder} +} + +// WithTooltip returns a copy of the Input with the tooltip set. +func (i Input) WithTooltip(tooltip string) Input { + i.Tooltip = tooltip + return i +} + +// MarshalJSON ... +func (i Input) MarshalJSON() ([]byte, error) { + m := map[string]any{ + "type": "input", + "text": i.Text, + "default": i.Default, + "placeholder": i.Placeholder, + } + if i.Tooltip != "" { + m["tooltip"] = i.Tooltip + } + return json.Marshal(m) +} + +// Value returns the value filled out by the user. +func (i Input) Value() string { + return i.value +} + +// Toggle represents an on-off button element. Submitters may either toggle this on or off, which will then +// hold a value of true or false respectively. +type Toggle struct { + // Text is the text displayed over the toggle element. The text may contain Minecraft formatting codes. + Text string + // Default determines if the toggle should be on/off by default. + Default bool + // Tooltip is an optional text displayed when hovering over the element's info icon. The icon only + // appears when a tooltip is set. + Tooltip string + + value bool +} + +// NewToggle creates and returns a new Toggle with the values passed. +func NewToggle(text string, defaultValue bool) Toggle { + return Toggle{Text: text, Default: defaultValue} +} + +// WithTooltip returns a copy of the Toggle with the tooltip set. +func (t Toggle) WithTooltip(tooltip string) Toggle { + t.Tooltip = tooltip + return t +} + +// MarshalJSON ... +func (t Toggle) MarshalJSON() ([]byte, error) { + m := map[string]any{ + "type": "toggle", + "text": t.Text, + "default": t.Default, + } + if t.Tooltip != "" { + m["tooltip"] = t.Tooltip + } + return json.Marshal(m) +} + +// Value returns the value filled out by the user. +func (t Toggle) Value() bool { + return t.value +} + +// Slider represents a slider element. Submitters may move the slider to values within the range of the slider +// to select a value. +type Slider struct { + // Text is the text displayed over the slider element. The text may contain Minecraft formatting codes. + Text string + // Min and Max are used to specify the minimum and maximum range of the slider. A value lower or higher + // than these values cannot be selected. + Min, Max float64 + // StepSize is the size that one step of the slider takes up. When set to 1.0 for example, a submitter + // will be able to select only whole values. + StepSize float64 + // Default is the default value filled out for the slider. + Default float64 + // Tooltip is an optional text displayed when hovering over the element's info icon. The icon only + // appears when a tooltip is set. + Tooltip string + + value float64 +} + +// NewSlider creates and returns a new Slider using the values passed. +func NewSlider(text string, min, max, stepSize, defaultValue float64) Slider { + return Slider{Text: text, Min: min, Max: max, StepSize: stepSize, Default: defaultValue} +} + +// WithTooltip returns a copy of the Slider with the tooltip set. +func (s Slider) WithTooltip(tooltip string) Slider { + s.Tooltip = tooltip + return s +} + +// MarshalJSON ... +func (s Slider) MarshalJSON() ([]byte, error) { + m := map[string]any{ + "type": "slider", + "text": s.Text, + "min": s.Min, + "max": s.Max, + "step": s.StepSize, + "default": s.Default, + } + if s.Tooltip != "" { + m["tooltip"] = s.Tooltip + } + return json.Marshal(m) +} + +// Value returns the value filled out by the user. +func (s Slider) Value() float64 { + return s.value +} + +// Dropdown represents a dropdown which, when clicked, opens a window with the options set in the Options +// field. Submitters may select one of the options. +type Dropdown struct { + // Text is the text displayed over the dropdown element. The text may contain Minecraft formatting codes. + Text string + // Options holds a list of options that a Submitter may select. The order of these options is retained + // when shown to the submitter of the form. + Options []string + // DefaultIndex is the index in the Options slice that is used as default. When sent to a Submitter, the + // value at this index in the Options slice will be selected. + DefaultIndex int + // Tooltip is an optional text displayed when hovering over the element's info icon. The icon only + // appears when a tooltip is set. + Tooltip string + + value int +} + +// NewDropdown creates and returns new Dropdown using the values passed. +func NewDropdown(text string, options []string, defaultIndex int) Dropdown { + return Dropdown{Text: text, Options: options, DefaultIndex: defaultIndex} +} + +// WithTooltip returns a copy of the Dropdown with the tooltip set. +func (d Dropdown) WithTooltip(tooltip string) Dropdown { + d.Tooltip = tooltip + return d +} + +// MarshalJSON ... +func (d Dropdown) MarshalJSON() ([]byte, error) { + m := map[string]any{ + "type": "dropdown", + "text": d.Text, + "default": d.DefaultIndex, + "options": d.Options, + } + if d.Tooltip != "" { + m["tooltip"] = d.Tooltip + } + return json.Marshal(m) +} + +// Value returns the value that the Submitter submitted. The value is an index pointing to the selected option +// in the Options slice. +func (d Dropdown) Value() int { + return d.value +} + +// StepSlider represents a slider that has a number of options that may be selected. It is essentially a +// combination of a Dropdown and a Slider, looking like a slider but having properties like a dropdown. +type StepSlider Dropdown + +// NewStepSlider creates and returns new StepSlider using the values passed. +func NewStepSlider(text string, options []string, defaultIndex int) StepSlider { + return StepSlider{Text: text, Options: options, DefaultIndex: defaultIndex} +} + +// WithTooltip returns a copy of the StepSlider with the tooltip set. +func (s StepSlider) WithTooltip(tooltip string) StepSlider { + s.Tooltip = tooltip + return s +} + +// MarshalJSON ... +func (s StepSlider) MarshalJSON() ([]byte, error) { + m := map[string]any{ + "type": "step_slider", + "text": s.Text, + "default": s.DefaultIndex, + "steps": s.Options, + } + if s.Tooltip != "" { + m["tooltip"] = s.Tooltip + } + return json.Marshal(m) +} + +// Value returns the value that the Submitter submitted. The value is an index pointing to the selected option +// in the Options slice. +func (s StepSlider) Value() int { + return s.value +} + +// Button represents a button added to a Menu or Modal form. The button has text on it and an optional image, +// which may be either retrieved from a website or the local assets of the game. +type Button struct { + // Text holds the text displayed on the button. It may use Minecraft formatting codes and may have + // newlines. + Text string + // Image holds a path to an image for the button. The Image may either be a URL pointing to an image, + // such as 'https://someimagewebsite.com/someimage.png', or a path pointing to a local asset, such as + // 'textures/blocks/grass_carried'. + Image string +} + +// NewButton creates and returns a new Button using the text and image passed. +func NewButton(text, image string) Button { + return Button{Text: text, Image: image} +} + +// MarshalJSON ... +func (b Button) MarshalJSON() ([]byte, error) { + m := map[string]any{ + "type": "button", + "text": b.Text, + } + if b.Image != "" { + buttonType := "path" + if strings.HasPrefix(b.Image, "http:") || strings.HasPrefix(b.Image, "https:") { + buttonType = "url" + } + m["image"] = map[string]any{"type": buttonType, "data": b.Image} + } + return json.Marshal(m) +} + +func (Divider) elem() {} +func (Header) elem() {} +func (Label) elem() {} +func (Input) elem() {} +func (Toggle) elem() {} +func (Slider) elem() {} +func (Dropdown) elem() {} +func (StepSlider) elem() {} + +func (Divider) menuElem() {} +func (Header) menuElem() {} +func (Label) menuElem() {} +func (Button) menuElem() {} diff --git a/server/player/form/form.go b/server/player/form/form.go new file mode 100644 index 0000000..d6a556f --- /dev/null +++ b/server/player/form/form.go @@ -0,0 +1,217 @@ +package form + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "strings" + "unicode/utf8" + + "github.com/df-mc/dragonfly/server/world" +) + +// Form represents a form that may be sent to a Submitter. The three types of forms, custom forms, menu forms +// and modal forms implement this interface. +type Form interface { + json.Marshaler + SubmitJSON(b []byte, submitter Submitter, tx *world.Tx) error +} + +// Custom represents a form that may be sent to a player and has fields that should be filled out by the +// player that the form is sent to. +type Custom struct { + title string + submittable Submittable +} + +// MarshalJSON ... +func (f Custom) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]any{ + "type": "custom_form", + "title": f.title, + "content": f.Elements(), + }) +} + +// New creates a new (custom) form with the title passed and returns it. The title is formatted according to +// the rules of fmt.Sprintln. +// The submittable passed is used to create the structure of the form. The values of the Submittable's form +// fields are used to set text, defaults and placeholders. If the Submittable passed is not a struct, New +// panics. New also panics if one of the exported field types of the Submittable is not one that implements +// the Element interface. +func New(submittable Submittable, title ...any) Custom { + t := reflect.TypeOf(submittable) + if t.Kind() != reflect.Struct { + panic("submittable must be struct") + } + f := Custom{title: format(title), submittable: submittable} + f.verify() + return f +} + +// Title returns the formatted title passed when the form was created using New(). +func (f Custom) Title() string { + return f.title +} + +// Elements returns a list of all elements as set in the Submittable passed to form.New(). +func (f Custom) Elements() []Element { + v := reflect.New(reflect.TypeOf(f.submittable)).Elem() + v.Set(reflect.ValueOf(f.submittable)) + n := v.NumField() + + elements := make([]Element, 0, n) + for i := 0; i < n; i++ { + field := v.Field(i) + if !field.CanSet() { + continue + } + // Each exported field is guaranteed to implement the Element interface. + elements = append(elements, field.Interface().(Element)) + } + return elements +} + +// SubmitJSON submits a JSON data slice to the form. The form will check all values in the JSON array passed, +// making sure their values are valid for the form's elements. +// If the values are valid and can be parsed properly, the Submittable.Submit() method of the form's Submittable is +// called and the fields of the Submittable will be filled out. +func (f Custom) SubmitJSON(b []byte, submitter Submitter, tx *world.Tx) error { + if b == nil { + if closer, ok := f.submittable.(Closer); ok { + closer.Close(submitter, tx) + } + return nil + } + + dec := json.NewDecoder(bytes.NewBuffer(b)) + dec.UseNumber() + + var data []any + if err := dec.Decode(&data); err != nil { + return fmt.Errorf("error decoding JSON data to slice: %w", err) + } + + v := reflect.New(reflect.TypeOf(f.submittable)).Elem() + v.Set(reflect.ValueOf(f.submittable)) + + for i := 0; i < v.NumField(); i++ { + fieldV := v.Field(i) + if !fieldV.CanSet() { + continue + } + e := fieldV.Interface().(Element) + if len(data) == 0 { + return fmt.Errorf("form JSON data array does not have enough values") + } + if elementReadonly(e) { + data = data[1:] + continue + } + elem, err := f.parseValue(e, data[0]) + if err != nil { + return fmt.Errorf("error parsing form response value: %w", err) + } + fieldV.Set(elem) + data = data[1:] + } + + v.Interface().(Submittable).Submit(submitter, tx) + + return nil +} + +// elementReadonly returns true if the element is read only. +func elementReadonly(e Element) bool { + switch e.(type) { + case Divider, Header, Label: + return true + default: + return false + } +} + +// parseValue parses a value into the Element passed and returns it as a reflection Value. If the value is not +// valid for the element, an error is returned. +func (f Custom) parseValue(elem Element, s any) (reflect.Value, error) { + var ok bool + var value reflect.Value + + switch element := elem.(type) { + case Input: + element.value, ok = s.(string) + if !ok { + return value, fmt.Errorf("value %v is not allowed for input element", s) + } + if !utf8.ValidString(element.value) { + return value, fmt.Errorf("value %v is not valid UTF8", s) + } + value = reflect.ValueOf(element) + case Toggle: + element.value, ok = s.(bool) + if !ok { + return value, fmt.Errorf("value %v is not allowed for toggle element", s) + } + value = reflect.ValueOf(element) + case Slider: + v, ok := s.(json.Number) + f, err := v.Float64() + if !ok || err != nil { + return value, fmt.Errorf("value %v is not allowed for slider element", s) + } + if f > element.Max || f < element.Min { + return value, fmt.Errorf("slider value %v is out of range %v-%v", f, element.Min, element.Max) + } + element.value = f + value = reflect.ValueOf(element) + case Dropdown: + v, ok := s.(json.Number) + f, err := v.Int64() + if !ok || err != nil { + return value, fmt.Errorf("value %v is not allowed for dropdown element", s) + } + if f < 0 || int(f) >= len(element.Options) { + return value, fmt.Errorf("dropdown value %v is out of range %v-%v", f, 0, len(element.Options)-1) + } + element.value = int(f) + value = reflect.ValueOf(element) + case StepSlider: + v, ok := s.(json.Number) + f, err := v.Int64() + if !ok || err != nil { + return value, fmt.Errorf("value %v is not allowed for dropdown element", s) + } + if f < 0 || int(f) >= len(element.Options) { + return value, fmt.Errorf("dropdown value %v is out of range %v-%v", f, 0, len(element.Options)-1) + } + element.value = int(f) + value = reflect.ValueOf(element) + } + return value, nil +} + +// verify verifies if the form is valid, checking if the fields all implement the Element interface. It panics +// if the form is not valid. +func (f Custom) verify() { + el := reflect.TypeOf((*Element)(nil)).Elem() + + v := reflect.New(reflect.TypeOf(f.submittable)).Elem() + v.Set(reflect.ValueOf(f.submittable)) + + t := reflect.TypeOf(f.submittable) + for i := 0; i < v.NumField(); i++ { + if !v.Field(i).CanSet() { + continue + } + if !t.Field(i).Type.Implements(el) { + panic("all exported fields must implement form.Element interface") + } + } +} + +// format is a utility function to format a list of values to have spaces between them, but no newline at the +// end. +func format(a []any) string { + return strings.TrimSuffix(strings.TrimSuffix(fmt.Sprintln(a...), "\n"), "\n") +} diff --git a/server/player/form/menu.go b/server/player/form/menu.go new file mode 100644 index 0000000..46a690d --- /dev/null +++ b/server/player/form/menu.go @@ -0,0 +1,175 @@ +package form + +import ( + "encoding/json" + "fmt" + "reflect" + + "github.com/df-mc/dragonfly/server/world" +) + +// Menu represents a menu form. These menus are made up of a title and a body, with a number of elements which +// come below the body. These elements can include buttons, dividers, headers, and labels. +type Menu struct { + title, body string + submittable MenuSubmittable + elements []MenuElement +} + +// NewMenu creates a new Menu form using the MenuSubmittable passed to handle the output of the form. The +// title passed is formatted following the rules of fmt.Sprintln. +func NewMenu(submittable MenuSubmittable, title ...any) Menu { + t := reflect.TypeOf(submittable) + if t.Kind() != reflect.Struct { + panic("submittable must be struct") + } + m := Menu{title: format(title), submittable: submittable} + m.verify() + return m +} + +// MarshalJSON ... +func (m Menu) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]any{ + "type": "form", + "title": m.title, + "content": m.body, + "elements": m.Elements(), + }) +} + +// WithBody creates a copy of the Menu form and changes its body to the body passed, after which the new Menu +// form is returned. The text is formatted following the rules of fmt.Sprintln. +func (m Menu) WithBody(body ...any) Menu { + m.body = format(body) + return m +} + +// AddButton appends a button to the menu's element list and returns the updated Menu. +func (m Menu) AddButton(button Button) Menu { + m.elements = append(m.elements, button) + return m +} + +// AddDivider appends a divider to the menu's element list and returns the updated Menu. +func (m Menu) AddDivider(divider Divider) Menu { + m.elements = append(m.elements, divider) + return m +} + +// AddHeader appends a header to the menu's element list and returns the updated Menu. +func (m Menu) AddHeader(header Header) Menu { + m.elements = append(m.elements, header) + return m +} + +// AddLabel appends a label to the menu's element list and returns the updated Menu. +func (m Menu) AddLabel(label Label) Menu { + m.elements = append(m.elements, label) + return m +} + +// WithButtons creates a copy of the Menu form and appends the buttons passed to the existing elements, after +// which the new Menu form is returned. +func (m Menu) WithButtons(buttons ...Button) Menu { + for _, b := range buttons { + m.elements = append(m.elements, b) + } + return m +} + +// WithElements creates a copy of the Menu form and appends the elements passed to the existing elements, after +// which the new Menu form is returned. This allows adding any MenuElement type. +func (m Menu) WithElements(elements ...MenuElement) Menu { + m.elements = append(m.elements, elements...) + return m +} + +// Title returns the formatted title passed to the menu upon construction using NewMenu(). +func (m Menu) Title() string { + return m.title +} + +// Body returns the formatted text in the body passed to the menu using WithBody(). +func (m Menu) Body() string { + return m.body +} + +// Buttons returns a list of all buttons of the MenuSubmittable. It collects buttons from the MenuSubmittable +// fields and any buttons added via WithButtons(), AddButton(). +func (m Menu) Buttons() []Button { + v := reflect.New(reflect.TypeOf(m.submittable)).Elem() + v.Set(reflect.ValueOf(m.submittable)) + + buttons := make([]Button, 0) + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + if !field.CanSet() { + continue + } + if b, ok := field.Interface().(Button); ok { + buttons = append(buttons, b) + } + } + for _, elem := range m.elements { + if b, ok := elem.(Button); ok { + buttons = append(buttons, b) + } + } + return buttons +} + +// Elements returns all elements of this menu form. It collects elements from the MenuSubmittable +// fields and any elements added via WithElements(). +func (m Menu) Elements() []MenuElement { + v := reflect.New(reflect.TypeOf(m.submittable)).Elem() + v.Set(reflect.ValueOf(m.submittable)) + + elements := make([]MenuElement, 0, v.NumField()+len(m.elements)) + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + if !field.CanSet() { + continue + } + elements = append(elements, field.Interface().(MenuElement)) + } + elements = append(elements, m.elements...) + return elements +} + +// SubmitJSON submits a JSON value to the menu, containing the index of the button clicked. +func (m Menu) SubmitJSON(b []byte, submitter Submitter, tx *world.Tx) error { + if b == nil { + if closer, ok := m.submittable.(Closer); ok { + closer.Close(submitter, tx) + } + return nil + } + + var index uint + err := json.Unmarshal(b, &index) + if err != nil { + return fmt.Errorf("cannot parse button index as int: %w", err) + } + buttons := m.Buttons() + if index >= uint(len(buttons)) { + return fmt.Errorf("button index points to inexistent button: %v (only %v buttons present)", index, len(buttons)) + } + m.submittable.Submit(submitter, buttons[index], tx) + return nil +} + +// verify verifies if the form is valid, checking all exported fields implement MenuElement. +// It panics if the form is not valid. +func (m Menu) verify() { + v := reflect.New(reflect.TypeOf(m.submittable)).Elem() + v.Set(reflect.ValueOf(m.submittable)) + for i := 0; i < v.NumField(); i++ { + if !v.Field(i).CanSet() { + continue + } + if _, ok := v.Field(i).Interface().(MenuElement); !ok { + panic("all exported fields must implement form.MenuElement") + } + } +} diff --git a/server/player/form/modal.go b/server/player/form/modal.go new file mode 100644 index 0000000..e6a7431 --- /dev/null +++ b/server/player/form/modal.go @@ -0,0 +1,129 @@ +package form + +import ( + "encoding/json" + "fmt" + "github.com/df-mc/dragonfly/server/world" + "reflect" +) + +// Modal represents a modal form. These forms have a body with text and two buttons at the end, typically one +// for Yes and one for No. These buttons may have custom text, but can, unlike with a Menu form, not have +// images next to them. +type Modal struct { + title, body string + submittable ModalSubmittable +} + +// NewModal creates a new Modal form using the ModalSubmittable passed to handle the output of the form. The +// title passed is formatted following the fmt.Sprintln rules. +// Default 'yes' and 'no' buttons may be passed by setting the two exported struct fields of the submittable +// to YesButton() and NoButton() respectively. +func NewModal(submittable ModalSubmittable, title ...any) Modal { + t := reflect.TypeOf(submittable) + if t.Kind() != reflect.Struct { + panic("submittable must be struct") + } + m := Modal{title: format(title), submittable: submittable} + m.verify() + return m +} + +// YesButton returns a Button which may be used as a default 'yes' button for a modal form. +func YesButton() Button { + return Button{Text: "gui.yes"} +} + +// NoButton returns a Button which may be used as a default 'no' button for a modal form. +func NoButton() Button { + return Button{Text: "gui.no"} +} + +// MarshalJSON ... +func (m Modal) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]any{ + "type": "modal", + "title": m.title, + "content": m.body, + "button1": m.Buttons()[0].Text, + "button2": m.Buttons()[1].Text, + }) +} + +// WithBody creates a copy of the Modal form and changes its body to the body passed, after which the new Modal +// form is returned. The text is formatted following the rules of fmt.Sprintln. +func (m Modal) WithBody(body ...any) Modal { + m.body = format(body) + return m +} + +// Title returns the formatted title passed to the menu upon construction using NewModal(). +func (m Modal) Title() string { + return m.title +} + +// Body returns the formatted text in the body passed to the menu using WithBody(). +func (m Modal) Body() string { + return m.body +} + +// SubmitJSON submits a JSON byte slice to the modal form. This byte slice contains a JSON encoded bool in it, +// which is used to determine which button was clicked. +func (m Modal) SubmitJSON(b []byte, submitter Submitter, tx *world.Tx) error { + if b == nil { + if closer, ok := m.submittable.(Closer); ok { + closer.Close(submitter, tx) + } + return nil + } + + var value bool + if err := json.Unmarshal(b, &value); err != nil { + return fmt.Errorf("error parsing JSON as bool: %w", err) + } + if value { + m.submittable.Submit(submitter, m.Buttons()[0], tx) + return nil + } + m.submittable.Submit(submitter, m.Buttons()[1], tx) + return nil +} + +// Buttons returns a list of all buttons of the Modal form, which will always be a total of two buttons. +func (m Modal) Buttons() []Button { + v := reflect.New(reflect.TypeOf(m.submittable)).Elem() + v.Set(reflect.ValueOf(m.submittable)) + + buttons := make([]Button, 0, v.NumField()) + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + if !field.CanSet() { + continue + } + // Each exported field is guaranteed to be of type Button. + buttons = append(buttons, field.Interface().(Button)) + } + return buttons +} + +// verify verifies that the Modal form is valid. It checks if exactly two exported fields are present and +// ensures that both have the Button type. +func (m Modal) verify() { + var count int + + v := reflect.New(reflect.TypeOf(m.submittable)).Elem() + v.Set(reflect.ValueOf(m.submittable)) + + for i := 0; i < v.NumField(); i++ { + if !v.Field(i).CanSet() { + continue + } + if _, ok := v.Field(i).Interface().(Button); !ok { + panic("both exported fields must be of the type form.Button") + } + count++ + } + if count != 2 { + panic("modal form must have exactly two exported fields of the type form.Button") + } +} diff --git a/server/player/form/submit.go b/server/player/form/submit.go new file mode 100644 index 0000000..a31909c --- /dev/null +++ b/server/player/form/submit.go @@ -0,0 +1,46 @@ +package form + +import "github.com/df-mc/dragonfly/server/world" + +// Submittable is a structure which may be submitted by sending it as a form using form.New(). When filled out +// and submitted, the struct will have its Submit method called and its fields will have the values that the +// Submitter passed filled out. +// The fields of a Submittable struct must be either unexported or have a type of one of those that implement +// the form.Element interface. +type Submittable interface { + // Submit is called when the Submitter submits the form sent to it. Once this method is called, all fields + // in the struct will have their values filled out as filled out by the Submitter. + Submit(submitter Submitter, tx *world.Tx) +} + +// MenuSubmittable is a structure which may be submitted by sending it as a form using form.NewMenu(), much +// like a Submittable. The struct will have its Submit method called with the button pressed. +// A struct that implements the MenuSubmittable interface must only have exported fields with the type +// form.Button. +type MenuSubmittable interface { + // Submit is called when the Submitter submits the menu form sent to it. The method is called with the + // button that was pressed. It may be compared with buttons in the MenuSubmittable struct to check which + // button was pressed. + Submit(submitter Submitter, pressed Button, tx *world.Tx) +} + +// ModalSubmittable is a structure which may be submitted by sending it as a form using form.NewModal(), much +// like a Submittable and a MenuSubmittable. The struct will have its Submit method called with the button +// pressed. +// A struct that implements the ModalSubmittable interface must have exactly two exported fields with the type +// form.Button, which may be used to specify the text of the Modal form's buttons. Unlike with a Menu form, +// buttons on a Modal form will not have images. +type ModalSubmittable MenuSubmittable + +// Closer represents a form which has special logic when being closed by a Submitter. +type Closer interface { + // Close is called when the Submitter closes a form. + Close(submitter Submitter, tx *world.Tx) +} + +// Submitter is an entity that is able to submit a form sent to it. It is able to fill out fields in the form +// which will then be present when handled. The Submitter is also able to close the form. +type Submitter interface { + SendForm(form Form) + CloseForm() +} diff --git a/server/player/handler.go b/server/player/handler.go new file mode 100644 index 0000000..ada7a65 --- /dev/null +++ b/server/player/handler.go @@ -0,0 +1,202 @@ +package player + +import ( + "net" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/cmd" + "github.com/df-mc/dragonfly/server/event" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/player/skin" + "github.com/df-mc/dragonfly/server/session" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +type Context = event.Context[*Player] + +// Handler handles events that are called by a player. Implementations of Handler may be used to listen to +// specific events such as when a player chats or moves. +type Handler interface { + // HandleMove handles the movement of a player. ctx.Cancel() may be called to cancel the movement event. + // The new position, yaw and pitch are passed. + HandleMove(ctx *Context, newPos mgl64.Vec3, newRot cube.Rotation) + // HandleJump handles the player jumping. + HandleJump(p *Player) + // HandleTeleport handles the teleportation of a player. ctx.Cancel() may be called to cancel it. + HandleTeleport(ctx *Context, pos mgl64.Vec3) + // HandleChangeWorld handles when the player is added to a new world. before may be nil. + HandleChangeWorld(p *Player, before, after *world.World) + // HandleToggleSprint handles when the player starts or stops sprinting. + // After is true if the player is sprinting after toggling (changing their sprinting state). + HandleToggleSprint(ctx *Context, after bool) + // HandleToggleSneak handles when the player starts or stops sneaking. + // After is true if the player is sneaking after toggling (changing their sneaking state). + HandleToggleSneak(ctx *Context, after bool) + // HandleChat handles a message sent in the chat by a player. ctx.Cancel() may be called to cancel the + // message being sent in chat. + // The message may be changed by assigning to *message. + HandleChat(ctx *Context, message *string) + // HandleFoodLoss handles the food bar of a player depleting naturally, for example because the player was + // sprinting and jumping. ctx.Cancel() may be called to cancel the food points being lost. + HandleFoodLoss(ctx *Context, from int, to *int) + // HandleHeal handles the player being healed by a healing source. ctx.Cancel() may be called to cancel + // the healing. + // The health added may be changed by assigning to *health. + HandleHeal(ctx *Context, health *float64, src world.HealingSource) + // HandleHurt handles the player being hurt by any damage source. ctx.Cancel() may be called to cancel the + // damage being dealt to the player. + // The damage dealt to the player may be changed by assigning to *damage. + // *damage is the final damage dealt to the player. Immune is set to true + // if the player was hurt during an immunity frame with higher damage than + // the original cause of the immunity frame. In this case, the damage is + // reduced but the player is still knocked back. + HandleHurt(ctx *Context, damage *float64, immune bool, attackImmunity *time.Duration, src world.DamageSource) + // HandleDeath handles the player dying to a particular damage cause. + HandleDeath(p *Player, src world.DamageSource, keepInv *bool) + // HandleRespawn handles the respawning of the player in the world. The spawn position passed may be + // changed by assigning to *pos. The world.World in which the Player is respawned may be modifying by assigning to + // *w. This world may be the world the Player died in, but it might also point to a different world (the overworld) + // if the Player died in the nether or end. + HandleRespawn(p *Player, pos *mgl64.Vec3, w **world.World) + // HandleSkinChange handles the player changing their skin. ctx.Cancel() may be called to cancel the skin + // change. + HandleSkinChange(ctx *Context, skin *skin.Skin) + // HandleFireExtinguish handles the player extinguishing a fire at a specific position. ctx.Cancel() may + // be called to cancel the fire being extinguished. + // cube.Pos can be used to see where was the fire extinguished, may be used to cancel this on specific positions. + HandleFireExtinguish(ctx *Context, pos cube.Pos) + // HandleStartBreak handles the player starting to break a block at the position passed. ctx.Cancel() may + // be called to stop the player from breaking the block completely. + HandleStartBreak(ctx *Context, pos cube.Pos) + // HandleBlockBreak handles a block that is being broken by a player. ctx.Cancel() may be called to cancel + // the block being broken. A pointer to a slice of the block's drops is passed, and may be altered + // to change what items will actually be dropped. + HandleBlockBreak(ctx *Context, pos cube.Pos, drops *[]item.Stack, xp *int) + // HandleBlockPlace handles the player placing a specific block at a position in its world. ctx.Cancel() + // may be called to cancel the block being placed. + HandleBlockPlace(ctx *Context, pos cube.Pos, b world.Block) + // HandleBlockPick handles the player picking a specific block at a position in its world. ctx.Cancel() + // may be called to cancel the block being picked. + HandleBlockPick(ctx *Context, pos cube.Pos, b world.Block) + // HandleItemUse handles the player using an item in the air. It is called for each item, although most + // will not actually do anything. Items such as snowballs may be thrown if HandleItemUse does not cancel + // the context using ctx.Cancel(). It is not called if the player is holding no item. + HandleItemUse(ctx *Context) + // HandleItemUseOnBlock handles the player using the item held in its main hand on a block at the block + // position passed. The face of the block clicked is also passed, along with the relative click position. + // The click position has X, Y and Z values which are all in the range 0.0-1.0. It is also called if the + // player is holding no item. + HandleItemUseOnBlock(ctx *Context, pos cube.Pos, face cube.Face, clickPos mgl64.Vec3) + // HandleItemUseOnEntity handles the player using the item held in its main hand on an entity passed to + // the method. + // HandleItemUseOnEntity is always called when a player uses an item on an entity, regardless of whether + // the item actually does anything when used on an entity. It is also called if the player is holding no + // item. + HandleItemUseOnEntity(ctx *Context, e world.Entity) + // HandleItemRelease handles the player releasing an item after using it for + // a particular duration. These include items such as bows. + HandleItemRelease(ctx *Context, item item.Stack, dur time.Duration) + // HandleItemConsume handles the player consuming an item. This is called whenever a consumable such as + // food is consumed. + HandleItemConsume(ctx *Context, item item.Stack) + // HandleAttackEntity handles the player attacking an entity using the item held in its hand. ctx.Cancel() + // may be called to cancel the attack, which will cancel damage dealt to the target and will stop the + // entity from being knocked back. + // The entity attacked may not be alive (implements entity.Living), in which case no damage will be dealt + // and the target won't be knocked back. + // The entity attacked may also be immune when this method is called, in which case no damage and knock- + // back will be dealt. + // The knock back force and height is also provided which can be modified. + // The attack can be a critical attack, which would increase damage by a factor of 1.5 and + // spawn critical hit particles around the target entity. These particles will not be displayed + // if no damage is dealt. + HandleAttackEntity(ctx *Context, e world.Entity, force, height *float64, critical *bool) + // HandleExperienceGain handles the player gaining experience. ctx.Cancel() may be called to cancel + // the gain. + // The amount is also provided which can be modified. + HandleExperienceGain(ctx *Context, amount *int) + // HandlePunchAir handles the player punching air. + HandlePunchAir(ctx *Context) + // HandleSignEdit handles the player editing a sign. It is called for every keystroke while editing a sign and + // has both the old text passed and the text after the edit. This typically only has a change of one character. + HandleSignEdit(ctx *Context, pos cube.Pos, frontSide bool, oldText, newText string) + // HandleSleep handles the player beginning the sleep action. ctx.Cancel() may be called to cancel the action. + HandleSleep(ctx *Context, sendReminder *bool) + // HandleLecternPageTurn handles the player turning a page in a lectern. ctx.Cancel() may be called to cancel the + // page turn. The page number may be changed by assigning to *page. + HandleLecternPageTurn(ctx *Context, pos cube.Pos, oldPage int, newPage *int) + // HandleItemDamage handles the event wherein the item either held by the player or as armour takes + // damage through usage. + // The type of the item may be checked to determine whether it was armour or a tool used. The damage to + // the item is passed. + HandleItemDamage(ctx *Context, i item.Stack, damage *int) + // HandleItemPickup handles the player picking up an item from the ground. The item stack laying on the + // ground is passed. ctx.Cancel() may be called to prevent the player from picking up the item. + HandleItemPickup(ctx *Context, i *item.Stack) + // HandleHeldSlotChange handles the player changing the slot they are currently holding. + HandleHeldSlotChange(ctx *Context, from, to int) + // HandleItemDrop handles the player dropping an item on the ground. + // ctx.Cancel() may be called to prevent the player from dropping the item.Stack passed on the ground. + HandleItemDrop(ctx *Context, s item.Stack) + // HandleTransfer handles a player being transferred to another server. ctx.Cancel() may be called to + // cancel the transfer. + HandleTransfer(ctx *Context, addr *net.UDPAddr) + // HandleCommandExecution handles the command execution of a player, who wrote a command in the chat. + // ctx.Cancel() may be called to cancel the command execution. + HandleCommandExecution(ctx *Context, command cmd.Command, args []string) + // HandleQuit handles the closing of a player. It is always called when the player is disconnected, + // regardless of the reason. + HandleQuit(p *Player) + // HandleDiagnostics handles the latest diagnostics data that the player has sent to the server. This is + // not sent by every client however, only those with the "Creator > Enable Client Diagnostics" setting + // enabled. + HandleDiagnostics(p *Player, d session.Diagnostics) +} + +// NopHandler implements the Handler interface but does not execute any code when an event is called. The +// default Handler of players is set to NopHandler. +// Users may embed NopHandler to avoid having to implement each method. +type NopHandler struct{} + +// Compile time check to make sure NopHandler implements Handler. +var _ Handler = NopHandler{} + +func (NopHandler) HandleItemDrop(*Context, item.Stack) {} +func (NopHandler) HandleHeldSlotChange(*Context, int, int) {} +func (NopHandler) HandleMove(*Context, mgl64.Vec3, cube.Rotation) {} +func (NopHandler) HandleJump(*Player) {} +func (NopHandler) HandleTeleport(*Context, mgl64.Vec3) {} +func (NopHandler) HandleChangeWorld(*Player, *world.World, *world.World) {} +func (NopHandler) HandleToggleSprint(*Context, bool) {} +func (NopHandler) HandleToggleSneak(*Context, bool) {} +func (NopHandler) HandleCommandExecution(*Context, cmd.Command, []string) {} +func (NopHandler) HandleTransfer(*Context, *net.UDPAddr) {} +func (NopHandler) HandleChat(*Context, *string) {} +func (NopHandler) HandleSkinChange(*Context, *skin.Skin) {} +func (NopHandler) HandleFireExtinguish(*Context, cube.Pos) {} +func (NopHandler) HandleStartBreak(*Context, cube.Pos) {} +func (NopHandler) HandleBlockBreak(*Context, cube.Pos, *[]item.Stack, *int) {} +func (NopHandler) HandleBlockPlace(*Context, cube.Pos, world.Block) {} +func (NopHandler) HandleBlockPick(*Context, cube.Pos, world.Block) {} +func (NopHandler) HandleSignEdit(*Context, cube.Pos, bool, string, string) {} +func (NopHandler) HandleSleep(*Context, *bool) {} +func (NopHandler) HandleLecternPageTurn(*Context, cube.Pos, int, *int) {} +func (NopHandler) HandleItemPickup(*Context, *item.Stack) {} +func (NopHandler) HandleItemUse(*Context) {} +func (NopHandler) HandleItemUseOnBlock(*Context, cube.Pos, cube.Face, mgl64.Vec3) {} +func (NopHandler) HandleItemUseOnEntity(*Context, world.Entity) {} +func (NopHandler) HandleItemRelease(ctx *Context, item item.Stack, dur time.Duration) {} +func (NopHandler) HandleItemConsume(*Context, item.Stack) {} +func (NopHandler) HandleItemDamage(*Context, item.Stack, *int) {} +func (NopHandler) HandleAttackEntity(*Context, world.Entity, *float64, *float64, *bool) {} +func (NopHandler) HandleExperienceGain(*Context, *int) {} +func (NopHandler) HandlePunchAir(*Context) {} +func (NopHandler) HandleHurt(*Context, *float64, bool, *time.Duration, world.DamageSource) {} +func (NopHandler) HandleHeal(*Context, *float64, world.HealingSource) {} +func (NopHandler) HandleFoodLoss(*Context, int, *int) {} +func (NopHandler) HandleDeath(*Player, world.DamageSource, *bool) {} +func (NopHandler) HandleRespawn(*Player, *mgl64.Vec3, **world.World) {} +func (NopHandler) HandleQuit(*Player) {} +func (NopHandler) HandleDiagnostics(*Player, session.Diagnostics) {} diff --git a/server/player/hud/element.go b/server/player/hud/element.go new file mode 100644 index 0000000..44d50f8 --- /dev/null +++ b/server/player/hud/element.go @@ -0,0 +1,101 @@ +package hud + +// Element represents a HUD element in the game that can either be hidden or shown. +type Element struct { + element +} + +type element uint8 + +// PaperDoll is the element that shows the player's paper doll, which is a visual representation of the +// player's character model and equipment, as well as any currently played animations. It is located in the +// top left corner of the screen. +func PaperDoll() Element { + return Element{0} +} + +// Armour is the element that shows the player's armour level, sitting either above the hotbar or at the top +// of the screen on in non-classic views. +func Armour() Element { + return Element{1} +} + +// ToolTips is the element that shows useful hints and tips to the player, such as how to use items or +// how to perform certain actions in the game. These tips are displayed at the top right of the screen. +func ToolTips() Element { + return Element{2} +} + +// TouchControls is the element that shows the touch controls on the screen, which is used for touch-based +// devices. +func TouchControls() Element { + return Element{3} +} + +// Crosshair is the element that shows the crosshair in the middle of the screen, which is used for aiming +// and targeting entities or blocks. +func Crosshair() Element { + return Element{4} +} + +// HotBar is the element that shows all the items in the player's hotbar, located at the bottom of the screen. +func HotBar() Element { + return Element{5} +} + +// Health is the element that shows the player's health bar, sitting either above the hotbar or at the top +// of the screen on in non-classic views. +func Health() Element { + return Element{6} +} + +// ProgressBar is the element that shows the player's experience bar. It is always located just above the +// hotbar. +func ProgressBar() Element { + return Element{7} +} + +// Hunger is the element that shows the player's hunger bar, which indicates how hungry the player is and +// how much food they need to consume to restore their hunger. It is located either above the hotbar or at the +// top of the screen on in non-classic views. +func Hunger() Element { + return Element{8} +} + +// AirBubbles is the element that shows the player's air bubbles, which indicate how much air the player has +// left when underwater. It is located either above the hotbar or at the top of the screen on in non-classic +// views. It is only visible when the player is underwater or they are regenerating air after being underwater. +func AirBubbles() Element { + return Element{9} +} + +// HorseHealth is the element that shows the health of the player's horse, which replaces the player's own +// health bar when riding a horse/other entity with health. +func HorseHealth() Element { + return Element{10} +} + +// StatusEffects is the element that shows the icons of the currently active status effects, located on the +// right side of the screen. +func StatusEffects() Element { + return Element{11} +} + +// ItemText is the element that shows the text of the item currently held in the player's hand, which is +// displayed just above the hotbar when switching to a new item. +func ItemText() Element { + return Element{12} +} + +// Uint8 returns the element type as a uint8. +func (s element) Uint8() uint8 { + return uint8(s) +} + +// All returns all the HUD elements that are available to be shown or hidden in the game. +func All() []Element { + return []Element{ + PaperDoll(), Armour(), ToolTips(), TouchControls(), Crosshair(), HotBar(), Health(), + ProgressBar(), Hunger(), AirBubbles(), HorseHealth(), StatusEffects(), ItemText(), + } +} diff --git a/server/player/hud/renderer.go b/server/player/hud/renderer.go new file mode 100644 index 0000000..b027a0a --- /dev/null +++ b/server/player/hud/renderer.go @@ -0,0 +1,11 @@ +package hud + +// Renderer represents an interface that can manage HUD elements for a player. +type Renderer interface { + // ShowHudElement shows a HUD element to the renderer if it is not already shown. + ShowHudElement(e Element) + // HideHudElement hides a HUD element from the renderer if it is not already hidden. + HideHudElement(e Element) + // HudElementHidden checks if a HUD element is currently hidden from the renderer. + HudElementHidden(e Element) bool +} diff --git a/server/player/hunger.go b/server/player/hunger.go new file mode 100644 index 0000000..b35716a --- /dev/null +++ b/server/player/hunger.go @@ -0,0 +1,139 @@ +package player + +import ( + "sync" +) + +// hungerManager handles the changes in hunger, exhaustion and saturation of a player. +type hungerManager struct { + mu sync.RWMutex + foodLevel int + saturationLevel float64 + exhaustionLevel float64 + foodTick int +} + +// newHungerManager returns a new hunger manager with the default values for food level, saturation level and +// exhaustion level. +func newHungerManager() *hungerManager { + return &hungerManager{foodLevel: 20, saturationLevel: 5, foodTick: 1} +} + +// Food returns the current food level of a player. The level returned is guaranteed to always be between 0 +// and 20. +func (m *hungerManager) Food() int { + m.mu.RLock() + defer m.mu.RUnlock() + return m.foodLevel +} + +// SetFood sets the food level of a player. The level passed must be in a range of 0-20. If the level passed +// is negative, the food level will be set to 0. If the level exceeds 20, the food level will be set to 20. +func (m *hungerManager) SetFood(level int) { + m.mu.Lock() + defer m.mu.Unlock() + + m.foodLevel = max(min(level, 20), 0) +} + +// AddFood adds a number of food points to the current food level of a player. +func (m *hungerManager) AddFood(points int) { + m.mu.Lock() + defer m.mu.Unlock() + + m.foodLevel = max(min(m.foodLevel+points, 20), 0) +} + +// Reset resets the hunger manager to its default values, identical to those set when creating a new manager +// using newHungerManager. +func (m *hungerManager) Reset() { + m.mu.Lock() + defer m.mu.Unlock() + + m.foodLevel = 20 + m.saturationLevel = 5 + m.exhaustionLevel = 0 + m.foodTick = 1 +} + +// ResetExhaustion resets the player's exhaustion level to 0. It prevents the +// player's food level from decreasing immediately after cancelling food loss. +func (m *hungerManager) resetExhaustion() { + m.mu.Lock() + defer m.mu.Unlock() + m.exhaustionLevel = 0 + m.saturationLevel = 0 + m.foodTick = 1 +} + +// exhaust exhausts the player by the amount of points passed. If the total exhaustion level exceeds 4, a +// saturation point, or food point, if saturation is 0, will be subtracted. +func (m *hungerManager) exhaust(points float64) { + m.mu.Lock() + defer m.mu.Unlock() + + m.exhaustionLevel += points + for m.exhaustionLevel >= 4 { + // Maximum exhaustion value is 4, so keep removing one saturation point until the exhaustion level + // is below 4. + m.exhaustionLevel -= 4 + m.desaturate() + } +} + +// saturate saturates the player's food and saturation by the amount of points passed. Note that the total +// saturation will never exceed the total food value. +func (m *hungerManager) saturate(food int, saturation float64) { + m.mu.Lock() + defer m.mu.Unlock() + + m.foodLevel = max(min(m.foodLevel+food, 20), 0) + m.saturationLevel = max(min(m.saturationLevel+saturation, float64(m.foodLevel)), 0) +} + +// desaturate removes one saturation point from the player. If the saturation level of the player is already +// 0, a point will be subtracted from the food level instead. If that level, too, is already 0, nothing will +// happen. +func (m *hungerManager) desaturate() { + if m.saturationLevel <= 0 && m.foodLevel != 0 { + m.foodLevel-- + } else if m.saturationLevel > 0 { + m.saturationLevel = max(m.saturationLevel-1, 0) + } +} + +// canQuicklyRegenerate checks if the player can quickly regenerate. The function returns true if Food() returns 20 +// and the player still has saturation left. +// The rate of regeneration is 1/0.5 seconds. +func (m *hungerManager) canQuicklyRegenerate() bool { + m.mu.RLock() + defer m.mu.RUnlock() + + return m.foodLevel == 20 && m.saturationLevel > 0 +} + +// canRegenerate checks if the player with the amount of food levels in the hunger manager can regenerate. +// The function returns true if Food() returns either 18-20. +// The rate of regeneration is 1/4 seconds. +func (m *hungerManager) canRegenerate() bool { + return m.Food() >= 18 +} + +// canSprint returns true if the food level of the player is 7 or higher. +func (m *hungerManager) canSprint() bool { + return m.Food() > 6 +} + +// starving checks if the player is currently considered to be starving. True is returned if Food() returns 0. +func (m *hungerManager) starving() bool { + return m.Food() == 0 +} + +// StarvationDamageSource is the world.DamageSource passed when a player is +// dealt damage from an empty food bar. +type StarvationDamageSource struct{} + +func (StarvationDamageSource) ReducedByArmour() bool { return false } +func (StarvationDamageSource) ReducedByResistance() bool { return false } +func (StarvationDamageSource) Fire() bool { return false } +func (StarvationDamageSource) IgnoreTotem() bool { return false } diff --git a/server/player/player.go b/server/player/player.go new file mode 100644 index 0000000..728494a --- /dev/null +++ b/server/player/player.go @@ -0,0 +1,3358 @@ +package player + +import ( + "fmt" + "math" + "math/rand/v2" + "net" + "slices" + "strings" + "sync" + "time" + + "github.com/df-mc/dragonfly/server/player/debug" + "github.com/df-mc/dragonfly/server/player/hud" + + "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/cmd" + "github.com/df-mc/dragonfly/server/entity" + "github.com/df-mc/dragonfly/server/entity/effect" + "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/item/inventory" + "github.com/df-mc/dragonfly/server/player/bossbar" + "github.com/df-mc/dragonfly/server/player/chat" + "github.com/df-mc/dragonfly/server/player/dialogue" + "github.com/df-mc/dragonfly/server/player/form" + "github.com/df-mc/dragonfly/server/player/scoreboard" + "github.com/df-mc/dragonfly/server/player/skin" + "github.com/df-mc/dragonfly/server/player/title" + "github.com/df-mc/dragonfly/server/session" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/particle" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" + "github.com/google/uuid" + "golang.org/x/text/language" +) + +type playerData struct { + xuid string + locale language.Tag + nameTag, scoreTag string + absorptionHealth float64 + scale float64 + + gameMode world.GameMode + skin skin.Skin + s *session.Session + h Handler + + inv, offHand, enderChest, ui *inventory.Inventory + armour *inventory.Armour + heldSlot *uint32 + + sneaking, sprinting, swimming, gliding, crawling, flying, + invisible, immobile, onGround, usingItem bool + + sleeping bool + sleepPos cube.Pos + + usingSince time.Time + + glideTicks int64 + fireTicks int64 + fallDistance float64 + + breathing bool + airSupplyTicks int + maxAirSupplyTicks int + + cooldowns map[string]time.Time + + speed float64 + flightSpeed float64 + verticalFlightSpeed float64 + + health *entity.HealthManager + experience *entity.ExperienceManager + effects *entity.EffectManager + + lastXPPickup *time.Time + + lastDamage float64 + immuneUntil time.Time + + deathPos *mgl64.Vec3 + deathDimension world.Dimension + + enchantSeed int64 + + mc *entity.MovementComputer + + collidedVertically, collidedHorizontally bool + + breaking bool + breakingPos cube.Pos + breakingFace cube.Face + lastBreakDuration time.Duration + + breakCounter uint32 + + hunger *hungerManager + + once sync.Once + + prevWorld *world.World +} + +// Player is an implementation of a player entity. It has methods that implement the behaviour that players +// need to play in the world. +type Player struct { + tx *world.Tx + handle *world.EntityHandle + data *world.EntityData + *playerData +} + +func (p *Player) H() *world.EntityHandle { + return p.handle +} + +func (p *Player) Tx() *world.Tx { + return p.tx +} + +// Name returns the username of the player. If the player is controlled by a client, it is the username of +// the client. (Typically the XBOX Live name) +func (p *Player) Name() string { + // TODO: This isn't correct, this will change if the nametag changes. + return p.data.Name +} + +// UUID returns the UUID of the player. This UUID will remain consistent with an XBOX Live account, and will, +// unlike the name of the player, never change. +// It is therefore recommended using the UUID over the name of the player. Additionally, it is recommended to +// use the UUID over the XUID because of its standard format. +func (p *Player) UUID() uuid.UUID { + return p.handle.UUID() +} + +// XUID returns the XBOX Live user ID of the player. It will remain consistent with the XBOX Live account, +// and will not change in the lifetime of an account. +// The XUID is a number that can be parsed as an int64. No more information on what it represents is +// available, and the UUID should be preferred. +// The XUID returned is empty if the Player is not connected to a network session or if the Player is not +// authenticated with XBOX Live. +func (p *Player) XUID() string { + return p.xuid +} + +// DeviceID returns the device ID of the player. If the Player is not connected to a network session, an empty string is +// returned. Otherwise, the device ID the network session sent in the ClientData is returned. +func (p *Player) DeviceID() string { + if p.session() == session.Nop { + return "" + } + return string(p.session().ClientData().DeviceID) +} + +// DeviceModel returns the device model of the player. If the Player is not connected to a network session, an empty +// string is returned. Otherwise, the device model the network session sent in the ClientData is returned. +func (p *Player) DeviceModel() string { + if p.session() == session.Nop { + return "" + } + return p.session().ClientData().DeviceModel +} + +// SelfSignedID returns the self-signed ID of the player. If the Player is not connected to a network session, an empty +// string is returned. Otherwise, the self-signed ID the network session sent in the ClientData is returned. +func (p *Player) SelfSignedID() string { + if p.session() == session.Nop { + return "" + } + return p.session().ClientData().SelfSignedID +} + +// Addr returns the net.Addr of the Player. If the Player is not connected to a network session, nil is returned. +func (p *Player) Addr() net.Addr { + if p.session() == session.Nop { + return nil + } + return p.session().Addr() +} + +// Skin returns the skin that a player is currently using. This skin will be visible to other players +// that the player is shown to. +// If the player was not connected to a network session, a default skin will be set. +func (p *Player) Skin() skin.Skin { + return p.skin +} + +// SetSkin changes the skin of the player. This skin will be visible to other players that the player +// is shown to. +func (p *Player) SetSkin(skin skin.Skin) { + ctx := event.C(p) + if p.Handler().HandleSkinChange(ctx, &skin); ctx.Cancelled() { + p.session().ViewSkin(p) + return + } + p.skin = skin + for _, v := range p.viewers() { + v.ViewSkin(p) + } +} + +// Locale returns the language and locale of the Player, as selected in the Player's settings. +func (p *Player) Locale() language.Tag { + return p.locale +} + +// Handle changes the current Handler of the player. As a result, events called by the player will call +// handlers of the Handler passed. +// Handle sets the player's Handler to NopHandler if nil is passed. +func (p *Player) Handle(h Handler) { + if h == nil { + h = NopHandler{} + } + p.h = h +} + +// Message sends a formatted message to the player. The message is formatted following the rules of +// fmt.Sprintln, however the newline at the end is not written. +func (p *Player) Message(a ...any) { + p.session().SendMessage(format(a)) +} + +// Messagef sends a formatted message using a specific format to the player. The message is formatted +// according to the fmt.Sprintf formatting rules. +func (p *Player) Messagef(f string, a ...any) { + p.session().SendMessage(fmt.Sprintf(f, a...)) +} + +// Messaget sends a translatable message to a player and parameterises it using +// the arguments passed. Messaget panics if an incorrect amount of arguments +// is passed. +func (p *Player) Messaget(t chat.Translation, a ...any) { + p.session().SendTranslation(t, p.locale, a) +} + +// SendPopup sends a formatted popup to the player. The popup is shown above the hotbar of the player and +// overwrites/is overwritten by the name of the item equipped. +// The popup is formatted following the rules of fmt.Sprintln without a newline at the end. +func (p *Player) SendPopup(a ...any) { + p.session().SendPopup(format(a)) +} + +// SendTip sends a tip to the player. The tip is shown in the middle of the screen of the player. +// The tip is formatted following the rules of fmt.Sprintln without a newline at the end. +func (p *Player) SendTip(a ...any) { + p.session().SendTip(format(a)) +} + +// SendJukeboxPopup sends a formatted jukebox popup to the player. This popup is shown above the hotbar of the player. +// The popup is close to the position of an action bar message and the text has no background. +func (p *Player) SendJukeboxPopup(a ...any) { + p.session().SendJukeboxPopup(format(a)) +} + +// SendToast sends a toast to the player. This toast is shown at the top of the screen, similar to achievements or pack +// loading. +func (p *Player) SendToast(title, message string) { + p.session().SendToast(title, message) +} + +// ResetFallDistance resets the player's fall distance. +func (p *Player) ResetFallDistance() { + p.fallDistance = 0 +} + +// FallDistance returns the player's fall distance. +func (p *Player) FallDistance() float64 { + return p.fallDistance +} + +// SendTitle sends a title to the player. The title may be configured to change the duration it is displayed +// and the text it shows. +// If non-empty, the subtitle is shown in a smaller font below the title. The same counts for the action text +// of the title, which is shown in a font similar to that of a tip/popup. +func (p *Player) SendTitle(t title.Title) { + p.session().SetTitleDurations(t.FadeInDuration(), t.Duration(), t.FadeOutDuration()) + if t.Text() != "" || t.Subtitle() != "" { + p.session().SendTitle(t.Text()) + if t.Subtitle() != "" { + p.session().SendSubtitle(t.Subtitle()) + } + } + if t.ActionText() != "" { + p.session().SendActionBarMessage(t.ActionText()) + } +} + +// SendScoreboard sends a scoreboard to the player. The scoreboard will be present indefinitely until removed +// by the caller. +// SendScoreboard may be called at any time to change the scoreboard of the player. +func (p *Player) SendScoreboard(scoreboard *scoreboard.Scoreboard) { + p.session().SendScoreboard(scoreboard) +} + +// RemoveScoreboard removes any scoreboard currently present on the screen of the player. Nothing happens if +// the player has no scoreboard currently active. +func (p *Player) RemoveScoreboard() { + p.session().RemoveScoreboard() +} + +// SendBossBar sends a boss bar to the player, so that it will be shown indefinitely at the top of the +// player's screen. +// The boss bar may be removed by calling Player.RemoveBossBar(). +func (p *Player) SendBossBar(bar bossbar.BossBar) { + p.session().SendBossBar(bar.Text(), bar.Colour().Uint8(), bar.HealthPercentage()) +} + +// RemoveBossBar removes any boss bar currently active on the player's screen. If no boss bar is currently +// present, nothing happens. +func (p *Player) RemoveBossBar() { + p.session().RemoveBossBar() +} + +// Chat writes a message in the global chat (chat.Global). The message is prefixed with the name of the +// player and is formatted following the rules of fmt.Sprintln. +func (p *Player) Chat(msg ...any) { + message := format(msg) + ctx := event.C(p) + if p.Handler().HandleChat(ctx, &message); ctx.Cancelled() { + return + } + _, _ = fmt.Fprintf(chat.Global, "<%v> %v\n", p.Name(), message) +} + +// ExecuteCommand executes a command passed as the player. If the command could not be found, or if the usage +// was incorrect, an error message is sent to the player. This message should start with a "/" for the command to be +// recognised. +func (p *Player) ExecuteCommand(commandLine string) { + if p.Dead() { + return + } + args := strings.Split(commandLine, " ") + + name, ok := strings.CutPrefix(args[0], "/") + if !ok { + return + } + + command, ok := cmd.ByAlias(name) + if !ok { + o := &cmd.Output{} + o.Errort(cmd.MessageUnknown, name) + p.SendCommandOutput(o) + return + } + ctx := event.C(p) + if p.Handler().HandleCommandExecution(ctx, command, args[1:]); ctx.Cancelled() { + return + } + command.Execute(strings.Join(args[1:], " "), p, p.tx) +} + +// Transfer transfers the player to a server at the address passed. If the address could not be resolved, an +// error is returned. If it is returned, the player is closed and transferred to the server. +func (p *Player) Transfer(address string) error { + addr, err := net.ResolveUDPAddr("udp", address) + if err != nil { + return err + } + + ctx := event.C(p) + if p.Handler().HandleTransfer(ctx, addr); ctx.Cancelled() { + return nil + } + p.session().Transfer(addr.IP, addr.Port) + return nil +} + +// SendCommandOutput sends the output of a command to the player. +func (p *Player) SendCommandOutput(output *cmd.Output) { + p.session().SendCommandOutput(output, p.locale) +} + +// SendDialogue sends an NPC dialogue to the player, using the entity passed as the entity that the dialogue +// is shown for. Dialogues can be sent on top of each other without the other closing, making it possible +// to have non-flashing transitions between menus compared to forms. The player can either press one of the +// buttons or close the dialogue. It is impossible for a dialogue to have any more than 6 buttons. +func (p *Player) SendDialogue(d dialogue.Dialogue, e world.Entity) { + p.session().SendDialogue(d, e) +} + +// CloseDialogue closes the player's currently open dialogue, if any. If the dialogue's Submittable implements +// dialogue.Closer, the Close method of the Submittable is called after the client acknowledges the closing +// of the dialogue. +func (p *Player) CloseDialogue() { + p.session().CloseDialogue() +} + +// SendForm sends a form to the player for the client to fill out. Once the client fills it out, the Submit +// method of the form will be called. +// Note that the client may also close the form instead of filling it out, which will result in the form not +// having its Submit method called at all. Forms should never depend on the player actually filling out the +// form. +func (p *Player) SendForm(f form.Form) { + p.session().SendForm(f) +} + +// CloseForm closes any forms that the player currently has open. If the player has no forms open, nothing +// happens. +func (p *Player) CloseForm() { + p.session().CloseForm() +} + +// ShowCoordinates enables the vanilla coordinates for the player. +func (p *Player) ShowCoordinates() { + p.session().EnableCoordinates(true) +} + +// HideCoordinates disables the vanilla coordinates for the player. +func (p *Player) HideCoordinates() { + p.session().EnableCoordinates(false) +} + +// EnableInstantRespawn enables the vanilla instant respawn for the player. +func (p *Player) EnableInstantRespawn() { + p.session().EnableInstantRespawn(true) +} + +// DisableInstantRespawn disables the vanilla instant respawn for the player. +func (p *Player) DisableInstantRespawn() { + p.session().EnableInstantRespawn(false) +} + +// SetNameTag changes the name tag displayed over the player in-game. Changing the name tag does not change +// the player's name in, for example, the player list or the chat. +func (p *Player) SetNameTag(name string) { + p.nameTag = name + p.updateState() +} + +// NameTag returns the current name tag of the Player as shown in-game. It can be changed using SetNameTag. +func (p *Player) NameTag() string { + return p.nameTag +} + +// SetScoreTag changes the score tag displayed over the player in-game. The score tag is displayed under the player's +// name tag. +func (p *Player) SetScoreTag(a ...any) { + tag := format(a) + p.scoreTag = tag + p.updateState() +} + +// ScoreTag returns the current score tag of the player. It can be changed using SetScoreTag and by default is empty. +func (p *Player) ScoreTag() string { + return p.scoreTag +} + +// SetSpeed sets the speed of the player. The value passed is the blocks/tick speed that the player will then +// obtain. +func (p *Player) SetSpeed(speed float64) { + p.speed = speed + p.session().SendSpeed(speed) +} + +// Speed returns the speed of the player, returning a value that indicates the blocks/tick speed. The default +// speed of a player is 0.1. +func (p *Player) Speed() float64 { + return p.speed +} + +// SetFlightSpeed sets the flight speed of the player. The value passed represents the base speed, which is +// multiplied by 10 to obtain the actual blocks/tick speed that the player will then obtain while flying. +func (p *Player) SetFlightSpeed(flightSpeed float64) { + p.flightSpeed = flightSpeed + p.session().SendAbilities(p) +} + +// FlightSpeed returns the flight speed of the player, with the value representing the base speed. The actual +// blocks/tick speed is this value multiplied by 10. The default flight speed of a player is 0.05, which +// corresponds to 0.5 blocks/tick. +func (p *Player) FlightSpeed() float64 { + return p.flightSpeed +} + +// SetVerticalFlightSpeed sets the flight speed of the player on the Y axis. The value passed represents the +// base speed, which is the blocks/tick speed that the player will obtain while flying. +func (p *Player) SetVerticalFlightSpeed(flightSpeed float64) { + p.verticalFlightSpeed = flightSpeed + p.session().SendAbilities(p) +} + +// VerticalFlightSpeed returns the flight speed of the player on the Y axis, with the value representing the +// base speed. The default vertical flight speed of a player is 1.0, which corresponds to 1 block/tick. +func (p *Player) VerticalFlightSpeed() float64 { + return p.verticalFlightSpeed +} + +// Health returns the current health of the player. It will always be lower than Player.MaxHealth(). +func (p *Player) Health() float64 { + return p.health.Health() +} + +// MaxHealth returns the maximum amount of health that a player may have. The MaxHealth will always be higher +// than Player.Health(). +func (p *Player) MaxHealth() float64 { + return p.health.MaxHealth() +} + +// SetMaxHealth sets the maximum health of the player. If the current health of the player is higher than the +// new maximum health, the health is set to the new maximum. +// SetMaxHealth panics if the max health passed is 0 or lower. +func (p *Player) SetMaxHealth(health float64) { + p.health.SetMaxHealth(health) + p.session().SendHealth(p.Health(), p.MaxHealth(), p.absorptionHealth) +} + +// addHealth adds health to the player's current health. +func (p *Player) addHealth(health float64) { + p.health.AddHealth(health) + p.session().SendHealth(p.Health(), p.MaxHealth(), p.absorptionHealth) +} + +// Heal heals the entity for a given amount of health. The source passed +// represents the cause of the healing, for example entity.FoodHealingSource if +// the entity healed by having a full food bar. If the health added to the +// original health exceeds the entity's max health, Heal will not add the full +// amount. If the health passed is negative, Heal will not do anything. +func (p *Player) Heal(health float64, source world.HealingSource) { + if p.Dead() || health < 0 || !p.GameMode().AllowsTakingDamage() { + return + } + ctx := event.C(p) + if p.Handler().HandleHeal(ctx, &health, source); ctx.Cancelled() { + return + } + p.addHealth(health) +} + +// updateFallState is called to update the entities falling state. +func (p *Player) updateFallState(distanceThisTick float64) { + switch { + case p.OnGround(): + if p.fallDistance > 0 { + p.fall(p.fallDistance) + p.ResetFallDistance() + } + case distanceThisTick < p.fallDistance: + p.fallDistance -= distanceThisTick + default: + p.ResetFallDistance() + } +} + +// fall is called when a falling entity hits the ground. +func (p *Player) fall(distance float64) { + pos := cube.PosFromVec3(p.Position()) + b := p.tx.Block(pos) + + if len(b.Model().BBox(pos, p.tx)) == 0 { + pos = pos.Sub(cube.Pos{0, 1}) + b = p.tx.Block(pos) + } + if h, ok := b.(block.EntityLander); ok { + h.EntityLand(pos, p.tx, p, &distance) + } + dmg := distance - 3 + if boost, ok := p.Effect(effect.JumpBoost); ok { + dmg -= float64(boost.Level()) + } + if dmg < 0.5 { + return + } + p.Hurt(math.Ceil(dmg), entity.FallDamageSource{}) +} + +// Hurt hurts the player for a given amount of damage. The source passed +// represents the cause of the damage, for example entity.AttackDamageSource if +// the player is attacked by another entity. If the final damage exceeds the +// health that the player currently has, the player is killed and will have to +// respawn. +// If the damage passed is negative, Hurt will not do anything. Hurt returns the +// final damage dealt to the Player and if the Player was vulnerable to this +// kind of damage. +func (p *Player) Hurt(dmg float64, src world.DamageSource) (float64, bool) { + if _, ok := p.Effect(effect.FireResistance); (ok && src.Fire()) || p.Dead() || !p.GameMode().AllowsTakingDamage() || dmg < 0 { + return 0, false + } + totalDamage := p.FinalDamageFrom(dmg, src) + damageLeft := totalDamage + + immune := time.Now().Before(p.immuneUntil) + if immune { + if damageLeft -= p.lastDamage; damageLeft <= 0 { + return 0, false + } + } + + immunity := time.Second / 2 + ctx := event.C(p) + if p.Handler().HandleHurt(ctx, &damageLeft, immune, &immunity, src); ctx.Cancelled() { + return 0, false + } + p.setAttackImmunity(immunity, totalDamage) + + if a := p.Absorption(); a > 0 { + remaining := a - damageLeft + p.SetAbsorption(remaining) + damageLeft = max(0, damageLeft-a) + if _, exists := p.Effect(effect.Absorption); exists && remaining <= 0 { + p.RemoveEffect(effect.Absorption) + } + } + + if p.Health()-damageLeft <= mgl64.Epsilon && !src.IgnoreTotem() { + hand, offHand := p.HeldItems() + if _, ok := offHand.Item().(item.Totem); ok { + p.applyTotemEffects() + p.SetHeldItems(hand, offHand.Grow(-1)) + return 0, false + } else if _, ok := hand.Item().(item.Totem); ok { + p.applyTotemEffects() + p.SetHeldItems(hand.Grow(-1), offHand) + return 0, false + } + } + + p.addHealth(-damageLeft) + + if src.ReducedByArmour() { + p.Exhaust(0.1) + p.Armour().Damage(dmg, p.damageItem) + + var origin world.Entity + if s, ok := src.(entity.AttackDamageSource); ok { + origin = s.Attacker + } else if s, ok := src.(entity.ProjectileDamageSource); ok { + origin = s.Owner + } + if l, ok := origin.(entity.Living); ok { + if thornsDmg := p.Armour().ThornsDamage(p.damageItem); thornsDmg > 0 { + l.Hurt(thornsDmg, enchantment.ThornsDamageSource{Owner: p}) + } + } + } + + pos := p.Position() + for _, viewer := range p.viewers() { + viewer.ViewEntityAction(p, entity.HurtAction{}) + } + if src.Fire() { + p.tx.PlaySound(pos, sound.Burning{}) + } else if _, ok := src.(entity.DrowningDamageSource); ok { + p.tx.PlaySound(pos, sound.Drowning{}) + } + + p.Wake() + + if p.Dead() { + p.kill(src) + } + return totalDamage, true +} + +// applyTotemEffects is an unexported function that is used to handle totem effects. +func (p *Player) applyTotemEffects() { + p.addHealth(2 - p.Health()) + + for _, e := range p.Effects() { + p.RemoveEffect(e.Type()) + } + + p.AddEffect(effect.New(effect.Regeneration, 2, time.Second*40)) + p.AddEffect(effect.New(effect.FireResistance, 1, time.Second*40)) + p.AddEffect(effect.New(effect.Absorption, 2, time.Second*5)) + + p.tx.PlaySound(p.Position(), sound.Totem{}) + + for _, viewer := range p.viewers() { + viewer.ViewEntityAction(p, entity.TotemUseAction{}) + } +} + +// FinalDamageFrom resolves the final damage received by the player if it is attacked by the source passed +// with the damage passed. FinalDamageFrom takes into account things such as the armour worn and the +// enchantments on the individual pieces. +// The damage returned will be at the least 0. +func (p *Player) FinalDamageFrom(dmg float64, src world.DamageSource) float64 { + dmg = max(dmg, 0) + + dmg -= p.Armour().DamageReduction(dmg, src) + if res, ok := p.Effect(effect.Resistance); ok { + dmg *= effect.Resistance.Multiplier(src, res.Level()) + } + return dmg +} + +// Explode ... +func (p *Player) Explode(explosionPos mgl64.Vec3, impact float64, c block.ExplosionConfig) { + diff := p.Position().Sub(explosionPos) + p.Hurt(math.Floor((impact*impact+impact)*3.5*c.Size*2+1), entity.ExplosionDamageSource{}) + p.knockBack(explosionPos, impact, diff[1]/diff.Len()*impact) +} + +// SetAbsorption sets the absorption health of a player. This extra health shows as golden hearts and do not +// actually increase the maximum health. Once the hearts are lost, they will not regenerate. +// Nothing happens if a negative number is passed. +func (p *Player) SetAbsorption(health float64) { + p.absorptionHealth = max(health, 0) + p.session().SendHealth(p.Health(), p.MaxHealth(), p.absorptionHealth) +} + +// Absorption returns the absorption health that the player has. +func (p *Player) Absorption() float64 { + return p.absorptionHealth +} + +// KnockBack knocks the player back with a given force and height. A source is passed which indicates the +// source of the velocity, typically the position of an attacking entity. The source is used to calculate the +// direction which the entity should be knocked back in. +func (p *Player) KnockBack(src mgl64.Vec3, force, height float64) { + if p.Dead() || !p.GameMode().AllowsTakingDamage() { + return + } + p.knockBack(src, force, height) +} + +// knockBack is an unexported function that is used to knock the player back. This function does not check if the player +// can take damage or not. +func (p *Player) knockBack(src mgl64.Vec3, force, height float64) { + velocity := p.Position().Sub(src) + velocity[1] = 0 + + if velocity.Len() != 0 { + velocity = velocity.Normalize().Mul(force) + } + velocity[1] = height + + p.SetVelocity(velocity.Mul(1 - p.Armour().KnockBackResistance())) +} + +// setAttackImmunity sets the duration the player is immune to entity attacks. +func (p *Player) setAttackImmunity(d time.Duration, dmg float64) { + p.immuneUntil = time.Now().Add(d) + p.lastDamage = dmg +} + +// Food returns the current food level of a player. The level returned is guaranteed to always be between 0 +// and 20. Every half drumstick is one level. +func (p *Player) Food() int { + return p.hunger.Food() +} + +// SetFood sets the food level of a player. The level passed must be in a range of 0-20. If the level passed +// is negative, the food level will be set to 0. If the level exceeds 20, the food level will be set to 20. +func (p *Player) SetFood(level int) { + p.hunger.SetFood(level) + p.sendFood() +} + +// AddFood adds a number of points to the food level of the player. If the new food level is negative or if +// it exceeds 20, it will be set to 0 or 20 respectively. +func (p *Player) AddFood(points int) { + p.hunger.AddFood(points) + p.sendFood() +} + +// Saturate saturates the player's food bar with the amount of food points and saturation points passed. The +// total saturation of the player will never exceed its total food level. +func (p *Player) Saturate(food int, saturation float64) { + p.hunger.saturate(food, saturation) + p.sendFood() +} + +// sendFood sends the current food properties to the client. +func (p *Player) sendFood() { + p.session().SendFood(p.hunger.foodLevel, p.hunger.saturationLevel, p.hunger.exhaustionLevel) +} + +// AddEffect adds an entity.Effect to the Player. If the effect is instant, it is applied to the Player +// immediately. If not, the effect is applied to the player every time the Tick method is called. +// AddEffect will overwrite any effects present if the level of the effect is higher than the existing one, or +// if the effects' levels are equal and the new effect has a longer duration. +func (p *Player) AddEffect(e effect.Effect) { + p.session().SendEffect(p.effects.Add(e, p)) + p.updateState() +} + +// RemoveEffect removes any effect that might currently be active on the Player. +func (p *Player) RemoveEffect(e effect.Type) { + p.effects.Remove(e, p) + p.session().SendEffectRemoval(e) + p.updateState() +} + +// Effect returns the effect instance and true if the Player has the effect. If not found, it will return an empty +// effect instance and false. +func (p *Player) Effect(e effect.Type) (effect.Effect, bool) { + return p.effects.Effect(e) +} + +// Effects returns any effect currently applied to the entity. The returned effects are guaranteed not to have +// expired when returned. +func (p *Player) Effects() []effect.Effect { + return p.effects.Effects() +} + +// BeaconAffected ... +func (*Player) BeaconAffected() bool { + return true +} + +// Exhaust exhausts the player by the amount of points passed if the player is in survival mode. If the total +// exhaustion level exceeds 4, a saturation point, or food point, if saturation is 0, will be subtracted. +func (p *Player) Exhaust(points float64) { + if !p.GameMode().AllowsTakingDamage() || p.tx.World().Difficulty().FoodRegenerates() { + return + } + before := p.hunger.Food() + p.hunger.exhaust(points) + if after := p.hunger.Food(); before != after { + // Temporarily set the food level back so that it hasn't yet changed once the event is handled. + p.hunger.SetFood(before) + + ctx := event.C(p) + if p.Handler().HandleFoodLoss(ctx, before, &after); ctx.Cancelled() { + // Reset the exhaustion level if the event was cancelled. Because if + // we cancel this, and at some point we stop cancelling it, the + // first food point will be lost more quickly than expected. + p.hunger.resetExhaustion() + return + } + p.hunger.SetFood(after) + if before >= 7 && after <= 6 { + // The client will stop sprinting by itself too, but we force it just to be sure. + p.StopSprinting() + } + } + p.sendFood() +} + +// Dead checks if the player is considered dead. True is returned if the health of the player is equal to or +// lower than 0. +func (p *Player) Dead() bool { + return p.Health() <= mgl64.Epsilon +} + +// DeathPosition returns the last position the player was at when they died. If the player has never died, the third +// return value will be false. +func (p *Player) DeathPosition() (mgl64.Vec3, world.Dimension, bool) { + if p.deathPos == nil { + return mgl64.Vec3{}, nil, false + } + return *p.deathPos, p.deathDimension, true +} + +// kill kills the player, clearing its inventories and resetting it to its base state. +func (p *Player) kill(src world.DamageSource) { + for _, viewer := range p.viewers() { + viewer.ViewEntityAction(p, entity.DeathAction{}) + } + + p.addHealth(-p.MaxHealth()) + + keepInv := false + p.Handler().HandleDeath(p, src, &keepInv) + p.StopSneaking() + p.StopSprinting() + + pos := p.Position() + if !keepInv { + p.dropItems() + } + for _, e := range p.Effects() { + p.RemoveEffect(e.Type()) + } + + p.deathPos, p.deathDimension = &pos, p.tx.World().Dimension() + + // Wait a little before removing the entity. The client displays a death + // animation while the player is dying. + time.AfterFunc(time.Millisecond*1100, func() { + p.H().ExecWorld(finishDying) + }) +} + +// finishDying completes the death of a player, removing it from the world. +func finishDying(_ *world.Tx, e world.Entity) { + p := e.(*Player) + if p.session() == session.Nop { + _ = p.Close() + return + } + if p.Dead() { + p.SetInvisible() + // We have an actual client connected to this player: We change its + // position server side so that in the future, the client won't respawn + // on the death location when disconnecting. The client should not see + // the movement itself yet, though. + pos, _, _, _ := p.spawnLocation() + + p.data.Pos = pos.Vec3() + } +} + +// dropItems drops all items and experience of the Player on the ground in random directions. +func (p *Player) dropItems() { + pos := p.Position() + for _, orb := range entity.NewExperienceOrbs(pos, int(math.Min(float64(p.experience.Level()*7), 100))) { + p.tx.AddEntity(orb) + } + p.experience.Reset() + p.session().SendExperience(p.ExperienceLevel(), p.ExperienceProgress()) + + p.MoveItemsToInventory() + for _, it := range append(p.inv.Clear(), append(p.armour.Clear(), p.offHand.Clear()...)...) { + if _, ok := it.Enchantment(enchantment.CurseOfVanishing); ok { + continue + } + opts := world.EntitySpawnOpts{Position: pos, Velocity: mgl64.Vec3{rand.Float64()*0.2 - 0.1, 0.2, rand.Float64()*0.2 - 0.1}} + p.tx.AddEntity(entity.NewItem(opts, it)) + } +} + +// MoveItemsToInventory moves items kept in 'temporary' slots, such as the +// crafting grid of slots in an enchantment table, to the player's inventory. +// If no space is left for these items, the leftover items are dropped. +func (p *Player) MoveItemsToInventory() { + for _, i := range p.ui.Clear() { + if n, err := p.inv.AddItem(i); err != nil { + // We couldn't add the item to the main inventory (probably because + // it was full), so we drop it instead. + p.Drop(i.Grow(i.Count() - n)) + } + } +} + +// Respawn spawns the player after it dies, so that its health is replenished, +// and it is spawned in the world again. Nothing will happen if the player does +// not have a session connected to it. +// Calling Respawn may lead to the player being removed from its world and being +// added to a new world. This means that p cannot be assumed to be valid after +// a call to Respawn. +func (p *Player) Respawn() *world.EntityHandle { + p.respawn(nil) + return p.handle +} + +func (p *Player) respawn(f func(p *Player)) { + if !p.Dead() || p.session() == session.Nop { + return + } + + blockPos, w, spawnObstructed, _ := p.spawnLocation() + pos := blockPos.Vec3Middle() + + if spawnObstructed { + p.Messaget(chat.MessageBedNotValid) + } + + p.addHealth(p.MaxHealth()) + p.hunger.Reset() + p.sendFood() + p.Extinguish() + p.ResetFallDistance() + + p.Handler().HandleRespawn(p, &pos, &w) + + handle := p.tx.RemoveEntity(p) + w.Exec(func(tx *world.Tx) { + np := tx.AddEntity(handle).(*Player) + np.Teleport(pos) + np.session().SendRespawn(pos, p) + np.SetVisible() + if f != nil { + f(np) + } + }) +} + +// spawnLocation designates a players safe spawn location. +func (p *Player) spawnLocation() (playerSpawn cube.Pos, w *world.World, spawnBlockBroken bool, previousDimension world.Dimension) { + tx := p.tx + w = tx.World() + previousDimension = w.Dimension() + playerSpawn = w.PlayerSpawn(p.UUID()) + if b, ok := tx.Block(playerSpawn).(block.Bed); ok && b.CanRespawnOn() { + pos, ok := b.SafeSpawn(playerSpawn, tx) + if ok { + return pos, w, false, previousDimension + } + } + + // We can use the principle here that returning through a portal of a specific dimension inside that dimension will + // always bring us back to the overworld. + w = w.PortalDestination(w.Dimension()) + worldSpawn := w.Spawn() + return worldSpawn, w, playerSpawn != worldSpawn, previousDimension +} + +// StartSprinting makes a player start sprinting, increasing the speed of the player by 30% and making +// particles show up under the feet. The player will only start sprinting if its food level is high enough. +// If the player is sneaking when calling StartSprinting, it is stopped from sneaking. +func (p *Player) StartSprinting() { + if !p.hunger.canSprint() && p.GameMode().AllowsTakingDamage() || p.crawling || p.sprinting { + return + } + ctx := event.C(p) + if p.Handler().HandleToggleSprint(ctx, true); ctx.Cancelled() { + return + } + p.StopSneaking() + p.sprinting = true + p.SetSpeed(p.speed * 1.3) + p.updateState() +} + +// Sprinting checks if the player is currently sprinting. +func (p *Player) Sprinting() bool { + return p.sprinting +} + +// StopSprinting makes a player stop sprinting, setting back the speed of the player to its original value. +func (p *Player) StopSprinting() { + if !p.sprinting { + return + } + ctx := event.C(p) + if p.Handler().HandleToggleSprint(ctx, false); ctx.Cancelled() { + return + } + p.sprinting = false + p.SetSpeed(p.speed / 1.3) + p.updateState() +} + +// StartSneaking makes a player start sneaking. If the player is already sneaking, StartSneaking will not do +// anything. +// If the player is sprinting while StartSneaking is called, the sprinting is stopped. +func (p *Player) StartSneaking() { + if p.sneaking { + return + } + ctx := event.C(p) + if p.Handler().HandleToggleSneak(ctx, true); ctx.Cancelled() { + return + } + if !p.Flying() { + p.StopSprinting() + } + p.sneaking = true + p.updateState() +} + +// Sneaking checks if the player is currently sneaking. +func (p *Player) Sneaking() bool { + return p.sneaking +} + +// StopSneaking makes a player stop sneaking if it currently is. If the player is not sneaking, StopSneaking +// will not do anything. +func (p *Player) StopSneaking() { + if !p.sneaking { + return + } + ctx := event.C(p) + if p.Handler().HandleToggleSneak(ctx, false); ctx.Cancelled() { + return + } + p.sneaking = false + p.updateState() +} + +// StartSwimming makes the player start swimming if it is not currently doing so. If the player is sneaking +// while StartSwimming is called, the sneaking is stopped. +func (p *Player) StartSwimming() { + if p.swimming { + return + } + p.StopSneaking() + p.swimming = true + p.updateState() +} + +// Swimming checks if the player is currently swimming. +func (p *Player) Swimming() bool { + return p.swimming +} + +// StopSwimming makes the player stop swimming if it is currently doing so. +func (p *Player) StopSwimming() { + if !p.swimming { + return + } + p.swimming = false + p.updateState() +} + +// Splash is called when a water bottle splashes onto the player. +func (p *Player) Splash(*world.Tx, mgl64.Vec3) { + if d := p.OnFireDuration(); d.Seconds() <= 0 { + return + } + p.Extinguish() +} + +// StartCrawling makes the player start crawling if it is not currently doing so. If the player is sneaking +// while StartCrawling is called, the sneaking is stopped. +func (p *Player) StartCrawling() { + if p.crawling { + return + } + for _, corner := range p.H().Type().BBox(p).Translate(p.Position()).Corners() { + if _, isAir := p.tx.Block(cube.PosFromVec3(corner).Add(cube.Pos{0, 1, 0})).(block.Air); !isAir { + p.crawling = true + break + } + } + p.StopSneaking() + p.updateState() +} + +// StopCrawling makes the player stop crawling if it is currently doing so. +func (p *Player) StopCrawling() { + if !p.crawling { + return + } + p.crawling = false + p.updateState() +} + +// Crawling checks if the player is currently crawling. +func (p *Player) Crawling() bool { + return p.crawling +} + +// StartGliding makes the player start gliding if it is not currently doing so. +func (p *Player) StartGliding() { + if p.gliding { + return + } + chest := p.Armour().Chestplate() + if _, ok := chest.Item().(item.Elytra); !ok || chest.Durability() < 2 { + return + } + p.gliding = true + p.updateState() +} + +// Gliding checks if the player is currently gliding. +func (p *Player) Gliding() bool { + return p.gliding +} + +// StopGliding makes the player stop gliding if it is currently doing so. +func (p *Player) StopGliding() { + if !p.gliding { + return + } + p.gliding = false + p.glideTicks = 0 + p.updateState() +} + +// StartFlying makes the player start flying if they aren't already. It requires the player to be in a gamemode which +// allows flying. +func (p *Player) StartFlying() { + if !p.GameMode().AllowsFlying() || p.Flying() { + return + } + p.flying = true + p.session().SendGameMode(p) +} + +// Flying checks if the player is currently flying. +func (p *Player) Flying() bool { + return p.flying +} + +// StopFlying makes the player stop flying if it currently is. +func (p *Player) StopFlying() { + if !p.flying { + return + } + p.flying = false + p.session().SendGameMode(p) +} + +// Jump makes the player jump if they are on ground. It exhausts the player by 0.05 food points, an additional 0.15 +// is exhausted if the player is sprint jumping. +func (p *Player) Jump() { + if p.Dead() { + return + } + + p.Handler().HandleJump(p) + if p.OnGround() { + jumpVel := 0.42 + if e, ok := p.Effect(effect.JumpBoost); ok { + jumpVel = float64(e.Level()) / 10 + } + p.data.Vel = mgl64.Vec3{0, jumpVel} + } + if p.Sprinting() { + p.Exhaust(0.2) + } else { + p.Exhaust(0.05) + } +} + +// Sleep makes the player sleep at the given position. If the position does not map to a bed (specifically the head side), +// the player will not sleep. +func (p *Player) Sleep(pos cube.Pos) { + if p.sleeping { + // The player is already sleeping. + return + } + + tx := p.tx + b, ok := tx.Block(pos).(block.Bed) + if !ok || b.Sleeper != nil { + // The player cannot sleep here. + return + } + + ctx, sendReminder := event.C(p), true + if p.Handler().HandleSleep(ctx, &sendReminder); ctx.Cancelled() { + return + } + + b.Sleeper = p.H() + tx.SetBlock(pos, b, nil) + + tx.World().SetRequiredSleepDuration(time.Millisecond * 5050) + + p.data.Pos = pos.Vec3Middle().Add(mgl64.Vec3{0, 0.5625}) + p.sleeping = true + p.sleepPos = pos + + p.session().SendPlayerSpawn(pos.Vec3()) + + if sendReminder { + tx.BroadcastSleepingReminder(p) + } + + tx.BroadcastSleepingIndicator() + p.updateState() +} + +// Wake forces the player out of bed if they are sleeping. +func (p *Player) Wake() { + if !p.sleeping { + return + } + p.sleeping = false + + tx := p.tx + tx.BroadcastSleepingIndicator() + + for _, v := range p.viewers() { + v.ViewEntityWake(p) + } + p.updateState() + + pos := p.sleepPos + if b, ok := tx.Block(pos).(block.Bed); ok { + b.Sleeper = nil + tx.SetBlock(pos, b, nil) + } +} + +// Sleeping returns true if the player is currently sleeping, along with the position of the bed the player is sleeping +// on. +func (p *Player) Sleeping() (cube.Pos, bool) { + if !p.sleeping { + return cube.Pos{}, false + } + return p.sleepPos, true +} + +// SendSleepingIndicator displays a notification to the player on the amount of sleeping players in the world. +func (p *Player) SendSleepingIndicator(sleeping, max int) { + p.session().ViewSleepingPlayers(sleeping, max) +} + +// SetInvisible sets the player invisible, so that other players will not be able to see it. +func (p *Player) SetInvisible() { + if p.Invisible() { + return + } + p.invisible = true + p.updateState() +} + +// SetVisible sets the player visible again, so that other players can see it again. If the player was already +// visible, or if the player is in spectator mode, nothing happens. +func (p *Player) SetVisible() { + if _, ok := p.Effect(effect.Invisibility); ok || !p.GameMode().Visible() || !p.invisible { + return + } + p.invisible = false + p.updateState() +} + +// Invisible checks if the Player is currently invisible. +func (p *Player) Invisible() bool { + return p.invisible +} + +// SetImmobile prevents the player from moving around, but still allows them to look around. +func (p *Player) SetImmobile() { + if p.Immobile() { + return + } + p.immobile = true + p.updateState() +} + +// SetMobile allows the player to freely move around again after being immobile. +func (p *Player) SetMobile() { + if !p.Immobile() { + return + } + p.immobile = false + p.updateState() +} + +// Immobile checks if the Player is currently immobile. +func (p *Player) Immobile() bool { + return p.immobile +} + +// FireProof checks if the Player is currently fireproof. True is returned if the player has a fireResistance effect or +// if it is in creative mode. +func (p *Player) FireProof() bool { + if _, ok := p.Effect(effect.FireResistance); ok { + return true + } + return !p.GameMode().AllowsTakingDamage() +} + +// OnFireDuration ... +func (p *Player) OnFireDuration() time.Duration { + return time.Duration(p.fireTicks) * time.Second / 20 +} + +// SetOnFire ... +func (p *Player) SetOnFire(duration time.Duration) { + ticks := int64(duration.Seconds() * 20) + if level := p.Armour().HighestEnchantmentLevel(enchantment.FireProtection); level > 0 { + ticks -= int64(math.Floor(float64(ticks) * float64(level) * 0.15)) + } + p.fireTicks = ticks + p.updateState() +} + +// Extinguish ... +func (p *Player) Extinguish() { + p.SetOnFire(0) +} + +// Inventory returns the inventory of the player. This inventory holds the items stored in the normal part of +// the inventory and the hotbar. It also includes the item in the main hand as returned by Player.HeldItems(). +func (p *Player) Inventory() *inventory.Inventory { + return p.inv +} + +// Armour returns the armour inventory of the player. This inventory yields 4 slots, for the helmet, +// chestplate, leggings and boots respectively. +func (p *Player) Armour() *inventory.Armour { + return p.armour +} + +// HeldItems returns the items currently held in the hands of the player. The first item stack returned is the +// one held in the main hand, the second is held in the off-hand. +// If no item was held in a hand, the stack returned has a count of 0. Stack.Empty() may be used to check if +// the hand held anything. +func (p *Player) HeldItems() (mainHand, offHand item.Stack) { + offHand, _ = p.offHand.Item(0) + mainHand, _ = p.inv.Item(int(*p.heldSlot)) + return mainHand, offHand +} + +// SetHeldItems sets items to the main hand and the off-hand of the player. The Stacks passed may be empty +// (Stack.Empty()) to clear the held item. +func (p *Player) SetHeldItems(mainHand, offHand item.Stack) { + _ = p.inv.SetItem(int(*p.heldSlot), mainHand) + _ = p.offHand.SetItem(0, offHand) +} + +// SetHeldSlot updates the held slot of the player to the slot provided. The +// slot must be between 0 and 8. +func (p *Player) SetHeldSlot(to int) error { + // The slot that the player might have selected must be within the hotbar: + // The held item cannot be in a different place in the inventory. + if to < 0 || to > 8 { + return fmt.Errorf("held slot exceeds hotbar range 0-8: slot is %v", to) + } + from := int(*p.heldSlot) + if from == to { + // Old slot was the same as new slot, so don't do anything. + return nil + } + + ctx := event.C(p) + p.Handler().HandleHeldSlotChange(ctx, from, to) + if ctx.Cancelled() { + // The slot change was cancelled, resend held slot. + p.session().SendHeldSlot(from, p, true) + return nil + } + *p.heldSlot = uint32(to) + p.usingItem = false + + for _, viewer := range p.viewers() { + viewer.ViewEntityItems(p) + } + p.session().SendHeldSlot(to, p, false) + return nil +} + +// EnderChestInventory returns the player's ender chest inventory. Its accessed by the player when opening +// ender chests anywhere. +func (p *Player) EnderChestInventory() *inventory.Inventory { + return p.enderChest +} + +// SetGameMode sets the game mode of a player. The game mode specifies the way that the player can interact +// with the world that it is in. +func (p *Player) SetGameMode(mode world.GameMode) { + previous := p.GameMode() + p.gameMode = mode + + if !mode.AllowsFlying() { + p.StopFlying() + } + if !mode.Visible() { + p.SetInvisible() + } else if !previous.Visible() { + p.SetVisible() + } + + p.session().SendGameMode(p) + for _, v := range p.viewers() { + v.ViewEntityGameMode(p) + } + if mode.AllowsTakingDamage() { + p.session().SendHealth(p.Health(), p.MaxHealth(), p.absorptionHealth) + } +} + +// GameMode returns the current game mode assigned to the player. If not changed, the game mode returned will +// be the same as that of the world that the player spawns in. +// The game mode may be changed using Player.SetGameMode(). +func (p *Player) GameMode() world.GameMode { + return p.gameMode +} + +// HasCooldown returns true if the item passed has an active cooldown, meaning it currently cannot be used again. If the +// world.Item passed is nil, HasCooldown always returns false. +func (p *Player) HasCooldown(item world.Item) bool { + if item == nil { + return false + } + name, _ := item.EncodeItem() + otherTime, ok := p.cooldowns[name] + if !ok { + return false + } + if time.Now().After(otherTime) { + delete(p.cooldowns, name) + return false + } + return true +} + +// SetCooldown sets a cooldown for an item. If the world.Item passed is nil, nothing happens. +func (p *Player) SetCooldown(item world.Item, cooldown time.Duration) { + if item == nil { + return + } + name, _ := item.EncodeItem() + p.cooldowns[name] = time.Now().Add(cooldown) + p.session().ViewItemCooldown(item, cooldown) +} + +// UseItem uses the item currently held in the player's main hand in the air. Generally, nothing happens, +// unless the held item implements the item.Usable interface, in which case it will be activated. +// This generally happens for items such as throwable items like snowballs. +func (p *Player) UseItem() { + i, _ := p.HeldItems() + ctx := event.C(p) + if p.HasCooldown(i.Item()) { + return + } + if p.Handler().HandleItemUse(ctx); ctx.Cancelled() { + return + } + i, left := p.HeldItems() + it := i.Item() + + if cd, ok := it.(item.Cooldown); ok { + p.SetCooldown(it, cd.Cooldown()) + } + + if _, ok := it.(item.Releasable); ok { + if !p.canRelease() { + return + } + p.usingSince, p.usingItem = time.Now(), true + p.updateState() + } + switch usable := it.(type) { + case item.Chargeable: + useCtx := p.useContext() + if !p.usingItem { + 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 + } + p.handleUseContext(useCtx) + p.updateState() + return + } + + // Stop charging and determine if the item is ready. + p.usingItem = false + dur := p.useDuration() + if usable.Charge(p, p.tx, useCtx, dur) { + p.session().SendChargeItemComplete() + } + p.handleUseContext(useCtx) + p.updateState() + case item.Usable: + useCtx := p.useContext() + if !usable.Use(p.tx, p, useCtx) { + return + } + // We only swing the player's arm if the item held actually does something. If it doesn't, there is no + // reason to swing the arm. + p.SwingArm() + p.SetHeldItems(p.subtractItem(p.damageItem(i, useCtx.Damage), useCtx.CountSub), left) + p.addNewItem(useCtx) + case item.Consumable: + if c, ok := usable.(interface{ CanConsume() bool }); ok && !c.CanConsume() { + p.ReleaseItem() + return + } + if !usable.AlwaysConsumable() && p.GameMode().AllowsTakingDamage() && p.Food() >= 20 { + // The item.Consumable is not always consumable, the player is not in creative mode and the + // food bar is filled: The item cannot be consumed. + p.ReleaseItem() + return + } + if !p.usingItem { + // Consumable starts being consumed: Set the start timestamp and update the using state to viewers. + p.usingItem, p.usingSince = true, time.Now() + p.updateState() + return + } + // The player is currently using the item held. This is a signal the item was consumed, so we + // consume it and start using it again. + useCtx, dur := p.useContext(), p.useDuration() + if dur < usable.ConsumeDuration() { + // The required duration for consuming this item was not met, so we don't consume it. + return + } + // Reset the duration for the next item to be consumed. + p.usingSince = time.Now() + ctx := event.C(p) + if p.Handler().HandleItemConsume(ctx, i); ctx.Cancelled() { + return + } + useCtx.CountSub, useCtx.NewItem = 1, usable.Consume(p.tx, p) + p.handleUseContext(useCtx) + p.tx.PlaySound(p.Position().Add(mgl64.Vec3{0, 1.5}), sound.Burp{}) + } +} + +// ReleaseItem makes the Player release the item it is currently using. This is only applicable for items that +// implement the item.Releasable interface. +// If the Player is not currently using any item, ReleaseItem returns immediately. +// ReleaseItem either aborts the using of the item or finished it, depending on the time that elapsed since +// the item started being used. +func (p *Player) ReleaseItem() { + if !p.usingItem || !p.canRelease() || !p.GameMode().AllowsInteraction() { + p.usingItem = false + return + } + p.usingItem = false + + useCtx, dur := p.useContext(), p.useDuration() + i, _ := p.HeldItems() + ctx := event.C(p) + if p.Handler().HandleItemRelease(ctx, i, dur); ctx.Cancelled() { + return + } + i.Item().(item.Releasable).Release(p, p.tx, useCtx, dur) + p.handleUseContext(useCtx) + p.updateState() +} + +// canRelease returns whether the player can release the item currently held in the main hand. +func (p *Player) canRelease() bool { + held, left := p.HeldItems() + releasable, ok := held.Item().(item.Releasable) + if !ok { + return false + } + if p.GameMode().CreativeInventory() { + return true + } + for _, req := range releasable.Requirements() { + reqName, _ := req.Item().EncodeItem() + + if !left.Empty() { + leftName, _ := left.Item().EncodeItem() + if leftName == reqName { + continue + } + } + + _, found := p.Inventory().FirstFunc(func(stack item.Stack) bool { + name, _ := stack.Item().EncodeItem() + return name == reqName + }) + if !found { + return false + } + } + return true +} + +// handleUseContext handles the item.UseContext after the item has been used. +func (p *Player) handleUseContext(ctx *item.UseContext) { + i, left := p.HeldItems() + + p.SetHeldItems(p.subtractItem(p.damageItem(i, ctx.Damage), ctx.CountSub), left) + p.addNewItem(ctx) + for _, it := range ctx.ConsumedItems { + _, offHand := p.HeldItems() + if offHand.Comparable(it) { + if err := p.offHand.RemoveItem(it); err == nil { + continue + } + + it = it.Grow(-offHand.Count()) + } + + _ = p.Inventory().RemoveItem(it) + } +} + +// useDuration returns the duration the player has been using the item in the main hand. +func (p *Player) useDuration() time.Duration { + return time.Since(p.usingSince) + time.Second/20 +} + +// UsingItem checks if the Player is currently using an item. True is returned if the Player is currently eating an +// item or using it over a longer duration such as when using a bow. +func (p *Player) UsingItem() bool { + return p.usingItem +} + +// UseItemOnBlock uses the item held in the main hand of the player on a block at the position passed. The +// player is assumed to have clicked the face passed with the relative click position clickPos. +// If the item could not be used successfully, for example when the position is out of range, the method +// returns immediately. +// UseItemOnBlock does nothing if the block at the cube.Pos passed is of the type block.Air. +func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3) { + if _, ok := p.tx.Block(pos).(block.Air); ok || !p.canReach(pos.Vec3Centre()) { + // The client used its item on a block that does not exist server-side or one it couldn't reach. Stop trying + // to use the item immediately. + p.resendNearbyBlocks(pos, face) + return + } + ctx := event.C(p) + if p.Handler().HandleItemUseOnBlock(ctx, pos, face, clickPos); ctx.Cancelled() { + p.resendNearbyBlocks(pos, face) + return + } + i, left := p.HeldItems() + b := p.tx.Block(pos) + if act, ok := b.(block.Activatable); ok { + // If a player is sneaking, it will not activate the block clicked, unless it is not holding any + // items, in which case the block will be activated as usual. + if !p.Sneaking() || i.Empty() { + p.SwingArm() + + // The block was activated: Blocks such as doors must always have precedence over the item being + // used. + if useCtx := p.useContext(); act.Activate(pos, face, p.tx, p, useCtx) { + p.SetHeldItems(p.subtractItem(p.damageItem(i, useCtx.Damage), useCtx.CountSub), left) + p.addNewItem(useCtx) + return + } + } + } + if i.Empty() { + return + } + switch ib := i.Item().(type) { + case item.UsableOnBlock: + // The item does something when used on a block. + useCtx := p.useContext() + if !ib.UseOnBlock(pos, face, clickPos, p.tx, p, useCtx) { + return + } + p.SwingArm() + p.SetHeldItems(p.subtractItem(p.damageItem(i, useCtx.Damage), useCtx.CountSub), left) + p.addNewItem(useCtx) + case world.Block: + // The item IS a block, meaning it is being placed. + replacedPos := pos + if replaceable, ok := b.(block.Replaceable); !ok || !replaceable.ReplaceableBy(ib) { + // The block clicked was either not replaceable, or not replaceable using the block passed. + replacedPos = pos.Side(face) + } + if replaceable, ok := p.tx.Block(replacedPos).(block.Replaceable); !ok || !replaceable.ReplaceableBy(ib) || replacedPos.OutOfBounds(p.tx.Range()) { + return + } + if !p.placeBlock(replacedPos, ib, false) || p.GameMode().CreativeInventory() { + return + } + p.SetHeldItems(p.subtractItem(i, 1), left) + } +} + +// UseItemOnEntity uses the item held in the main hand of the player on the entity passed, provided it is +// within range of the player. +// If the item held in the main hand of the player does nothing when used on an entity, nothing will happen. +func (p *Player) UseItemOnEntity(e world.Entity) bool { + if !p.canReach(e.Position()) { + return false + } + ctx := event.C(p) + if p.Handler().HandleItemUseOnEntity(ctx, e); ctx.Cancelled() { + return false + } + i, left := p.HeldItems() + usable, ok := i.Item().(item.UsableOnEntity) + if !ok { + return true + } + useCtx := p.useContext() + if !usable.UseOnEntity(e, p.tx, p, useCtx) { + return true + } + p.SwingArm() + p.SetHeldItems(p.subtractItem(p.damageItem(i, useCtx.Damage), useCtx.CountSub), left) + p.addNewItem(useCtx) + return true +} + +// AttackEntity uses the item held in the main hand of the player to attack the entity passed, provided it is +// within range of the player. +// The damage dealt to the entity will depend on the item held by the player and any effects the player may +// have. +// If the player cannot reach the entity at its position, the method returns immediately. +func (p *Player) AttackEntity(e world.Entity) bool { + if !p.canReach(e.Position()) { + return false + } + + living, isLiving := e.(entity.Living) + if isLiving && living.Dead() { + return false + } + + var ( + force, height = 0.45, 0.3608 + _, slowFalling = p.Effect(effect.SlowFalling) + _, blind = p.Effect(effect.Blindness) + critical = !p.Sprinting() && !p.Flying() && p.FallDistance() > 0 && !slowFalling && !blind + ) + + i, _ := p.HeldItems() + if k, ok := i.Enchantment(enchantment.Knockback); ok { + inc := enchantment.Knockback.Force(k.Level()) + force += inc + height += inc + } + + ctx := event.C(p) + if p.Handler().HandleAttackEntity(ctx, e, &force, &height, &critical); ctx.Cancelled() { + return false + } + p.SwingArm() + + if !isLiving { + return false + } + + dmg := i.AttackDamage() + if strength, ok := p.Effect(effect.Strength); ok { + dmg += dmg * effect.Strength.Multiplier(strength.Level()) + } + if weakness, ok := p.Effect(effect.Weakness); ok { + dmg -= dmg * effect.Weakness.Multiplier(weakness.Level()) + } + if s, ok := i.Enchantment(enchantment.Sharpness); ok { + dmg += enchantment.Sharpness.Addend(s.Level()) + for _, v := range p.tx.Viewers(living.Position()) { + v.ViewEntityAction(living, entity.EnchantedHitAction{}) + } + } + if critical { + dmg *= 1.5 + } + + n, vulnerable := living.Hurt(dmg, entity.AttackDamageSource{Attacker: p}) + i, left := p.HeldItems() + + if durable, ok := i.Item().(item.Durable); ok { + p.SetHeldItems(p.damageItem(i, durable.DurabilityInfo().AttackDurability), left) + } + + p.tx.PlaySound(entity.EyePosition(e), sound.Attack{Damage: !mgl64.FloatEqual(n, 0)}) + if !vulnerable { + return true + } + if critical { + for _, v := range p.tx.Viewers(living.Position()) { + v.ViewEntityAction(living, entity.CriticalHitAction{}) + } + } + + p.Exhaust(0.1) + + living.KnockBack(p.Position(), force, height) + + if f, ok := i.Enchantment(enchantment.FireAspect); ok { + if flammable, ok := living.(entity.Flammable); ok { + flammable.SetOnFire(enchantment.FireAspect.Duration(f.Level())) + } + } + return true +} + +// StartBreaking makes the player start breaking the block at the position passed using the item currently +// held in its main hand. +// If no block is present at the position, or if the block is out of range, StartBreaking will return +// immediately and the block will not be broken. StartBreaking will stop the breaking of any block that the +// player might be breaking before this method is called. +func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { + p.AbortBreaking() + if _, air := p.tx.Block(pos).(block.Air); air || !p.canReach(pos.Vec3Centre()) { + // The block was either out of range or air, so it can't be broken by the player. + return + } + if _, ok := p.tx.Block(pos.Side(face)).(block.Fire); ok { + ctx := event.C(p) + if p.Handler().HandleFireExtinguish(ctx, pos); ctx.Cancelled() { + // Resend the block because on client side that was extinguished + p.resendNearbyBlocks(pos, face) + return + } + + p.tx.SetBlock(pos.Side(face), nil, nil) + p.tx.PlaySound(pos.Vec3(), sound.FireExtinguish{}) + return + } + + held, _ := p.HeldItems() + if _, ok := held.Item().(item.Sword); ok && p.GameMode().CreativeInventory() { + // Can't break blocks with a sword in creative mode. + return + } + // Note: We intentionally store this regardless of whether the breaking proceeds, so that we + // can resend the block to the client when it tries to break the block regardless. + p.breakingPos = pos + + ctx := event.C(p) + if p.Handler().HandleStartBreak(ctx, pos); ctx.Cancelled() { + return + } + if punchable, ok := p.tx.Block(pos).(block.Punchable); ok { + punchable.Punch(pos, face, p.tx, p) + } + + p.breaking, p.breakingFace = true, face + p.SwingArm() + + if p.GameMode().CreativeInventory() { + return + } + p.lastBreakDuration = p.breakTime(pos) + for _, viewer := range p.viewers() { + viewer.ViewBlockAction(pos, block.StartCrackAction{BreakTime: p.lastBreakDuration}) + } +} + +// breakTime returns the time needed to break a block at the position passed, taking into account the item +// held, if the player is on the ground/underwater and if the player has any effects. +func (p *Player) breakTime(pos cube.Pos) time.Duration { + held, _ := p.HeldItems() + breakTime := block.BreakDuration(p.tx.Block(pos), held) + if !p.OnGround() { + breakTime *= 5 + } + if _, ok := p.Armour().Helmet().Enchantment(enchantment.AquaAffinity); p.insideOfWater() && !ok { + breakTime *= 5 + } + for _, e := range p.Effects() { + lvl := e.Level() + switch e.Type() { + case effect.Haste: + breakTime = time.Duration(float64(breakTime) * effect.Haste.Multiplier(lvl)) + case effect.MiningFatigue: + breakTime = time.Duration(float64(breakTime) * effect.MiningFatigue.Multiplier(lvl)) + case effect.ConduitPower: + breakTime = time.Duration(float64(breakTime) * effect.ConduitPower.Multiplier(lvl)) + } + } + return breakTime +} + +// FinishBreaking makes the player finish breaking the block it is currently breaking, or returns immediately +// if the player isn't breaking anything. +// FinishBreaking will stop the animation and break the block. +func (p *Player) FinishBreaking() { + if !p.breaking { + p.resendNearbyBlock(p.breakingPos) + return + } + p.AbortBreaking() + p.BreakBlock(p.breakingPos) +} + +// AbortBreaking makes the player stop breaking the block it is currently breaking, or returns immediately +// if the player isn't breaking anything. +// Unlike FinishBreaking, AbortBreaking does not stop the animation. +func (p *Player) AbortBreaking() { + if !p.breaking { + return + } + p.breaking, p.breakCounter = false, 0 + for _, viewer := range p.viewers() { + viewer.ViewBlockAction(p.breakingPos, block.StopCrackAction{}) + } +} + +// ContinueBreaking makes the player continue breaking the block it started breaking after a call to +// Player.StartBreaking(). +// The face passed is used to display particles on the side of the block broken. +func (p *Player) ContinueBreaking(face cube.Face) { + if !p.breaking { + return + } + pos := p.breakingPos + b := p.tx.Block(pos) + p.tx.AddParticle(pos.Vec3(), particle.PunchBlock{Block: b, Face: face}) + + if p.breakCounter++; p.breakCounter%5 == 0 { + p.SwingArm() + + // We send this sound only every so often. Vanilla doesn't send it every tick while breaking + // either. Every 5 ticks seems accurate. + p.tx.PlaySound(pos.Vec3(), sound.BlockBreaking{Block: b}) + } + if breakTime := p.breakTime(pos); breakTime != p.lastBreakDuration { + for _, viewer := range p.viewers() { + viewer.ViewBlockAction(pos, block.ContinueCrackAction{BreakTime: breakTime}) + } + p.lastBreakDuration = breakTime + } +} + +// PlaceBlock makes the player place the block passed at the position passed, granted it is within the range +// of the player. +// An item.UseContext may be passed to obtain information on if the block placement was successful. (SubCount will +// be incremented). Nil may also be passed for the context parameter. +func (p *Player) PlaceBlock(pos cube.Pos, b world.Block, ctx *item.UseContext) { + ignoreBBox := ctx != nil && ctx.IgnoreBBox + if !p.placeBlock(pos, b, ignoreBBox) { + return + } + if ctx != nil { + ctx.CountSub++ + } +} + +// placeBlock makes the player place the block passed at the position passed, granted it is within the range +// of the player. A bool is returned indicating if a block was placed successfully. +func (p *Player) placeBlock(pos cube.Pos, b world.Block, ignoreBBox bool) bool { + if !p.canReach(pos.Vec3Centre()) || !p.GameMode().AllowsEditing() { + p.resendNearbyBlocks(pos, cube.Faces()...) + return false + } + if obstructed, selfOnly := p.obstructedPos(pos, b); obstructed && !ignoreBBox { + if !selfOnly { + // Only resend blocks if there were other entities blocking the + // placement than the player itself. Resending blocks placed inside + // the player itself leads to synchronisation issues. + p.resendNearbyBlocks(pos, cube.Faces()...) + } + return false + } + + ctx := event.C(p) + if p.Handler().HandleBlockPlace(ctx, pos, b); ctx.Cancelled() { + p.resendNearbyBlocks(pos, cube.Faces()...) + return false + } + p.tx.SetBlock(pos, b, nil) + p.tx.PlaySound(pos.Vec3(), sound.BlockPlace{Block: b}) + p.SwingArm() + return true +} + +// obstructedPos checks if the position passed is obstructed if the block +// passed is attempted to be placed. The function returns true as the first +// bool if there is an entity in the way that could prevent the block from +// being placed. +// If the only entity preventing the block from being placed is the player +// itself, the second bool returned is true too. +func (p *Player) obstructedPos(pos cube.Pos, b world.Block) (obstructed, selfOnly bool) { + blockBoxes := b.Model().BBox(pos, p.tx) + for i, box := range blockBoxes { + blockBoxes[i] = box.Translate(pos.Vec3()) + } + + for e := range p.tx.EntitiesWithin(cube.Box(-3, -3, -3, 3, 3, 3).Translate(pos.Vec3())) { + t := e.H().Type() + switch t { + case entity.ItemType, entity.ArrowType, entity.ExperienceOrbType: + continue + default: + if cube.AnyIntersections(blockBoxes, t.BBox(e).Translate(e.Position()).Grow(-1e-4)) { + obstructed = true + if e.H() == p.handle { + continue + } + return true, false + } + } + } + return obstructed, true +} + +// BreakBlock makes the player break a block in the world at a position passed. If the player is unable to +// reach the block passed, the method returns immediately. +func (p *Player) BreakBlock(pos cube.Pos) { + b := p.tx.Block(pos) + if _, air := b.(block.Air); air { + // Don't do anything if the position broken is already air. + return + } + if !p.canReach(pos.Vec3Centre()) || !p.GameMode().AllowsEditing() { + p.resendNearbyBlocks(pos) + return + } + if _, breakable := b.(block.Breakable); !breakable && !p.GameMode().CreativeInventory() { + p.resendNearbyBlocks(pos) + return + } + held, _ := p.HeldItems() + drops := p.drops(held, b) + + xp := 0 + if breakable, ok := b.(block.Breakable); ok && !p.GameMode().CreativeInventory() { + if _, hasSilkTouch := held.Enchantment(enchantment.SilkTouch); !hasSilkTouch { + xp = breakable.BreakInfo().XPDrops.RandomValue() + } + } + + ctx := event.C(p) + if p.Handler().HandleBlockBreak(ctx, pos, &drops, &xp); ctx.Cancelled() { + p.resendNearbyBlocks(pos) + return + } + held, left := p.HeldItems() + + p.SwingArm() + p.tx.SetBlock(pos, nil, nil) + p.tx.AddParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) + + if breakable, ok := b.(block.Breakable); ok { + info := breakable.BreakInfo() + if info.BreakHandler != nil { + info.BreakHandler(pos, p.tx, p) + } + for _, orb := range entity.NewExperienceOrbs(pos.Vec3Centre(), xp) { + p.tx.AddEntity(orb) + } + } + for _, drop := range drops { + opts := world.EntitySpawnOpts{Position: pos.Vec3Centre(), Velocity: mgl64.Vec3{rand.Float64()*0.2 - 0.1, 0.2, rand.Float64()*0.2 - 0.1}} + p.tx.AddEntity(entity.NewItem(opts, drop)) + } + + p.Exhaust(0.005) + if block.BreaksInstantly(b, held) { + return + } + if durable, ok := held.Item().(item.Durable); ok { + p.SetHeldItems(p.damageItem(held, durable.DurabilityInfo().BreakDurability), left) + } +} + +// drops returns the drops that the player can get from the block passed using the item held. +func (p *Player) drops(held item.Stack, b world.Block) []item.Stack { + t, ok := held.Item().(item.Tool) + if !ok { + t = item.ToolNone{} + } + var drops []item.Stack + if breakable, ok := b.(block.Breakable); ok && !p.GameMode().CreativeInventory() { + if breakable.BreakInfo().Harvestable(t) { + drops = breakable.BreakInfo().Drops(t, held.Enchantments()) + } + } else if it, ok := b.(world.Item); ok && !p.GameMode().CreativeInventory() { + drops = []item.Stack{item.NewStack(it, 1)} + } + return drops +} + +// PickBlock makes the player pick a block in the world at a position passed. If the player is unable to +// pick the block, the method returns immediately. +func (p *Player) PickBlock(pos cube.Pos) { + if !p.canReach(pos.Vec3()) { + return + } + + b := p.tx.Block(pos) + + var pickedItem item.Stack + if pi, ok := b.(block.Pickable); ok { + pickedItem = pi.Pick() + } else if i, ok := b.(world.Item); ok { + it, _ := world.ItemByName(i.EncodeItem()) + pickedItem = item.NewStack(it, 1) + } else { + return + } + + slot, found := p.Inventory().First(pickedItem) + if !found && !p.GameMode().CreativeInventory() { + return + } + + ctx := event.C(p) + if p.Handler().HandleBlockPick(ctx, pos, b); ctx.Cancelled() { + return + } + _, offhand := p.HeldItems() + + if found { + if slot < 9 { + _ = p.SetHeldSlot(slot) + return + } + _ = p.Inventory().Swap(slot, int(*p.heldSlot)) + return + } + + firstEmpty, emptyFound := p.Inventory().FirstEmpty() + if !emptyFound { + p.SetHeldItems(pickedItem, offhand) + return + } + if firstEmpty < 9 { + _ = p.SetHeldSlot(firstEmpty) + _ = p.Inventory().SetItem(firstEmpty, pickedItem) + return + } + _ = p.Inventory().Swap(firstEmpty, int(*p.heldSlot)) + p.SetHeldItems(pickedItem, offhand) +} + +// Teleport teleports the player to a target position in the world. Unlike Move, it immediately changes the +// position of the player, rather than showing an animation. +func (p *Player) Teleport(pos mgl64.Vec3) { + ctx := event.C(p) + if p.Handler().HandleTeleport(ctx, pos); ctx.Cancelled() { + return + } + 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. +func (p *Player) teleport(pos mgl64.Vec3) { + for _, v := range p.viewers() { + v.ViewEntityTeleport(p, pos) + } + p.data.Pos = pos + p.data.Vel = mgl64.Vec3{} + p.ResetFallDistance() +} + +// Move moves the player from one position to another in the world, by adding the delta passed to the current +// position of the player. +// Move also rotates the player, adding deltaYaw and deltaPitch to the respective values. +func (p *Player) Move(deltaPos mgl64.Vec3, deltaYaw, deltaPitch float64) { + if p.Dead() || (deltaPos.ApproxEqual(mgl64.Vec3{}) && mgl64.FloatEqual(deltaYaw, 0) && mgl64.FloatEqual(deltaPitch, 0)) { + return + } + if p.immobile { + if mgl64.FloatEqual(deltaYaw, 0) && mgl64.FloatEqual(deltaPitch, 0) { + // If only the position was changed, don't continue with the movement when immobile. + return + } + // Still update rotation if it was changed. + deltaPos = mgl64.Vec3{} + } + var ( + pos = p.Position() + res, resRot = pos.Add(deltaPos), p.Rotation().Add(cube.Rotation{deltaYaw, deltaPitch}) + ) + ctx := event.C(p) + if p.Handler().HandleMove(ctx, res, resRot); ctx.Cancelled() { + if p.session() != session.Nop && pos.ApproxEqual(p.Position()) { + // The position of the player was changed and the event cancelled. This means we still need to notify the + // player of this movement change. + p.teleport(pos) + } + return + } + for _, v := range p.viewers() { + v.ViewEntityMovement(p, res, resRot, p.OnGround()) + } + + p.data.Pos = res + p.data.Rot = resRot + if deltaPos.Len() <= 3 { + // Only update velocity if the player is not moving too fast to prevent potential OOMs. + p.data.Vel = deltaPos + p.checkBlockCollisions(deltaPos) + } + + horizontalVel := deltaPos + horizontalVel[1] = 0 + if p.Gliding() { + if deltaPos.Y() >= -0.5 { + p.fallDistance = 1.0 + } + if p.collidedHorizontally { + if force := horizontalVel.Len()*10.0 - 3.0; force > 0.0 { + p.tx.PlaySound(p.Position(), sound.Fall{Distance: force}) + p.Hurt(force, entity.GlideDamageSource{}) + } + } + } + + _, submergedBefore := p.tx.Liquid(cube.PosFromVec3(pos.Add(mgl64.Vec3{0, p.EyeHeight()}))) + _, submergedAfter := p.tx.Liquid(cube.PosFromVec3(res.Add(mgl64.Vec3{0, p.EyeHeight()}))) + if submergedBefore != submergedAfter { + // Player wasn't either breathing before and no longer isn't, or wasn't breathing before and now is, + // so send the updated metadata. + p.session().ViewEntityState(p) + } + + p.onGround = p.checkOnGround(deltaPos) + p.updateFallState(deltaPos[1]) + + if p.Swimming() { + p.Exhaust(0.01 * horizontalVel.Len()) + } else if p.Sprinting() { + p.Exhaust(0.1 * horizontalVel.Len()) + } +} + +// Position returns the current position of the player. It may be changed as the player moves or is moved +// around the world. +func (p *Player) Position() mgl64.Vec3 { + return p.data.Pos +} + +// Velocity returns the players current velocity. If there is an attached session, this will be empty. +func (p *Player) Velocity() mgl64.Vec3 { + return p.data.Vel +} + +// SetVelocity updates the player's velocity. If there is an attached session, this will just send +// the velocity to the player session for the player to update. +func (p *Player) SetVelocity(velocity mgl64.Vec3) { + if p.session() == session.Nop { + p.data.Vel = velocity + return + } + for _, v := range p.viewers() { + v.ViewEntityVelocity(p, velocity) + } +} + +// Rotation returns the yaw and pitch of the player in degrees. Yaw is horizontal rotation (rotation around the +// vertical axis, 0 when facing forward), pitch is vertical rotation (rotation around the horizontal axis, also 0 +// when facing forward). +func (p *Player) Rotation() cube.Rotation { + return p.data.Rot +} + +// Collect makes the player collect the item stack passed, adding it to the inventory. The amount of items that could +// be added is returned. +func (p *Player) Collect(s item.Stack) (int, bool) { + if p.Dead() || !p.GameMode().AllowsInteraction() { + return 0, false + } + ctx := event.C(p) + if p.Handler().HandleItemPickup(ctx, &s); ctx.Cancelled() { + return 0, false + } + var added int + if _, offHand := p.HeldItems(); !offHand.Empty() && offHand.Comparable(s) { + added, _ = p.offHand.AddItem(s) + } + if s.Count() != added { + n, _ := p.Inventory().AddItem(s.Grow(-added)) + added += n + } + return added, true +} + +// Experience returns the amount of experience the player has. +func (p *Player) Experience() int { + return p.experience.Experience() +} + +// EnchantmentSeed is a seed used to calculate random enchantments with enchantment tables. +func (p *Player) EnchantmentSeed() int64 { + return p.enchantSeed +} + +// ResetEnchantmentSeed resets the enchantment seed to a new random value. +func (p *Player) ResetEnchantmentSeed() { + p.enchantSeed = rand.Int64() +} + +// AddExperience adds experience to the player. +func (p *Player) AddExperience(amount int) int { + ctx := event.C(p) + if p.Handler().HandleExperienceGain(ctx, &amount); ctx.Cancelled() { + return 0 + } + before := p.experience.Level() + level, _ := p.experience.Add(amount) + if level/5 > before/5 { + p.PlaySound(sound.LevelUp{}) + } else if amount > 0 { + p.PlaySound(sound.Experience{}) + } + p.session().SendExperience(p.ExperienceLevel(), p.ExperienceProgress()) + return amount +} + +// RemoveExperience removes experience from the player. +func (p *Player) RemoveExperience(amount int) { + p.experience.Add(-amount) + p.session().SendExperience(p.ExperienceLevel(), p.ExperienceProgress()) +} + +// ExperienceLevel returns the experience level of the player. +func (p *Player) ExperienceLevel() int { + return p.experience.Level() +} + +// SetExperienceLevel sets the experience level of the player. The level must have a value between 0 and 2,147,483,647, +// otherwise the method panics. +func (p *Player) SetExperienceLevel(level int) { + p.experience.SetLevel(level) + p.session().SendExperience(p.ExperienceLevel(), p.ExperienceProgress()) +} + +// ExperienceProgress returns the experience progress of the player. +func (p *Player) ExperienceProgress() float64 { + return p.experience.Progress() +} + +// SetExperienceProgress sets the experience progress of the player. The progress must have a value between 0.0 and 1.0, otherwise +// the method panics. +func (p *Player) SetExperienceProgress(progress float64) { + p.experience.SetProgress(progress) + p.session().SendExperience(p.ExperienceLevel(), p.ExperienceProgress()) +} + +// CanCollectExperience checks if the player can collect experience, which is true if the player is not dead, +// is in a game mode that allows interaction and if 100ms have passed since the last experience collection. +func (p *Player) CanCollectExperience() bool { + if p.Dead() || !p.GameMode().AllowsInteraction() { + return false + } + if last := p.lastXPPickup; last != nil && time.Since(*last) < time.Millisecond*100 { + return false + } + return true +} + +// CollectExperience makes the player collect the experience points passed, adding it to the experience manager. A bool +// is returned indicating whether the player was able to collect the experience or not, due to the 100ms delay between +// experience collection or if the player was dead or in a game mode that doesn't allow collection. +func (p *Player) CollectExperience(value int) bool { + if !p.CanCollectExperience() { + return false + } + value = p.mendItems(value) + now := time.Now() + p.lastXPPickup = &now + if value > 0 { + return p.AddExperience(value) > 0 + } + + p.PlaySound(sound.Experience{}) + return true +} + +// mendItems handles the mending enchantment when collecting experience, it then returns the leftover experience. +func (p *Player) mendItems(xp int) int { + mendingItems := make([]item.Stack, 0, 6) + held, offHand := p.HeldItems() + if _, ok := offHand.Enchantment(enchantment.Mending); ok && offHand.Durability() < offHand.MaxDurability() { + mendingItems = append(mendingItems, offHand) + } + if _, ok := held.Enchantment(enchantment.Mending); ok && held.Durability() < held.MaxDurability() { + mendingItems = append(mendingItems, held) + } + for _, i := range p.Armour().Items() { + if i.Durability() == i.MaxDurability() { + continue + } + if _, ok := i.Enchantment(enchantment.Mending); ok { + mendingItems = append(mendingItems, i) + } + } + length := len(mendingItems) + if length == 0 { + return xp + } + foundItem := mendingItems[rand.IntN(length)] + repairAmount := math.Min(float64(foundItem.MaxDurability()-foundItem.Durability()), float64(xp*2)) + repairedItem := foundItem.WithDurability(foundItem.Durability() + int(repairAmount)) + if repairAmount >= 2 { + // mending removes 1 experience point for every 2 durability points. If the repaired durability is less than 2, + // then no experience is removed. + xp -= int(math.Ceil(repairAmount / 2)) + } + if offHand.Equal(foundItem) { + p.SetHeldItems(held, repairedItem) + } else if held.Equal(foundItem) { + p.SetHeldItems(repairedItem, offHand) + } else if slot, ok := p.Armour().Inventory().First(foundItem); ok { + _ = p.Armour().Inventory().SetItem(slot, repairedItem) + } + return xp +} + +// Drop makes the player drop the item.Stack passed as an entity.Item, so that it may be picked up from the +// ground. +// The dropped item entity has a pickup delay of 2 seconds. +// The number of items that was dropped in the end is returned. It is generally the count of the stack passed +// or 0 if dropping the item.Stack was cancelled. +func (p *Player) Drop(s item.Stack) int { + ctx := event.C(p) + if p.Handler().HandleItemDrop(ctx, s); ctx.Cancelled() { + return 0 + } + opts := world.EntitySpawnOpts{Position: p.Position().Add(mgl64.Vec3{0, 1.4}), Velocity: p.Rotation().Vec3().Mul(0.4)} + p.tx.AddEntity(entity.NewItemPickupDelay(opts, s, time.Second*2)) + return s.Count() +} + +// OpenBlockContainer opens a block container, such as a chest, at the position passed. If no container was +// present at that location, OpenBlockContainer does nothing. +// OpenBlockContainer will also do nothing if the player has no session connected to it. +func (p *Player) OpenBlockContainer(pos cube.Pos, tx *world.Tx) { + if p.session() != session.Nop { + p.session().OpenBlockContainer(pos, tx) + } +} + +// HideEntity hides a world.Entity from the Player so that it can under no circumstance see it. Hidden entities can be +// made visible again through a call to ShowEntity. +func (p *Player) HideEntity(e world.Entity) { + if p.session() != session.Nop && p.H() != e.H() { + p.session().StopShowingEntity(e) + } +} + +// ShowEntity shows a world.Entity previously hidden from the Player using HideEntity. It does nothing if the entity +// wasn't currently hidden. +func (p *Player) ShowEntity(e world.Entity) { + if p.session() != session.Nop { + p.session().StartShowingEntity(e) + } +} + +// Latency returns a rolling average of latency between the sending and the receiving end of the connection of +// the player. +// The latency returned is updated continuously and is half the round trip time (RTT). +// If the Player does not have a session associated with it, Latency returns 0. +func (p *Player) Latency() time.Duration { + if p.session() == session.Nop { + return 0 + } + return p.session().Latency() +} + +// Tick ticks the entity, performing actions such as checking if the player is still breaking a block. +func (p *Player) Tick(tx *world.Tx, current int64) { + if p.Dead() { + return + } + if _, ok := p.tx.Liquid(cube.PosFromVec3(p.Position())); !ok { + p.StopSwimming() + if _, ok := p.Armour().Helmet().Item().(item.TurtleShell); ok { + p.AddEffect(effect.New(effect.WaterBreathing, 1, time.Second*10).WithoutParticles()) + } + } + + if _, ok := p.Armour().Chestplate().Item().(item.Elytra); ok && p.Gliding() { + if p.glideTicks += 1; p.glideTicks%20 == 0 { + d := p.damageItem(p.Armour().Chestplate(), 1) + p.armour.SetChestplate(d) + if d.Durability() < 2 { + p.StopGliding() + } + } + } + + p.checkBlockCollisions(p.data.Vel) + p.onGround = p.checkOnGround(mgl64.Vec3{}) + p.checkEntitySteppers() + + p.effects.Tick(p, p.tx) + + p.tickFood() + p.tickAirSupply() + + if p.Position()[1] < float64(p.tx.Range()[0]) { + p.Hurt(4, entity.VoidDamageSource{}) + } + if p.insideOfSolid() { + p.Hurt(1, entity.SuffocationDamageSource{}) + } + + if p.OnFireDuration() > 0 { + p.fireTicks -= 1 + if !p.GameMode().AllowsTakingDamage() || p.OnFireDuration() <= 0 || p.tx.RainingAt(cube.PosFromVec3(p.Position())) { + p.Extinguish() + } + if p.OnFireDuration()%time.Second == 0 { + p.Hurt(1, block.FireDamageSource{}) + } + } + + held, _ := p.HeldItems() + if current%4 == 0 && p.usingItem { + if _, ok := held.Item().(item.Consumable); ok { + // Eating particles seem to happen roughly every 4 ticks. + for _, v := range p.viewers() { + v.ViewEntityAction(p, entity.EatAction{}) + } + } + } + + if p.usingItem { + if c, ok := held.Item().(item.Chargeable); ok { + c.ContinueCharge(p, tx, p.useContext(), p.useDuration()) + } + } + if p.breaking { + p.ContinueBreaking(p.breakingFace) + } + + for it, ti := range p.cooldowns { + if time.Now().After(ti) { + delete(p.cooldowns, it) + } + } + + p.session().SendDebugShapes(tx.World().Dimension()) + p.session().SendHudUpdates() + + if p.prevWorld != tx.World() && p.prevWorld != nil { + p.Handler().HandleChangeWorld(p, p.prevWorld, tx.World()) + } + p.prevWorld = tx.World() + + if p.session() == session.Nop && !p.Immobile() { + m := p.mc.TickMovement(p, p.Position(), p.Velocity(), p.Rotation(), p.tx) + m.Send() + + p.data.Vel = m.Velocity() + p.Move(m.Position().Sub(p.Position()), 0, 0) + } else { + p.data.Vel = mgl64.Vec3{} + } +} + +// ViewLayer returns the ViewLayer attached to the player's session. +func (p *Player) ViewLayer() *world.ViewLayer { + return p.session().ViewLayer() +} + +// ViewNameTag overrides the public name tag of the entity for this player. +func (p *Player) ViewNameTag(entity world.Entity, nameTag string) { + p.session().ViewNameTag(entity, nameTag) +} + +// ViewPublicNameTag removes the name tag override of the entity for this player. +func (p *Player) ViewPublicNameTag(entity world.Entity) { + p.session().ViewPublicNameTag(entity) +} + +// ViewScoreTag overrides the public score tag of the entity for this player. +func (p *Player) ViewScoreTag(entity world.Entity, scoreTag string) { + p.session().ViewScoreTag(entity, scoreTag) +} + +// ViewPublicScoreTag removes the score tag override of the entity for this player. +func (p *Player) ViewPublicScoreTag(entity world.Entity) { + p.session().ViewPublicScoreTag(entity) +} + +// ViewVisibility overrides the public visibility of the entity for this player. +func (p *Player) ViewVisibility(entity world.Entity, level world.VisibilityLevel) { + p.session().ViewVisibility(entity, level) +} + +// RemoveViewLayer removes all view-layer overrides of the entity for this player. +func (p *Player) RemoveViewLayer(entity world.Entity) { + p.session().RemoveViewLayer(entity) +} + +// tickAirSupply tick's the player's air supply, consuming it when underwater, and replenishing it when out of water. +func (p *Player) tickAirSupply() { + if !p.canBreathe() { + if r, ok := p.Armour().Helmet().Enchantment(enchantment.Respiration); ok && rand.Float64() <= enchantment.Respiration.Chance(r.Level()) { + // respiration grants a chance to avoid drowning damage every tick. + return + } + if p.airSupplyTicks -= 1; p.airSupplyTicks <= -20 { + p.airSupplyTicks = 0 + p.Hurt(2, entity.DrowningDamageSource{}) + } + p.breathing = false + p.updateState() + } else if !p.breathing && p.airSupplyTicks < p.maxAirSupplyTicks { + p.airSupplyTicks = min(p.airSupplyTicks+5, p.maxAirSupplyTicks) + p.breathing = p.airSupplyTicks == p.maxAirSupplyTicks + p.updateState() + } +} + +// tickFood ticks food related functionality, such as the depletion of the food bar and regeneration if it +// is full enough. +func (p *Player) tickFood() { + if p.hunger.foodTick%10 == 0 && (p.hunger.canQuicklyRegenerate() || p.tx.World().Difficulty().FoodRegenerates()) { + if p.tx.World().Difficulty().FoodRegenerates() { + p.AddFood(1) + } + if p.hunger.foodTick%20 == 0 { + p.regenerate(true) + } + } + if p.hunger.foodTick == 1 { + if p.hunger.canRegenerate() { + p.regenerate(false) + } else if p.hunger.starving() { + p.starve() + } + } + + if !p.hunger.canSprint() { + p.StopSprinting() + } + + p.hunger.foodTick++ + if p.hunger.foodTick > 80 { + p.hunger.foodTick = 1 + } +} + +// regenerate attempts to regenerate half a heart of health, typically caused by a full food bar. +func (p *Player) regenerate(exhaust bool) { + if p.Health() == p.MaxHealth() { + return + } + p.Heal(1, entity.FoodHealingSource{}) + if exhaust { + p.Exhaust(6) + } +} + +// starve deals starvation damage to the player if the difficult allows it. In peaceful mode, no damage will +// ever be dealt. In easy mode, damage will only be dealt if the player has more than 10 health. In normal +// mode, damage will only be dealt if the player has more than 2 health and in hard mode, damage will always +// be dealt. +func (p *Player) starve() { + if p.Health() > p.tx.World().Difficulty().StarvationHealthLimit() { + p.Hurt(1, StarvationDamageSource{}) + } +} + +// AirSupply returns the player's remaining air supply. +func (p *Player) AirSupply() time.Duration { + return time.Duration(p.airSupplyTicks) * time.Second / 20 +} + +// SetAirSupply sets the player's remaining air supply. +func (p *Player) SetAirSupply(duration time.Duration) { + p.airSupplyTicks = int(duration.Milliseconds() / 50) + p.updateState() +} + +// MaxAirSupply returns the player's maximum air supply. +func (p *Player) MaxAirSupply() time.Duration { + return time.Duration(p.maxAirSupplyTicks) * time.Second / 20 +} + +// SetMaxAirSupply sets the player's maximum air supply. +func (p *Player) SetMaxAirSupply(duration time.Duration) { + p.maxAirSupplyTicks = int(duration.Milliseconds() / 50) + p.updateState() +} + +// canBreathe returns true if the player can currently breathe. +func (p *Player) canBreathe() bool { + canTakeDamage := p.GameMode().AllowsTakingDamage() + _, waterBreathing := p.effects.Effect(effect.WaterBreathing) + _, conduitPower := p.effects.Effect(effect.ConduitPower) + return !canTakeDamage || waterBreathing || conduitPower || (!p.insideOfWater() && !p.insideOfSolid()) +} + +// breathingDistanceBelowEyes is the lowest distance the player can be in water and still be able to breathe based on +// the player's eye height. +const breathingDistanceBelowEyes = 0.11111111 + +// insideOfWater returns true if the player is currently underwater. +func (p *Player) insideOfWater() bool { + pos := cube.PosFromVec3(entity.EyePosition(p)) + if l, ok := p.tx.Liquid(pos); ok { + if _, ok := l.(block.Water); ok { + d := float64(l.SpreadDecay()) + 1 + if l.LiquidFalling() { + d = 1 + } + return p.Position().Y() < (pos.Side(cube.FaceUp).Vec3().Y())-(d/9-breathingDistanceBelowEyes) + } + } + return false +} + +// insideOfSolid returns true if the player is inside a solid block. +func (p *Player) insideOfSolid() bool { + pos := cube.PosFromVec3(entity.EyePosition(p)) + b, box := p.tx.Block(pos), p.handle.Type().BBox(p).Translate(p.Position()) + + _, solid := b.Model().(model.Solid) + if !solid { + // Not solid. + return false + } + d, diffuses := b.(block.LightDiffuser) + if diffuses && d.LightDiffusionLevel() == 0 { + // Transparent. + return false + } + for _, blockBox := range b.Model().BBox(pos, p.tx) { + if blockBox.Translate(pos.Vec3()).IntersectsWith(box) { + return true + } + } + return false +} + +// checkCollisions checks the player's block collisions. +func (p *Player) checkBlockCollisions(vel mgl64.Vec3) { + entityBBox := Type.BBox(p).Translate(p.Position()) + deltaX, deltaY, deltaZ := vel[0], vel[1], vel[2] + + p.checkEntityInsiders(entityBBox) + + grown := entityBBox.Extend(vel).Grow(0.25) + low, high := grown.Min(), grown.Max() + minX, minY, minZ := int(math.Floor(low[0])), int(math.Floor(low[1])), int(math.Floor(low[2])) + maxX, maxY, maxZ := int(math.Ceil(high[0])), int(math.Ceil(high[1])), int(math.Ceil(high[2])) + + // A prediction of one BBox per block, plus an additional 2, in case + blocks := make([]cube.BBox, 0, (maxX-minX)*(maxY-minY)*(maxZ-minZ)+2) + for y := minY; y <= maxY; y++ { + for x := minX; x <= maxX; x++ { + for z := minZ; z <= maxZ; z++ { + pos := cube.Pos{x, y, z} + boxes := p.tx.Block(pos).Model().BBox(pos, p.tx) + for _, box := range boxes { + blocks = append(blocks, box.Translate(pos.Vec3())) + } + } + } + } + + // epsilon is the epsilon used for thresholds for change used for change in position and velocity. + const epsilon = 0.001 + + if !mgl64.FloatEqualThreshold(deltaY, 0, epsilon) { + // First we move the entity BBox on the Y axis. + for _, blockBBox := range blocks { + deltaY = entityBBox.YOffset(blockBBox, deltaY) + } + entityBBox = entityBBox.Translate(mgl64.Vec3{0, deltaY}) + } + if !mgl64.FloatEqualThreshold(deltaX, 0, epsilon) { + // Then on the X axis. + for _, blockBBox := range blocks { + deltaX = entityBBox.XOffset(blockBBox, deltaX) + } + entityBBox = entityBBox.Translate(mgl64.Vec3{deltaX}) + } + if !mgl64.FloatEqualThreshold(deltaZ, 0, epsilon) { + // And finally on the Z axis. + for _, blockBBox := range blocks { + deltaZ = entityBBox.ZOffset(blockBBox, deltaZ) + } + } + + p.collidedHorizontally = !mgl64.FloatEqual(deltaX, vel[0]) || !mgl64.FloatEqual(deltaZ, vel[2]) + p.collidedVertically = !mgl64.FloatEqual(deltaY, vel[1]) +} + +// checkEntityInsiders checks if the player is colliding with any EntityInsider blocks. +func (p *Player) checkEntityInsiders(entityBBox cube.BBox) { + box := entityBBox.Grow(-0.0001) + low, high := cube.PosFromVec3(box.Min()), cube.PosFromVec3(box.Max()) + + for y := low[1]; y <= high[1]; y++ { + for x := low[0]; x <= high[0]; x++ { + for z := low[2]; z <= high[2]; z++ { + blockPos := cube.Pos{x, y, z} + b := p.tx.Block(blockPos) + if collide, ok := b.(block.EntityInsider); ok { + collide.EntityInside(blockPos, p.tx, p) + if _, liquid := b.(world.Liquid); liquid { + continue + } + } + + if l, ok := p.tx.Liquid(blockPos); ok { + if collide, ok := l.(block.EntityInsider); ok { + collide.EntityInside(blockPos, p.tx, p) + } + } + } + } + } +} + +// checkEntitySteppers checks if the player is standing on any EntityStepper blocks. +func (p *Player) checkEntitySteppers() { + if !p.OnGround() { + return + } + box := Type.BBox(p).Translate(p.Position()).Grow(-0.0001) + low, high := cube.PosFromVec3(box.Min()), cube.PosFromVec3(box.Max()) + y := int(math.Floor(box.Min()[1] - 0.0001)) + + for x := low[0]; x <= high[0]; x++ { + for z := low[2]; z <= high[2]; z++ { + pos := cube.Pos{x, y, z} + if stepper, ok := p.tx.Block(pos).(block.EntityStepper); ok { + stepper.EntityStepOn(pos, p.tx, p) + return + } + } + } +} + +// checkOnGround checks if the player is currently considered to be on the ground. +func (p *Player) checkOnGround(deltaPos mgl64.Vec3) bool { + box := Type.BBox(p).Translate(p.Position()).Extend(mgl64.Vec3{0, -0.05}).Extend(deltaPos.Mul(-1.0)) + b := box.Grow(1) + + epsilon := mgl64.Vec3{mgl64.Epsilon, mgl64.Epsilon, mgl64.Epsilon} + low, high := cube.PosFromVec3(b.Min().Add(epsilon)), cube.PosFromVec3(b.Max().Sub(epsilon)) + for x := low[0]; x <= high[0]; x++ { + for z := low[2]; z <= high[2]; z++ { + for y := low[1]; y < high[1]; y++ { + pos := cube.Pos{x, y, z} + for _, bb := range p.tx.Block(pos).Model().BBox(pos, p.tx) { + if bb.Translate(pos.Vec3()).IntersectsWith(box) { + return true + } + } + } + } + } + return false +} + +// Scale returns the scale modifier of the Player. The default value for a normal scale is 1. A scale of 0 +// will make the Player completely invisible. +func (p *Player) Scale() float64 { + return p.scale +} + +// SetScale changes the scale modifier of the Player. The default value for a normal scale is 1. A scale of 0 +// will make the Player completely invisible. +func (p *Player) SetScale(s float64) { + p.scale = s + p.updateState() +} + +// OnGround checks if the player is considered to be on the ground. +func (p *Player) OnGround() bool { + if p.session() == session.Nop { + return p.mc.OnGround() + } + return p.onGround +} + +// EyeHeight returns the eye height of the player: 1.62, 1.26 if player is sneaking or 0.52 if the player is +// swimming, gliding or crawling. +func (p *Player) EyeHeight() float64 { + switch { + case p.swimming || p.crawling || p.gliding: + return 0.4 + case p.sneaking: + return 1.27 + default: + return 1.62 + } +} + +// TorsoHeight returns the player's torso offset above its feet. This is 0.1 blocks below the eye height. +func (p *Player) TorsoHeight() float64 { + return p.EyeHeight() - 0.1 +} + +// PlaySound plays a world.Sound that only this Player can hear. Unlike World.PlaySound, it is not broadcast +// to players around it. +func (p *Player) PlaySound(sound world.Sound) { + p.session().PlaySound(sound, entity.EyePosition(p)) +} + +// ShowParticle shows a particle that only this Player can see. Unlike World.AddParticle, it is not broadcast +// to players around it. +func (p *Player) ShowParticle(pos mgl64.Vec3, particle world.Particle) { + p.session().ViewParticle(pos, particle) +} + +// OpenSign makes the player open the sign at the cube.Pos passed, with the specific side provided. The client will not +// show the interface if it is not aware of a sign at the position. +func (p *Player) OpenSign(pos cube.Pos, frontSide bool) { + p.session().OpenSign(pos, frontSide) +} + +// EditSign edits the sign at the cube.Pos passed and writes the text passed to a sign at that position. If no sign is +// present, an error is returned. +func (p *Player) EditSign(pos cube.Pos, frontText, backText string) error { + sign, ok := p.tx.Block(pos).(block.Sign) + if !ok { + return fmt.Errorf("edit sign: no sign at position %v", pos) + } + + if sign.Waxed { + return nil + } else if frontText == sign.Front.Text && backText == sign.Back.Text { + return nil + } + + ctx := event.C(p) + if frontText != sign.Front.Text { + if p.Handler().HandleSignEdit(ctx, pos, true, sign.Front.Text, frontText); ctx.Cancelled() { + p.resendNearbyBlock(pos) + return nil + } + sign.Front.Text = frontText + sign.Front.Owner = p.XUID() + } else { + if p.Handler().HandleSignEdit(ctx, pos, false, sign.Back.Text, backText); ctx.Cancelled() { + p.resendNearbyBlock(pos) + return nil + } + sign.Back.Text = backText + sign.Back.Owner = p.XUID() + } + p.tx.SetBlock(pos, sign, nil) + return nil +} + +// TurnLecternPage edits the lectern at the cube.Pos passed by turning the page to the page passed. If no lectern is +// present, an error is returned. +func (p *Player) TurnLecternPage(pos cube.Pos, page int) error { + lectern, ok := p.tx.Block(pos).(block.Lectern) + if !ok { + return fmt.Errorf("edit lectern: no lectern at position %v", pos) + } + + ctx := event.C(p) + if p.Handler().HandleLecternPageTurn(ctx, pos, lectern.Page, &page); ctx.Cancelled() { + return nil + } + + lectern.Page = page + p.tx.SetBlock(pos, lectern, nil) + return nil +} + +// updateState updates the state of the player to all viewers of the player. +func (p *Player) updateState() { + for _, v := range p.viewers() { + v.ViewEntityState(p) + } +} + +// Breathing checks if the player is currently able to breathe. If it's underwater and the player does not +// have the water breathing or conduit power effect, this returns false. +// If the player is in creative or spectator mode, Breathing always returns true. +func (p *Player) Breathing() bool { + _, breathing := p.Effect(effect.WaterBreathing) + _, conduitPower := p.Effect(effect.ConduitPower) + _, submerged := p.tx.Liquid(cube.PosFromVec3(entity.EyePosition(p))) + return !p.GameMode().AllowsTakingDamage() || !submerged || breathing || conduitPower +} + +// SwingArm makes the player swing its arm. +func (p *Player) SwingArm() { + if p.Dead() { + return + } + for _, v := range p.viewers() { + v.ViewEntityAction(p, entity.SwingArmAction{}) + } +} + +// PunchAir makes the player punch the air and plays the sound for attacking with no damage. +func (p *Player) PunchAir() { + if p.Dead() { + return + } + ctx := event.C(p) + if p.Handler().HandlePunchAir(ctx); ctx.Cancelled() { + return + } + p.SwingArm() + p.tx.PlaySound(p.Position(), sound.Attack{}) +} + +// UpdateDiagnostics updates the diagnostics of the player. +func (p *Player) UpdateDiagnostics(d session.Diagnostics) { + p.Handler().HandleDiagnostics(p, d) +} + +// ShowHudElement shows a HUD element to the player if it is not already shown. +func (p *Player) ShowHudElement(e hud.Element) { + p.session().ShowHudElement(e) +} + +// HideHudElement hides a HUD element from the player if it is not already hidden. +func (p *Player) HideHudElement(e hud.Element) { + p.session().HideHudElement(e) +} + +// HudElementHidden checks if a HUD element is currently hidden from the player. +func (p *Player) HudElementHidden(e hud.Element) bool { + return p.session().HudElementHidden(e) +} + +// AddDebugShape adds a debug shape to be rendered to the player. If the shape already exists, it will be +// updated with the new information. +func (p *Player) AddDebugShape(shape debug.Shape) { + p.session().AddDebugShape(shape) +} + +// RemoveDebugShape removes a debug shape from the player by its unique identifier. +func (p *Player) RemoveDebugShape(shape debug.Shape) { + p.session().RemoveDebugShape(shape) +} + +// VisibleDebugShapes returns a slice of all debug shapes that are currently being shown to the player. +func (p *Player) VisibleDebugShapes() []debug.Shape { + return p.session().VisibleDebugShapes() +} + +// RemoveAllDebugShapes removes all rendered debug shapes from the player, as well as any shapes that have +// not yet been rendered. +func (p *Player) RemoveAllDebugShapes() { + p.session().RemoveAllDebugShapes() +} + +// damageItem damages the item stack passed with the damage passed and returns the new stack. If the item +// broke, a breaking sound is played. +// If the player is not survival, the original stack is returned. +func (p *Player) damageItem(s item.Stack, d int) item.Stack { + if p.GameMode().CreativeInventory() || d == 0 || s.MaxDurability() == -1 { + return s + } + ctx := event.C(p) + if p.Handler().HandleItemDamage(ctx, s, &d); ctx.Cancelled() || d <= 0 { + return s + } + if e, ok := s.Enchantment(enchantment.Unbreaking); ok { + d = enchantment.Unbreaking.Reduce(s.Item(), e.Level(), d) + } + if s = s.Damage(d); s.Empty() { + p.tx.PlaySound(p.Position(), sound.ItemBreak{}) + } + return s +} + +// subtractItem subtracts d from the count of the item stack passed and returns it, if the player is in +// survival or adventure mode. +func (p *Player) subtractItem(s item.Stack, d int) item.Stack { + if !p.GameMode().CreativeInventory() && d != 0 { + return s.Grow(-d) + } + return s +} + +// addNewItem adds the new item of the context passed to the inventory. +func (p *Player) addNewItem(ctx *item.UseContext) { + if (ctx.NewItemSurvivalOnly && p.GameMode().CreativeInventory()) || ctx.NewItem.Empty() { + return + } + held, left := p.HeldItems() + if held.Empty() { + p.SetHeldItems(ctx.NewItem, left) + return + } + n, err := p.Inventory().AddItem(ctx.NewItem) + if err != nil { + // Not all items could be added to the inventory, so drop the rest. + p.Drop(ctx.NewItem.Grow(ctx.NewItem.Count() - n)) + } + if p.Dead() { + p.dropItems() + } +} + +// canReach checks if a player can reach a position with its current range. The range depends on if the player +// is either survival or creative mode. +func (p *Player) canReach(pos mgl64.Vec3) bool { + dist := entity.EyePosition(p).Sub(pos).Len() + return !p.Dead() && p.GameMode().AllowsInteraction() && + (dist <= 8.0 || (dist <= 14.0 && p.GameMode().CreativeInventory())) +} + +// Disconnect closes the player and removes it from the world. +// Disconnect, unlike Close, allows a custom message to be passed to show to the player when it is +// disconnected. The message is formatted following the rules of fmt.Sprintln without a newline at the end. +func (p *Player) Disconnect(msg ...any) { + p.once.Do(func() { + p.close(format(msg)) + }) +} + +// Close closes the player and removes it from the world. +// Close disconnects the player with a 'Connection closed.' message. Disconnect should be used to disconnect a +// player with a custom message. +func (p *Player) Close() error { + p.once.Do(func() { + p.close("Connection closed.") + }) + return nil +} + +// close closes the player without disconnecting it. It executes code shared by both the closing and the +// disconnecting of players. +func (p *Player) close(msg string) { + // If the player is being disconnected while they are dead, we respawn the player + // so that the player logic works correctly the next time they join. + if p.Dead() && p.session() != nil { + p.respawn(func(np *Player) { + np.quit(msg) + }) + return + } + p.quit(msg) +} + +func (p *Player) quit(msg string) { + p.h.HandleQuit(p) + p.h = NopHandler{} + + if s := p.s; s != nil { + s.Disconnect(msg) + s.CloseConnection() + return + } + // Only remove the player from the world if it's not attached to a session. If it is attached to a session, the + // session will remove the player once ready. + p.tx.RemoveEntity(p) + _ = p.handle.Close() +} + +// Data returns the player data that needs to be saved. This is used when the player +// gets disconnected and the player provider needs to save the data. +func (p *Player) Data() Config { + p.hunger.mu.RLock() + defer p.hunger.mu.RUnlock() + return Config{ + Session: p.s, + Skin: p.skin, + XUID: p.xuid, + UUID: p.UUID(), + Name: p.nameTag, + Locale: p.locale, + GameMode: p.gameMode, + Position: p.Position(), + Rotation: p.Rotation(), + Velocity: p.Velocity(), + Health: p.Health(), + MaxHealth: p.MaxHealth(), + FoodTick: p.hunger.foodTick, + Food: p.hunger.foodLevel, + Exhaustion: p.hunger.exhaustionLevel, + Saturation: p.hunger.saturationLevel, + AirSupply: p.airSupplyTicks, + MaxAirSupply: p.maxAirSupplyTicks, + EnchantmentSeed: p.enchantSeed, + Experience: p.experience.Experience(), + HeldSlot: int(*p.heldSlot), + Inventory: p.inv, + OffHand: p.offHand, + Armour: p.armour, + EnderChestInventory: p.enderChest, + FireTicks: p.fireTicks, + FallDistance: p.fallDistance, + Effects: p.Effects(), + } +} + +// session returns the network session of the player. If it has one, it is returned. If not, a no-op session +// is returned. +func (p *Player) session() *session.Session { + if s := p.s; s != nil { + return s + } + return session.Nop +} + +// useContext returns an item.UseContext initialised for a Player. +func (p *Player) useContext() *item.UseContext { + call := func(ctx *inventory.Context, slot int, it item.Stack, f func(ctx *inventory.Context, slot int, it item.Stack)) error { + if ctx.Cancelled() { + return fmt.Errorf("action was cancelled") + } + f(ctx, slot, it) + if ctx.Cancelled() { + return fmt.Errorf("action was cancelled") + } + return nil + } + return &item.UseContext{ + SwapHeldWithArmour: func(i int) { + src, dst, srcInv, dstInv := int(*p.heldSlot), i, p.inv, p.armour.Inventory() + srcIt, _ := srcInv.Item(src) + dstIt, _ := dstInv.Item(dst) + + ctx := event.C(inventory.Holder(p)) + _ = call(ctx, src, srcIt, srcInv.Handler().HandleTake) + _ = call(ctx, src, dstIt, srcInv.Handler().HandlePlace) + _ = call(ctx, dst, dstIt, dstInv.Handler().HandleTake) + if err := call(ctx, dst, srcIt, dstInv.Handler().HandlePlace); err == nil { + _ = srcInv.SetItem(src, dstIt) + _ = dstInv.SetItem(dst, srcIt) + p.PlaySound(sound.EquipItem{Item: srcIt.Item()}) + } + }, + FirstFunc: func(comparable func(item.Stack) bool) (item.Stack, bool) { + _, left := p.HeldItems() + if !left.Empty() && comparable(left) { + return left, true + } + inv := p.Inventory() + s, ok := inv.FirstFunc(comparable) + if !ok { + return item.Stack{}, false + } + it, _ := inv.Item(s) + return it, ok + }, + } +} + +// Handler returns the Handler of the player. +func (p *Player) Handler() Handler { + return p.h +} + +// broadcastItems broadcasts the items held to viewers. +func (p *Player) broadcastItems(int, item.Stack, item.Stack) { + for _, viewer := range p.viewers() { + viewer.ViewEntityItems(p) + } +} + +// broadcastArmour broadcasts the armour equipped to viewers. +func (p *Player) broadcastArmour(_ int, before, after item.Stack) { + if before.Comparable(after) && before.Empty() == after.Empty() { + // Only send armour if the type of the armour changed. + return + } + for _, viewer := range p.viewers() { + viewer.ViewEntityArmour(p) + } +} + +// viewers returns a list of all viewers of the Player. +func (p *Player) viewers() []world.Viewer { + viewers := p.tx.Viewers(p.Position()) + var s world.Viewer = p.session() + if slices.Index(viewers, s) == -1 && p.s != nil { + return append(viewers, p.s) + } + return viewers +} + +// withinChunkRadius checks if the position provided is within the chunk radius of the player. +func (p *Player) withinChunkRadius(pos mgl64.Vec3) bool { + playerChunkX, playerChunkZ := int(p.Position().X())>>4, int(p.Position().Z())>>4 + posChunkX, posChunkZ := int(pos.X())>>4, int(pos.Z())>>4 + dx, dz := playerChunkX-posChunkX, playerChunkZ-posChunkZ + if dx < 0 { + dx = -dx + } + if dz < 0 { + dz = -dz + } + r := int(p.session().ChunkRadius()) + return dx <= r && dz <= r +} + +// resendNearbyBlocks resends the block at cube.Pos and its adjacent blocks (if faces provided), +// but only if they are within the player's render distance. +func (p *Player) resendNearbyBlocks(pos cube.Pos, faces ...cube.Face) { + if p.session() == session.Nop { + return + } + p.resendNearbyBlock(pos) + for _, f := range faces { + p.resendNearbyBlock(pos.Side(f)) + } +} + +// resendNearbyBlock resends a block at cube.Pos if it is within the player's render distance. +func (p *Player) resendNearbyBlock(pos cube.Pos) { + if p.session() == session.Nop { + return + } + if !p.withinChunkRadius(pos.Vec3()) { + // This is a safety check. Without it, clients could request block resends for arbitrary world positions + // (including unloaded chunks). A malicious client could repeatedly trigger such requests and force the server + // to allocate memory for chunks, potentially exhausting RAM. + return + } + b := p.tx.Block(pos) + p.session().ViewBlockUpdate(pos, b, 0) + if _, ok := b.(world.LiquidDisplacer); ok { + liq, _ := p.tx.Liquid(pos) + p.session().ViewBlockUpdate(pos, liq, 1) + } +} + +// format is a utility function to format a list of values to have spaces between them, but no newline at the +// end, which is typically used for sending messages, popups and tips. +func format(a []any) string { + return strings.TrimSuffix(strings.TrimSuffix(fmt.Sprintln(a...), "\n"), "\n") +} diff --git a/server/player/playerdb/effect.go b/server/player/playerdb/effect.go new file mode 100644 index 0000000..ec303b0 --- /dev/null +++ b/server/player/playerdb/effect.go @@ -0,0 +1,50 @@ +package playerdb + +import "github.com/df-mc/dragonfly/server/entity/effect" + +func effectsToData(effects []effect.Effect) []jsonEffect { + data := make([]jsonEffect, len(effects)) + for key, eff := range effects { + id, ok := effect.ID(eff.Type()) + if !ok { + continue + } + data[key] = jsonEffect{ + ID: id, + Duration: eff.Duration(), + Level: eff.Level(), + Ambient: eff.Ambient(), + ParticlesHidden: eff.ParticlesHidden(), + Infinite: eff.Infinite(), + } + } + return data +} + +func dataToEffects(data []jsonEffect) []effect.Effect { + effects := make([]effect.Effect, len(data)) + for i, d := range data { + e, ok := effect.ByID(d.ID) + if !ok { + continue + } + switch eff := e.(type) { + case effect.LastingType: + switch { + case d.Ambient: + effects[i] = effect.NewAmbient(eff, d.Level, d.Duration) + case d.Infinite: + effects[i] = effect.NewInfinite(eff, d.Level) + default: + effects[i] = effect.New(eff, d.Level, d.Duration) + } + + if d.ParticlesHidden { + effects[i] = effects[i].WithoutParticles() + } + default: + effects[i] = effect.NewInstant(eff, d.Level) + } + } + return effects +} diff --git a/server/player/playerdb/inventory.go b/server/player/playerdb/inventory.go new file mode 100644 index 0000000..435b56e --- /dev/null +++ b/server/player/playerdb/inventory.go @@ -0,0 +1,95 @@ +package playerdb + +import ( + "bytes" + "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/item" + "github.com/sandertv/gophertunnel/minecraft/nbt" +) + +// InventoryData is a struct that contains all data of the player inventories. +type InventoryData struct { + // Items contains all the items in the player's main inventory. + // This excludes armour and offhand. + Items []item.Stack + // Boots, Leggings, Chestplate, Helmet are armour pieces that belong to the slot corresponding to the name. + Boots item.Stack + Leggings item.Stack + Chestplate item.Stack + Helmet item.Stack + // OffHand is what the player is carrying in their non-main hand, like a shield or arrows. + OffHand item.Stack + // MainHandSlot saves the slot in the hotbar that the player is currently switched to. + // Should be between 0-8. + MainHandSlot uint32 +} + +func invToData(data InventoryData) jsonInventoryData { + d := jsonInventoryData{ + MainHandSlot: data.MainHandSlot, + OffHand: encodeItem(data.OffHand), + } + d.Items = encodeItems(data.Items) + d.Boots = encodeItem(data.Boots) + d.Leggings = encodeItem(data.Leggings) + d.Chestplate = encodeItem(data.Chestplate) + d.Helmet = encodeItem(data.Helmet) + return d +} + +func dataToInv(data jsonInventoryData) InventoryData { + d := InventoryData{ + MainHandSlot: data.MainHandSlot, + OffHand: decodeItem(data.OffHand), + Items: make([]item.Stack, 36), + } + decodeItems(data.Items, d.Items) + d.Boots = decodeItem(data.Boots) + d.Leggings = decodeItem(data.Leggings) + d.Chestplate = decodeItem(data.Chestplate) + d.Helmet = decodeItem(data.Helmet) + return d +} + +func encodeItems(items []item.Stack) (encoded []jsonSlot) { + encoded = make([]jsonSlot, 0, len(items)) + for slot, i := range items { + data := encodeItem(i) + if data == nil { + continue + } + encoded = append(encoded, jsonSlot{Slot: slot, Item: data}) + } + return +} + +func decodeItems(encoded []jsonSlot, items []item.Stack) { + for _, i := range encoded { + items[i.Slot] = decodeItem(i.Item) + } +} + +func encodeItem(item item.Stack) []byte { + if item.Empty() { + return nil + } + + var b bytes.Buffer + itemNBT := nbtconv.WriteItem(item, true) + encoder := nbt.NewEncoderWithEncoding(&b, nbt.LittleEndian) + err := encoder.Encode(itemNBT) + if err != nil { + return nil + } + return b.Bytes() +} + +func decodeItem(data []byte) item.Stack { + var itemNBT map[string]any + decoder := nbt.NewDecoderWithEncoding(bytes.NewBuffer(data), nbt.LittleEndian) + err := decoder.Decode(&itemNBT) + if err != nil { + return item.Stack{} + } + return nbtconv.Item(itemNBT, nil) +} diff --git a/server/player/playerdb/json.go b/server/player/playerdb/json.go new file mode 100644 index 0000000..c453da0 --- /dev/null +++ b/server/player/playerdb/json.go @@ -0,0 +1,143 @@ +package playerdb + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/inventory" + "github.com/df-mc/dragonfly/server/player" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" + "github.com/google/uuid" + "time" +) + +func (p *Provider) fromJson(d jsonData, lookupWorld func(world.Dimension) *world.World) (player.Config, *world.World) { + dim, _ := world.DimensionByID(int(d.Dimension)) + mode, _ := world.GameModeByID(int(d.GameMode)) + conf := player.Config{ + UUID: uuid.MustParse(d.UUID), + XUID: d.XUID, + Name: d.Username, + Position: d.Position, + Rotation: cube.Rotation{d.Yaw, d.Pitch}, + Velocity: d.Velocity, + Health: d.Health, + MaxHealth: d.MaxHealth, + Food: d.Hunger, + FoodTick: d.FoodTick, + Exhaustion: d.ExhaustionLevel, + Saturation: d.SaturationLevel, + Experience: d.Experience, + AirSupply: d.AirSupply, + MaxAirSupply: d.MaxAirSupply, + EnchantmentSeed: d.EnchantmentSeed, + GameMode: mode, + Effects: dataToEffects(d.Effects), + FireTicks: d.FireTicks, + FallDistance: d.FallDistance, + Inventory: inventory.New(36, nil), + EnderChestInventory: inventory.New(27, nil), + OffHand: inventory.New(1, nil), + Armour: inventory.NewArmour(nil), + } + echest := make([]item.Stack, 27) + decodeItems(d.EnderChestInventory, echest) + invData := dataToInv(d.Inventory) + + for slot, stack := range invData.Items { + _ = conf.Inventory.SetItem(slot, stack) + } + _ = conf.OffHand.SetItem(0, invData.OffHand) + conf.Armour.Set(invData.Helmet, invData.Chestplate, invData.Leggings, invData.Boots) + conf.HeldSlot = int(invData.MainHandSlot) + + for slot, stack := range echest { + _ = conf.EnderChestInventory.SetItem(slot, stack) + } + return conf, lookupWorld(dim) +} + +func (p *Provider) toJson(d player.Config, w *world.World) jsonData { + dim, _ := world.DimensionID(w.Dimension()) + mode, _ := world.GameModeID(d.GameMode) + offHand, _ := d.OffHand.Item(0) + return jsonData{ + UUID: d.UUID.String(), + Username: d.Name, + Position: d.Position, + Velocity: d.Velocity, + Yaw: d.Rotation.Yaw(), + Pitch: d.Rotation.Pitch(), + Health: d.Health, + MaxHealth: d.MaxHealth, + Hunger: d.Food, + FoodTick: d.FoodTick, + ExhaustionLevel: d.Exhaustion, + SaturationLevel: d.Saturation, + Experience: d.Experience, + AirSupply: d.AirSupply, + MaxAirSupply: d.MaxAirSupply, + EnchantmentSeed: d.EnchantmentSeed, + GameMode: uint8(mode), + Effects: effectsToData(d.Effects), + FireTicks: d.FireTicks, + FallDistance: d.FallDistance, + Inventory: invToData(InventoryData{ + Items: d.Inventory.Slots(), + Boots: d.Armour.Boots(), + Leggings: d.Armour.Leggings(), + Chestplate: d.Armour.Chestplate(), + Helmet: d.Armour.Helmet(), + OffHand: offHand, + MainHandSlot: uint32(d.HeldSlot), + }), + EnderChestInventory: encodeItems(d.EnderChestInventory.Slots()), + Dimension: uint8(dim), + } +} + +type jsonData struct { + UUID string + XUID string + Username string + Position, Velocity mgl64.Vec3 + Yaw, Pitch float64 + Health, MaxHealth float64 + Hunger int + FoodTick int + ExhaustionLevel, SaturationLevel float64 + EnchantmentSeed int64 + Experience int + AirSupply, MaxAirSupply int + GameMode uint8 + Inventory jsonInventoryData + EnderChestInventory []jsonSlot + Effects []jsonEffect + FireTicks int64 + FallDistance float64 + Dimension uint8 +} + +type jsonInventoryData struct { + Items []jsonSlot + Boots []byte + Leggings []byte + Chestplate []byte + Helmet []byte + OffHand []byte + MainHandSlot uint32 +} + +type jsonSlot struct { + Item []byte + Slot int +} + +type jsonEffect struct { + ID int + Level int + Duration time.Duration + Ambient bool + ParticlesHidden bool + Infinite bool +} diff --git a/server/player/playerdb/leveldb.go b/server/player/playerdb/leveldb.go new file mode 100644 index 0000000..0aa8575 --- /dev/null +++ b/server/player/playerdb/leveldb.go @@ -0,0 +1,61 @@ +package playerdb + +import ( + "encoding/json" + "github.com/df-mc/dragonfly/server/player" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/goleveldb/leveldb" + "github.com/df-mc/goleveldb/leveldb/opt" + "github.com/google/uuid" + "os" +) + +// Provider is a player data provider that uses a LevelDB database to store data. The data passed on +// will first be converted to make sure it can be marshaled into JSON. This JSON (in bytes) will then +// be stored in the database under a key that is the byte representation of the player's UUID. +type Provider struct { + db *leveldb.DB +} + +// NewProvider creates a new player data provider that saves and loads data using +// a LevelDB database. +func NewProvider(path string) (*Provider, error) { + if _, err := os.Stat(path); os.IsNotExist(err) { + _ = os.Mkdir(path, 0777) + } + db, err := leveldb.OpenFile(path, &opt.Options{Compression: opt.SnappyCompression}) + if err != nil { + return nil, err + } + return &Provider{db: db}, nil +} + +// Save ... +func (p *Provider) Save(id uuid.UUID, d player.Config, w *world.World) error { + b, err := json.Marshal(p.toJson(d, w)) + if err != nil { + return err + } + return p.db.Put(id[:], b, nil) +} + +// Load ... +func (p *Provider) Load(id uuid.UUID, world func(world.Dimension) *world.World) (player.Config, *world.World, error) { + b, err := p.db.Get(id[:], nil) + if err != nil { + return player.Config{}, nil, err + } + var d jsonData + err = json.Unmarshal(b, &d) + if err != nil { + return player.Config{}, nil, err + } + conf, w := p.fromJson(d, world) + + return conf, w, nil +} + +// Close ... +func (p *Provider) Close() error { + return p.db.Close() +} diff --git a/server/player/provider.go b/server/player/provider.go new file mode 100644 index 0000000..537aefa --- /dev/null +++ b/server/player/provider.go @@ -0,0 +1,33 @@ +package player + +import ( + "errors" + "github.com/df-mc/dragonfly/server/world" + "github.com/google/uuid" + "io" +) + +// Provider represents a value that may provide data to a Player value. It usually does the reading and +// writing of the player data so that the Player may use it. +type Provider interface { + // Save is called when the player leaves the server. The Data of the player is passed. + Save(uuid uuid.UUID, data Config, w *world.World) error + // Load is called when the player joins and passes the UUID of the player. + // It expects to the player data, and an error that is nil if the player data could be found. If non-nil, the player + // will use default values, and you can use an empty Data struct. + Load(uuid uuid.UUID, world func(world.Dimension) *world.World) (Config, *world.World, error) + // Closer is used on server close when the server calls Provider.Close() and is used to safely close the Provider. + io.Closer +} + +// Compile time check to make sure NopProvider implements Provider. +var _ Provider = (*NopProvider)(nil) + +// NopProvider is a player data provider that won't store any data and instead always return default values +type NopProvider struct{} + +func (NopProvider) Save(uuid.UUID, Config, *world.World) error { return nil } +func (NopProvider) Load(uuid.UUID, func(world.Dimension) *world.World) (Config, *world.World, error) { + return Config{}, nil, errors.New("") +} +func (NopProvider) Close() error { return nil } diff --git a/server/player/scoreboard/scoreboard.go b/server/player/scoreboard/scoreboard.go new file mode 100644 index 0000000..709a928 --- /dev/null +++ b/server/player/scoreboard/scoreboard.go @@ -0,0 +1,105 @@ +package scoreboard + +import ( + "fmt" + "slices" + "strings" +) + +// Scoreboard represents a scoreboard that may be sent to a player. The scoreboard is shown on the right side +// of the player's screen. +// Scoreboard implements the io.Writer and io.StringWriter interfaces. fmt.Fprintf and fmt.Fprint may be used +// to write formatted text to the scoreboard. +type Scoreboard struct { + name string + lines []string + padding bool + descending bool +} + +// New returns a new scoreboard with the display name passed. Once returned, lines may be added to the +// scoreboard to add text to it. The name is formatted according to the rules of fmt.Sprintln. +// Changing the scoreboard after sending it to a player will not update the scoreboard of the player +// automatically: Player.SendScoreboard() must be called again to update it. +func New(name ...any) *Scoreboard { + return &Scoreboard{name: strings.TrimSuffix(fmt.Sprintln(name...), "\n"), padding: true} +} + +// Name returns the display name of the scoreboard, as passed during the construction of the scoreboard. +func (board *Scoreboard) Name() string { + return board.name +} + +// Write writes a slice of data as text to the scoreboard. Newlines may be written to create a new line on +// the scoreboard. +func (board *Scoreboard) Write(p []byte) (n int, err error) { + return board.WriteString(string(p)) +} + +// WriteString writes a string of text to the scoreboard. Newlines may be written to create a new line on +// the scoreboard. +func (board *Scoreboard) WriteString(s string) (n int, err error) { + lines := strings.Split(s, "\n") + board.lines = append(board.lines, lines...) + + // Scoreboards can have up to 15 lines. (16 including the title.) + if len(board.lines) >= 15 { + return len(lines), fmt.Errorf("write scoreboard: maximum of 15 lines of text exceeded") + } + return len(lines), nil +} + +// Set changes a specific line in the scoreboard and adds empty lines until this index is reached. Set panics if the +// index passed is negative or 15+. +func (board *Scoreboard) Set(index int, s string) { + if index < 0 || index >= 15 { + panic(fmt.Sprintf("index out of range %v", index)) + } + if diff := index - (len(board.lines) - 1); diff > 0 { + board.lines = append(board.lines, make([]string, diff)...) + } + // Remove new lines from the string + board.lines[index] = strings.TrimSuffix(strings.TrimSuffix(s, "\n"), "\n") +} + +// Remove removes a specific line from the scoreboard. Remove panics if the index passed is negative or 15+. +func (board *Scoreboard) Remove(index int) { + if index < 0 || index >= 15 { + panic(fmt.Sprintf("index out of range %v", index)) + } + board.lines = append(board.lines[:index], board.lines[index+1:]...) +} + +// RemovePadding removes the padding of one space that is added to the start of every line. +func (board *Scoreboard) RemovePadding() { + board.padding = false +} + +// Lines returns the data of the Scoreboard as a slice of strings. +func (board *Scoreboard) Lines() []string { + lines := slices.Clone(board.lines) + if board.padding { + for i, line := range lines { + if len(board.name)-len(line)-2 <= 0 { + lines[i] = " " + line + " " + continue + } + lines[i] = " " + line + strings.Repeat(" ", len(board.name)-len(line)-2) + } + } + if board.descending { + slices.Reverse(lines) + } + return lines +} + +// Descending returns whether the scoreboard is sorted in descending order. +func (board *Scoreboard) Descending() bool { + return board.descending +} + +// SetDescending sets the scoreboard sort order to descending. +// When sending the scoreboard to the player, it will reverse the order of the lines to match when it's ascending. +func (board *Scoreboard) SetDescending() { + board.descending = true +} diff --git a/server/player/skin/animation.go b/server/player/skin/animation.go new file mode 100644 index 0000000..914224b --- /dev/null +++ b/server/player/skin/animation.go @@ -0,0 +1,87 @@ +package skin + +import ( + "image" + "image/color" +) + +const ( + // AnimationHead is an animation that is played over the head part of the skin. + AnimationHead AnimationType = iota + // AnimationBody32x32 is an animation that is played over the body of a skin with a 32x32(/64) size. This + // is the usual animation type for body animations. + AnimationBody32x32 + // AnimationBody128x128 is an animation that is played over a body of a skin with a 128x128 size. This is + // the animation type for body animations with high resolution. + AnimationBody128x128 +) + +// AnimationType represents a type of the animation. It is one of the constants above, and specifies to what +// part of the body it is assigned. +type AnimationType int + +// Animation represents an animation that plays over the skin every so often. It is assigned to a particular +// part of the skin, which is represented by one of the constants above. +type Animation struct { + w, h int + aType AnimationType + + // Pix holds skin data for every frame of the animation. This is an RGBA byte slice, meaning that every + // first byte is a Red value, the second a Green value, the third a Blue value and the fourth an Alpha + // value. + Pix []uint8 + + // FrameCount is the amount of frames that the animation plays for. Exactly this amount of frames should + // be present in the Pix animation data. + FrameCount int + + // AnimationExpression is the player's animation expression. + AnimationExpression int +} + +// NewAnimation returns a new animation using the width and height passed, with the type specifying what part +// of the body to display it on. +// NewAnimation fills out the Pix field adequately and sets FrameCount to 1 by default. +func NewAnimation(width, height int, expression int, animationType AnimationType) Animation { + return Animation{ + w: width, + h: height, + aType: animationType, + Pix: make([]uint8, width*height*4), + FrameCount: 1, + AnimationExpression: expression, + } +} + +// Type returns the type of the animation, which is one of the constants above. +func (a Animation) Type() AnimationType { + return a.aType +} + +// ColorModel ... +func (a Animation) ColorModel() color.Model { + return color.RGBAModel +} + +// Bounds ... +func (a Animation) Bounds() image.Rectangle { + return image.Rectangle{ + Max: image.Point{X: a.w, Y: a.h}, + } +} + +// At returns the colour at a given position in the animation data, provided the X and Y are within the bounds +// of the animation passed during construction. +// The concrete type returned by At is a color.RGBA value. +func (a Animation) At(x, y int) color.Color { + if x < 0 || y < 0 || x >= a.w || y >= a.h { + panic("pixel coordinates out of bounds") + } + offset := x*4 + a.w*y*4 + return color.RGBA{ + R: a.Pix[offset], + G: a.Pix[offset+1], + B: a.Pix[offset+2], + A: a.Pix[offset+3], + } +} diff --git a/server/player/skin/cape.go b/server/player/skin/cape.go new file mode 100644 index 0000000..653dbdb --- /dev/null +++ b/server/player/skin/cape.go @@ -0,0 +1,52 @@ +package skin + +import ( + "image" + "image/color" +) + +// Cape represents the cape that a skin may additionally have. A skin is of a fixed size (always 32x64 bytes) +// and may be either empty or of that size. +type Cape struct { + w, h int + + // Pix holds the colour data of the cape in an RGBA byte array, similarly to the way that the pixels of + // a Skin are stored. + // The size of Pix is always 32 * 64 * 4 bytes. + Pix []uint8 +} + +// NewCape initialises a new Cape using the width and height passed. The pixels are pre-allocated so that the +// Cape may be used immediately. +func NewCape(width, height int) Cape { + return Cape{w: width, h: height, Pix: make([]uint8, width*height*4)} +} + +// ColorModel ... +func (c Cape) ColorModel() color.Model { + return color.RGBAModel +} + +// Bounds returns the bounds of the cape, which is always 32x64 or 0x0, depending on if the cape has any data +// in it. +func (c Cape) Bounds() image.Rectangle { + return image.Rectangle{ + Max: image.Point{X: c.w, Y: c.h}, + } +} + +// At returns the colour at a given position in the cape, provided the X and Y are within the bounds of the +// cape passed during construction. +// The concrete type returned by At is a color.RGBA value. +func (c Cape) At(x, y int) color.Color { + if x < 0 || y < 0 || x >= c.w || y >= c.h { + panic("pixel coordinates out of bounds") + } + offset := x*4 + c.w*y*4 + return color.RGBA{ + R: c.Pix[offset], + G: c.Pix[offset+1], + B: c.Pix[offset+2], + A: c.Pix[offset+3], + } +} diff --git a/server/player/skin/model_config.go b/server/player/skin/model_config.go new file mode 100644 index 0000000..ff0ec54 --- /dev/null +++ b/server/player/skin/model_config.go @@ -0,0 +1,37 @@ +package skin + +import ( + "encoding/json" +) + +// ModelConfig specifies the way that the model (geometry data) is used to form the complete skin. It does +// this by setting model names for specific keys found in the struct. +type ModelConfig struct { + // Default is the 'default' model to use. This model is essentially the model of the skin that will be + // used at all times, when nothing special is being done. (For example, an animation) + // The field holds the name of one of the models present in the JSON of the skin's model. + // This field should always be filled out. + Default string `json:"default"` + // AnimatedFace is the model of an animation played over the face. This field should be set if the model + // contains the model of an animation, in which case this field should hold the name of that model. + AnimatedFace string `json:"animated_face,omitempty"` +} + +// modelConfigContainer is a container of the model config data when encoded. +type modelConfigContainer struct { + Geometry ModelConfig `json:"geometry"` +} + +// Encode encodes a ModelConfig into its JSON representation. +func (cfg ModelConfig) Encode() []byte { + b, _ := json.Marshal(modelConfigContainer{Geometry: cfg}) + return b +} + +// DecodeModelConfig attempts to decode a ModelConfig from the JSON data passed. If not successful, an error +// is returned. +func DecodeModelConfig(b []byte) (ModelConfig, error) { + var m modelConfigContainer + err := json.Unmarshal(b, &m) + return m.Geometry, err +} diff --git a/server/player/skin/skin.go b/server/player/skin/skin.go new file mode 100644 index 0000000..e296131 --- /dev/null +++ b/server/player/skin/skin.go @@ -0,0 +1,77 @@ +package skin + +import ( + "image" + "image/color" +) + +// Skin holds the data of a skin that a player has equipped. It includes geometry data, the texture and the +// cape, if one is present. +// Skin implements the image.Image interface to ease working with the value as an image. +type Skin struct { + w, h int + // Persona specifies if the skin uses the persona skin system. + Persona bool + PlayFabID string + FullID string + + // Pix holds the raw pixel data of the skin. This is an RGBA byte slice, meaning that every first byte is + // a Red value, the second a Green value, the third a Blue value and the fourth an Alpha value. + Pix []uint8 + + // ModelConfig specifies how the Model field below should be used to form the total skin. + ModelConfig ModelConfig + // Model holds the raw JSON data that represents the model of the skin. If empty, it means the skin holds + // the standard skin data (geometry.humanoid). + // TODO: Write a full API for this. The model should be able to be easily modified or created runtime. + Model []byte + + // Cape holds the cape of the skin. By default, an empty cape is set in the skin. Cape.Exists() may be + // called to check if the cape actually has any data. + Cape Cape + + // Animations holds a list of all animations that the skin has. These animations must be pointed to in the + // ModelConfig, in order to display them on the skin. + Animations []Animation +} + +// New creates a new skin using the width and height passed. The dimensions passed must be either 64x32, +// 64x64 or 128x128. An error is returned if other dimensions are used. +// The skin pixels are initialised for the skin, and a random skin ID is picked. The model name and model is +// left empty. +func New(width, height int) Skin { + return Skin{ + w: width, + h: height, + Pix: make([]uint8, width*height*4), + } +} + +// Bounds returns the bounds of the skin. These are either 64x32, 64x64 or 128, depending on the bounds of the +// skin of the player. +func (s Skin) Bounds() image.Rectangle { + return image.Rectangle{ + Max: image.Point{X: s.w, Y: s.h}, + } +} + +// ColorModel returns color.RGBAModel. +func (s Skin) ColorModel() color.Model { + return color.RGBAModel +} + +// At returns the colour at a given position in the skin. The concrete value of the colour returned is a color.RGBA +// value. +// If the x or y values exceed the bounds of the skin, At will panic. +func (s Skin) At(x, y int) color.Color { + if x < 0 || y < 0 || x >= s.w || y >= s.h { + panic("pixel coordinates out of bounds") + } + offset := x*4 + s.w*y*4 + return color.RGBA{ + R: s.Pix[offset], + G: s.Pix[offset+1], + B: s.Pix[offset+2], + A: s.Pix[offset+3], + } +} diff --git a/server/player/title/title.go b/server/player/title/title.go new file mode 100644 index 0000000..8a4900f --- /dev/null +++ b/server/player/title/title.go @@ -0,0 +1,106 @@ +package title + +import ( + "fmt" + "strings" + "time" +) + +// Title represents a title that may be sent to the player. The title will show up as large text in the middle +// of the screen, with optional subtitle and action text. +type Title struct { + text, subtitle, actionText string + fadeInDuration, fadeOutDuration, duration time.Duration +} + +// New returns a new title using the text passed. The text is formatted according to the formatting rules of +// fmt.Sprintln, but with no newline at the end. +// The title has default durations set, which will generally suffice. +func New(text ...any) Title { + return Title{ + text: format(text), + fadeInDuration: time.Second / 20, + fadeOutDuration: time.Second / 20, + duration: time.Second * 2, + } +} + +// Text returns the text of the title, as passed to New when created. +func (title Title) Text() string { + return title.text +} + +// WithSubtitle sets the subtitle of the title. The text passed will be formatted according to the formatting +// rules of fmt.Sprintln, but without the newline. +// The subtitle is shown under the title in a somewhat smaller font. +// The new Title with the subtitle is returned. +func (title Title) WithSubtitle(text ...any) Title { + title.subtitle = format(text) + return title +} + +// Subtitle returns the subtitle of the title, as passed to SetSubtitle. Subtitle returns an empty string if +// no subtitle was previously set. +func (title Title) Subtitle() string { + return title.subtitle +} + +// WithActionText sets the action text of the title. This text is roughly the same as sending a tip/popup, but +// will synchronise with the title. +// SetActionText will format the text passed using the formatting rules of fmt.Sprintln, but without newline. +// The new Title with the action text is returned. +func (title Title) WithActionText(text ...any) Title { + title.actionText = format(text) + return title +} + +// ActionText returns the action text added to the title. This text is roughly the same as sending a tip, but +// will synchronise with the title. By default, the action text is empty. +func (title Title) ActionText() string { + return title.actionText +} + +// Duration returns the duration that the title will be visible for, without fading in or out. By default, +// this is two seconds. +func (title Title) Duration() time.Duration { + return title.duration +} + +// WithDuration sets the duration that the title will be visible for without fading in or fading out. +// The new Title with the duration is returned. +func (title Title) WithDuration(d time.Duration) Title { + title.duration = d + return title +} + +// WithFadeInDuration sets the duration that the title takes to fade in on the screen. +// The new Title with the fade-in duration is returned. +func (title Title) WithFadeInDuration(d time.Duration) Title { + title.fadeInDuration = d + return title +} + +// FadeInDuration returns the duration that the fade-in of the title takes. By default, this is a quarter of +// a second. +func (title Title) FadeInDuration() time.Duration { + return title.fadeInDuration +} + +// WithFadeOutDuration sets the duration that the title takes to fade out of the screen. +// The new Title with the fade-out duration is returned. +func (title Title) WithFadeOutDuration(d time.Duration) Title { + title.fadeOutDuration = d + return title +} + +// FadeOutDuration returns the duration that the fade-out of the title takes.By default, this is a quarter of +// a second. +func (title Title) FadeOutDuration() time.Duration { + return title.fadeOutDuration +} + +// format is a utility function to format a list of values to have spaces between them, but no newline at the +// end. +func format(a []any) string { + return strings.TrimSuffix(strings.TrimSuffix(fmt.Sprintln(a...), "\n"), "\n") +} diff --git a/server/player/type.go b/server/player/type.go new file mode 100644 index 0000000..49d91f4 --- /dev/null +++ b/server/player/type.go @@ -0,0 +1,63 @@ +package player + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// Type is a world.EntityType implementation for Player. +var Type ptype + +type ptype struct{} + +func (t ptype) Open(tx *world.Tx, handle *world.EntityHandle, data *world.EntityData) world.Entity { + pd := data.Data.(*playerData) + p := &Player{ + tx: tx, + handle: handle, + data: data, + playerData: pd, + } + + pd.offHand.SlotValidatorFunc(func(s item.Stack, _ int) bool { + if s.Empty() { + return true + } + it, allowedInOffhand := s.Item().(item.OffHand) + return allowedInOffhand && it.OffHand() + }) + + if pd.s != nil { + pd.s.HandleInventories(tx, p, pd.inv, pd.offHand, pd.enderChest, pd.ui, pd.armour, pd.heldSlot) + } else { + pd.inv.SlotFunc(func(slot int, before, after item.Stack) { + if slot == int(*p.heldSlot) { + p.broadcastItems(slot, before, after) + } + }) + pd.offHand.SlotFunc(p.broadcastItems) + pd.armour.Inventory().SlotFunc(p.broadcastArmour) + } + return p +} + +func (ptype) EncodeEntity() string { return "minecraft:player" } +func (ptype) NetworkOffset() float64 { return 1.621 } +func (ptype) BBox(e world.Entity) cube.BBox { + p := e.(*Player) + s := p.Scale() + _, sleeping := p.Sleeping() + switch { + case sleeping: + return cube.Box(-0.1*s, 0, -0.1*s, 0.1*s, 0.2*s, 0.1*s) + case p.Gliding(), p.Swimming(), p.Crawling(): + return cube.Box(-0.3*s, 0, -0.3*s, 0.3*s, 0.6*s, 0.3*s) + case p.Sneaking(): + return cube.Box(-0.3*s, 0, -0.3*s, 0.3*s, 1.49*s, 0.3*s) + default: + return cube.Box(-0.3*s, 0, -0.3*s, 0.3*s, 1.8*s, 0.3*s) + } +} +func (t ptype) DecodeNBT(map[string]any, *world.EntityData) {} +func (t ptype) EncodeNBT(*world.EntityData) map[string]any { return nil } diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..926d37f --- /dev/null +++ b/server/server.go @@ -0,0 +1,660 @@ +package server + +import ( + "context" + _ "embed" + "encoding/base64" + "fmt" + "iter" + "maps" + "os" + "os/signal" + "runtime/debug" + "slices" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/df-mc/dragonfly/server/internal/blockinternal" + "github.com/df-mc/dragonfly/server/internal/iteminternal" + "github.com/df-mc/dragonfly/server/internal/sliceutil" + _ "github.com/df-mc/dragonfly/server/item" // Imported for maintaining correct initialisation order. + "github.com/df-mc/dragonfly/server/player" + "github.com/df-mc/dragonfly/server/player/chat" + "github.com/df-mc/dragonfly/server/player/skin" + "github.com/df-mc/dragonfly/server/session" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl32" + "github.com/go-gl/mathgl/mgl64" + "github.com/google/uuid" + "github.com/sandertv/gophertunnel/minecraft" + "github.com/sandertv/gophertunnel/minecraft/nbt" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/protocol/login" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" + "golang.org/x/text/language" +) + +// Server implements a Dragonfly server. It runs the main server loop and +// handles the connections of players trying to join the server. +type Server struct { + conf Config + + once sync.Once + started atomic.Pointer[time.Time] + + world, nether, end *world.World + + customBlocks []protocol.BlockEntry + customItems []protocol.ItemEntry + + listeners []Listener + incoming chan incoming + + pmu sync.RWMutex + // p holds a map of all players currently connected to the server. When they + // leave, they are removed from the map. + p map[uuid.UUID]*onlinePlayer + // pwg is a sync.WaitGroup used to wait for all players to be disconnected + // before server shutdown, so that their data is saved properly. + pwg sync.WaitGroup + // wg is used to wait for all Listeners to be closed and their respective + // goroutines to be finished. + wg sync.WaitGroup +} + +// incoming holds data of a player that is connecting to the server. +type incoming struct { + conf player.Config + s *session.Session + p *onlinePlayer + w *world.World +} + +// onlinePlayer holds the entity handle, XUID and name of a player. +type onlinePlayer struct { + handle *world.EntityHandle + xuid string + name string +} + +// New creates a Server using a default Config. The Server's worlds are created +// and connections from the Server's listeners may be accepted by calling +// Server.Listen() and Server.Accept() afterwards. +func New() *Server { + var conf Config + return conf.New() +} + +// Listen starts running the server's listeners. Connections will be accepted +// until the listeners are closed using a call to Close. Once Listen is called, +// players may be accepted using Server.Accept(). +func (srv *Server) Listen() { + t := time.Now() + if !srv.started.CompareAndSwap(nil, &t) { + panic("start server: already started") + } + + info, _ := debug.ReadBuildInfo() + if info == nil { + info = &debug.BuildInfo{GoVersion: "N/A", Settings: []debug.BuildSetting{{Key: "vcs.revision", Value: "N/A"}}} + } + revision := "" + for _, set := range info.Settings { + if set.Key == "vcs.revision" { + revision = set.Value + } + } + + srv.conf.Log.Info("Dragonfly server started.", "mc-version", protocol.CurrentVersion, "go-version", info.GoVersion, "commit", revision) + srv.startListening() + go srv.wait() +} + +// Accept accepts incoming players into the server, returning an iterator that +// yields players that join the server while blocking otherwise. The iterator +// returned ends when the Server is closed using a call to Close. Players +// returned are only valid within the block of the for loop used to iterate over +// them: +// +// for p := range srv.Accept() { +// // p is valid here +// go func() { +// // p is no longer valid here +// }() +// } +// // p is no longer valid here +func (srv *Server) Accept() iter.Seq[*player.Player] { + return func(yield func(*player.Player) bool) { + for { + inc, ok := <-srv.incoming + if !ok { + return + } + srv.pmu.Lock() + srv.p[inc.p.handle.UUID()] = inc.p + srv.pmu.Unlock() + + ret := false + <-inc.w.Exec(func(tx *world.Tx) { + p := tx.AddEntity(inc.p.handle).(*player.Player) + inc.s.Spawn(p, tx) + ret = !yield(p) + }) + if ret { + return + } + } + } +} + +// World returns the overworld of the server. Players will be spawned in this +// world and this world will be read from and written to when the world is +// edited. +func (srv *Server) World() *world.World { + return srv.world +} + +// Nether returns the nether world of the server. Players are transported to it +// when entering a nether portal in the world returned by the World method. +func (srv *Server) Nether() *world.World { + return srv.nether +} + +// End returns the end world of the server. Players are transported to it when +// entering an end portal in the world returned by the World method. +func (srv *Server) End() *world.World { + return srv.end +} + +// MaxPlayerCount returns the maximum amount of players that are allowed to +// play on the server at the same time. Players trying to join when the server +// is full will be refused to enter. If the config has a maximum player count +// set to 0, MaxPlayerCount will return Server.PlayerCount + 1. +func (srv *Server) MaxPlayerCount() int { + if srv.conf.MaxPlayers == 0 { + srv.pmu.RLock() + defer srv.pmu.RUnlock() + return len(srv.p) + 1 + } + return srv.conf.MaxPlayers +} + +// PlayerCount returns the total number of players connected to the Server. +func (srv *Server) PlayerCount() int { + srv.pmu.RLock() + defer srv.pmu.RUnlock() + return len(srv.p) +} + +// Players returns an iterator that yields players currently online. If Players +// is called from within a transaction, the respective transaction should be +// passed. Passing nil is otherwise valid. Players returned are only valid +// within the block of the for loop used to iterate over them: +// +// for p := range srv.Players(nil) { +// // p is valid here +// go func() { +// // p is no longer valid here +// }() +// } +// // p is no longer valid here +// +// Collecting all values from the iterator using a function such as +// slices.Collect immediately invalidates the players because their transactions +// will be finished. +func (srv *Server) Players(tx *world.Tx) iter.Seq[*player.Player] { + srv.pmu.RLock() + handles := make([]*world.EntityHandle, 0, len(srv.p)) + for _, p := range srv.p { + handles = append(handles, p.handle) + } + srv.pmu.RUnlock() + + return func(yield func(*player.Player) bool) { + for _, handle := range handles { + if tx != nil { + if e, ok := handle.Entity(tx); ok { + if !yield(e.(*player.Player)) { + break + } + continue + } + } + ret := false + handle.ExecWorld(func(tx *world.Tx, e world.Entity) { + ret = !yield(e.(*player.Player)) + }) + if ret { + break + } + } + } +} + +// Player looks for a player on the server with the UUID passed. If found, the +// entity handle is returned and the bool returns holds a true value. If not, +// the bool returned is false and the handle is nil. +func (srv *Server) Player(uuid uuid.UUID) (*world.EntityHandle, bool) { + srv.pmu.RLock() + defer srv.pmu.RUnlock() + p, ok := srv.p[uuid] + if !ok { + return nil, false + } + return p.handle, ok +} + +// PlayerByName looks for a player on the server with the name passed. If +// found, the entity handle is returned and the bool returned holds a true +// value. If not, the bool is false and the handle is nil +func (srv *Server) PlayerByName(name string) (*world.EntityHandle, bool) { + if p, ok := sliceutil.SearchValue(slices.Collect(maps.Values(srv.p)), func(p *onlinePlayer) bool { + return p.name == name + }); ok { + return p.handle, true + } + return nil, false +} + +// PlayerByXUID looks for a player on the server with the XUID passed. If +// found, the entity handle is returned and the bool returned is true. If no +// player with the XUID was found, nil and false are returned. +func (srv *Server) PlayerByXUID(xuid string) (*world.EntityHandle, bool) { + if p, ok := sliceutil.SearchValue(slices.Collect(maps.Values(srv.p)), func(p *onlinePlayer) bool { + return p.xuid == xuid + }); ok { + return p.handle, true + } + return nil, false +} + +// CloseOnProgramEnd closes the server right before the program ends, so that +// all data of the server are saved properly. +func (srv *Server) CloseOnProgramEnd() { + c := make(chan os.Signal, 2) + signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-c + if err := srv.Close(); err != nil { + srv.conf.Log.Error("close server: " + err.Error()) + } + }() +} + +// Close closes the server, making any call to Run/Accept cancel immediately. +func (srv *Server) Close() error { + if srv.started.Load() == nil { + panic("server not yet running") + } + srv.once.Do(srv.close) + return nil +} + +// close stops the server, storing player and world data to disk. +func (srv *Server) close() { + srv.conf.Log.Info("Server closing...") + + srv.conf.Log.Debug("Disconnecting players...") + for p := range srv.Players(nil) { + p.Disconnect(chat.MessageServerDisconnect.Resolve(p.Locale())) + } + srv.pwg.Wait() + + srv.conf.Log.Debug("Closing player provider...") + if err := srv.conf.PlayerProvider.Close(); err != nil { + srv.conf.Log.Error("Close player provider: " + err.Error()) + } + + srv.conf.Log.Debug("Closing worlds...") + for _, w := range []*world.World{srv.end, srv.nether, srv.world} { + if err := w.Close(); err != nil { + srv.conf.Log.Error(fmt.Sprintf("Close dimension %v: ", w.Dimension()) + err.Error()) + } + } + + srv.conf.Log.Debug("Closing listeners...") + for _, l := range srv.listeners { + if err := l.Close(); err != nil { + srv.conf.Log.Error("Close listener: " + err.Error()) + } + } +} + +// listen makes the Server listen for new connections from the Listener passed. +// This may be used to listen for players on different interfaces. Note that +// the maximum player count of additional Listeners added is not enforced +// automatically. The limit must be enforced by the Listener. +func (srv *Server) listen(l Listener) { + wg := new(sync.WaitGroup) + ctx, cancel := context.WithCancel(context.Background()) + for { + c, err := l.Accept() + if err != nil { + // Cancel the context so that any call to StartGameContext is + // cancelled rapidly. + cancel() + // First wait until all connections that are being handled are done + // inserting the player into the channel. Afterwards, when we're + // sure no more values will be inserted in the players channel, we + // can return so the player channel can be closed. + wg.Wait() + srv.wg.Done() + return + } + + wg.Add(1) + go func() { + defer wg.Done() + if msg, ok := srv.conf.Allower.Allow(c.RemoteAddr(), c.IdentityData(), c.ClientData()); !ok { + _ = c.WritePacket(&packet.Disconnect{HideDisconnectionScreen: msg == "", Message: msg}) + _ = c.Close() + return + } + srv.finaliseConn(ctx, c, l) + }() + } +} + +// startListening starts making the EncodeBlock listener listen, accepting new +// connections from players. +func (srv *Server) startListening() { + srv.makeBlockEntries() + srv.makeItemComponents() + + srv.wg.Add(len(srv.listeners)) + for _, l := range srv.listeners { + go srv.listen(l) + } +} + +// makeBlockEntries initialises the server's block components map using the +// registered custom blocks. It allows block components to be created only once +// at startup. +func (srv *Server) makeBlockEntries() { + custom := slices.Collect(maps.Values(srv.conf.Blocks.CustomBlocks())) + srv.customBlocks = make([]protocol.BlockEntry, len(custom)) + + for i, b := range custom { + name, _ := b.EncodeBlock() + srv.customBlocks[i] = protocol.BlockEntry{ + Name: name, + Properties: blockinternal.Components(name, b, 10000+int32(i)), + } + } +} + +// makeItemComponents initialises the server's item components map using the +// registered custom items. It allows item components to be created only once +// at startup +func (srv *Server) makeItemComponents() { + custom := world.CustomItems() + srv.customItems = make([]protocol.ItemEntry, len(custom)) + + for _, it := range custom { + name, _ := it.EncodeItem() + rid, _, _ := world.ItemRuntimeID(it) + _, isCustomBlock := it.(world.CustomBlock) + var entryVersion int32 = protocol.ItemEntryVersionDataDriven + if isCustomBlock { + entryVersion = protocol.ItemEntryVersionNone + } + srv.customItems = append(srv.customItems, protocol.ItemEntry{ + Name: name, + ComponentBased: !isCustomBlock, + RuntimeID: int16(rid), + Version: entryVersion, + Data: iteminternal.Components(it), + }) + } +} + +// wait awaits the closing of all Listeners added to the Server through a call +// to listen and closed the players channel once that happens. +func (srv *Server) wait() { + srv.wg.Wait() + srv.conf.Log.Info("Server closed.", "uptime", time.Since(*srv.started.Load()).String()) + close(srv.incoming) +} + +// finaliseConn finalises the session.Conn passed and adds it to the incoming +// channel. +func (srv *Server) finaliseConn(ctx context.Context, conn session.Conn, l Listener) { + id := uuid.MustParse(conn.IdentityData().Identity) + data := srv.defaultGameData() + + d, w, err := srv.conf.PlayerProvider.Load(id, srv.dimension) + if err != nil { + w = srv.world + d.Position = w.Spawn().Vec3Centre() + d.GameMode = w.DefaultGameMode() + } + + data.PlayerPosition = vec64To32(d.Position).Add(mgl32.Vec3{0, 1.62}) + dim, _ := world.DimensionID(w.Dimension()) + data.Dimension = int32(dim) + data.Yaw, data.Pitch = float32(d.Rotation.Yaw()), float32(d.Rotation.Pitch()) + + data.EmoteChatMuted = srv.conf.MuteEmoteChat + + if err := conn.StartGameContext(ctx, data); err != nil { + _ = l.Disconnect(conn, "Connection timeout.") + + srv.conf.Log.Debug("spawn failed: "+err.Error(), "raddr", conn.RemoteAddr()) + return + } + if _, ok := srv.Player(id); ok { + _ = l.Disconnect(conn, "Already logged in.") + srv.conf.Log.Debug("spawn failed: already logged in", "raddr", conn.RemoteAddr()) + return + } + _ = conn.WritePacket(&packet.ItemRegistry{Items: srv.customItems}) + srv.incoming <- srv.createPlayer(id, conn, d, w) +} + +// defaultGameData returns a minecraft.GameData as sent for a new player. It +// may later be modified if the player was saved in the player provider of the +// server. +func (srv *Server) defaultGameData() minecraft.GameData { + gm, _ := world.GameModeID(srv.world.DefaultGameMode()) + return minecraft.GameData{ + // Entity runtime/unique ID for the player itself is always 1 in df. + EntityUniqueID: 1, + EntityRuntimeID: 1, + + WorldName: srv.conf.Name, + BaseGameVersion: protocol.CurrentVersion, + + Time: int64(srv.world.Time()), + Difficulty: 2, + + PlayerGameMode: int32(gm), + PlayerPermissions: packet.PermissionLevelMember, + PlayerPosition: vec64To32(srv.world.Spawn().Vec3Centre().Add(mgl64.Vec3{0, 1.62})), + + Items: srv.itemEntries(), + CustomBlocks: srv.customBlocks, + GameRules: []protocol.GameRule{ + {Name: "naturalregeneration", Value: false}, + {Name: "locatorBar", Value: false}, + }, + + ServerAuthoritativeInventory: true, + PlayerMovementSettings: protocol.PlayerMovementSettings{ + ServerAuthoritativeBlockBreaking: true, + }, + } +} + +// dimension returns a world by a dimension passed. +func (srv *Server) dimension(dimension world.Dimension) *world.World { + switch dimension { + default: + return srv.world + case world.Nether: + return srv.nether + case world.End: + return srv.end + } +} + +// handleSessionClose handles the closing of a session. It removes the player +// of the session from the server. +func (srv *Server) handleSessionClose(tx *world.Tx, c session.Controllable) { + srv.pmu.Lock() + _, ok := srv.p[c.UUID()] + delete(srv.p, c.UUID()) + srv.pmu.Unlock() + if !ok { + // When a player disconnects immediately after a session is started, it + // might not be added to the players map yet. This is expected, but we + // need to be careful not to crash when this happens. + return + } + + if err := srv.conf.PlayerProvider.Save(c.UUID(), c.(*player.Player).Data(), tx.World()); err != nil { + srv.conf.Log.Error("Save player data: " + err.Error()) + } + srv.pwg.Done() +} + +// createPlayer creates a new player instance using the UUID and connection +// passed. +func (srv *Server) createPlayer(id uuid.UUID, conn session.Conn, conf player.Config, w *world.World) incoming { + srv.pwg.Add(1) + + s := session.Config{ + Log: srv.conf.Log, + MaxChunkRadius: srv.conf.MaxChunkRadius, + EmoteChatMuted: srv.conf.MuteEmoteChat, + JoinMessage: srv.conf.JoinMessage, + QuitMessage: srv.conf.QuitMessage, + HandleStop: srv.handleSessionClose, + BlockRegistry: w.BlockRegistry(), + }.New(conn) + + conf.Name = conn.IdentityData().DisplayName + conf.XUID = conn.IdentityData().XUID + conf.UUID = id + conf.Locale, _ = language.Parse(strings.Replace(conn.ClientData().LanguageCode, "_", "-", 1)) + conf.Skin = srv.parseSkin(conn.ClientData()) + conf.Session = s + + handle := world.EntitySpawnOpts{Position: conf.Position, ID: id}.New(player.Type, conf) + s.SetHandle(handle, conf.Skin) + return incoming{s: s, w: w, conf: conf, p: &onlinePlayer{name: conf.Name, xuid: conf.XUID, handle: handle}} +} + +// createWorld loads a world with a specific dimension using the provider set +// in the Config. The nether and end dimensions point to the worlds that players +// are moved to when passing through the respective portals. +func (srv *Server) createWorld(dim world.Dimension, nether, end **world.World) *world.World { + logger := srv.conf.Log.With("dimension", strings.ToLower(fmt.Sprint(dim))) + logger.Debug("Loading dimension...") + + conf := world.Config{ + Log: logger, + Dim: dim, + Provider: srv.conf.WorldProvider, + Generator: srv.conf.Generator(dim), + RandomTickSpeed: srv.conf.RandomTickSpeed, + ReadOnly: srv.conf.ReadOnlyWorld, + SaveInterval: srv.conf.SaveInterval, + ChunkUnloadInterval: srv.conf.ChunkUnloadInterval, + Entities: srv.conf.Entities, + Blocks: srv.conf.Blocks, + PortalDestination: func(dim world.Dimension) *world.World { + switch dim { + case world.Nether: + return *nether + case world.End: + return *end + default: + return nil + } + }, + } + w := conf.New() + logger.Info("Opened dimension.", "name", w.Name()) + return w +} + +// parseSkin parses a skin from the login.ClientData and returns it. +func (srv *Server) parseSkin(data login.ClientData) skin.Skin { + // Gophertunnel guarantees the following values are valid data and are of + // the correct size. + skinResourcePatch, _ := base64.StdEncoding.DecodeString(data.SkinResourcePatch) + + playerSkin := skin.New(data.SkinImageWidth, data.SkinImageHeight) + playerSkin.Persona = data.PersonaSkin + playerSkin.Pix, _ = base64.StdEncoding.DecodeString(data.SkinData) + playerSkin.Model, _ = base64.StdEncoding.DecodeString(data.SkinGeometry) + playerSkin.ModelConfig, _ = skin.DecodeModelConfig(skinResourcePatch) + playerSkin.PlayFabID = data.PlayFabID + playerSkin.FullID = data.SkinID + + playerSkin.Cape = skin.NewCape(data.CapeImageWidth, data.CapeImageHeight) + playerSkin.Cape.Pix, _ = base64.StdEncoding.DecodeString(data.CapeData) + + for _, animation := range data.AnimatedImageData { + var t skin.AnimationType + switch animation.Type { + case protocol.SkinAnimationHead: + t = skin.AnimationHead + case protocol.SkinAnimationBody32x32: + t = skin.AnimationBody32x32 + case protocol.SkinAnimationBody128x128: + t = skin.AnimationBody128x128 + } + + anim := skin.NewAnimation(animation.ImageWidth, animation.ImageHeight, animation.AnimationExpression, t) + anim.FrameCount = int(animation.Frames) + anim.Pix, _ = base64.StdEncoding.DecodeString(animation.Image) + + playerSkin.Animations = append(playerSkin.Animations, anim) + } + + return playerSkin +} + +// vec64To32 converts a mgl64.Vec3 to a mgl32.Vec3. +func vec64To32(vec3 mgl64.Vec3) mgl32.Vec3 { + return mgl32.Vec3{float32(vec3[0]), float32(vec3[1]), float32(vec3[2])} +} + +// itemEntries loads a list of all custom item entries of the server, ready to +// be sent in the StartGame packet. +func (srv *Server) itemEntries() []protocol.ItemEntry { + entries := make([]protocol.ItemEntry, 0, len(vanillaItems)) + + for name, e := range vanillaItems { + entries = append(entries, protocol.ItemEntry{ + Name: name, + RuntimeID: int16(e.RuntimeID), + ComponentBased: e.ComponentBased, + Version: e.Version, + Data: e.Data, + }) + } + entries = append(entries, srv.customItems...) + return entries +} + +var ( + //go:embed world/vanilla_items.nbt + vanillaItemsData []byte + vanillaItems = map[string]struct { + RuntimeID int32 `nbt:"runtime_id"` + ComponentBased bool `nbt:"component_based"` + Version int32 `nbt:"version"` + Data map[string]any `nbt:"data,omitempty"` + }{} +) + +// init reads all item entries from the resource JSON, and sets the according +// values in the runtime ID maps. +func init() { + _ = nbt.Unmarshal(vanillaItemsData, &vanillaItems) +} diff --git a/server/session/chunk.go b/server/session/chunk.go new file mode 100644 index 0000000..150142a --- /dev/null +++ b/server/session/chunk.go @@ -0,0 +1,254 @@ +package session + +import ( + "bytes" + + "github.com/cespare/xxhash/v2" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/chunk" + "github.com/sandertv/gophertunnel/minecraft/nbt" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// subChunkRequests is set to true to enable the sub-chunk request system. This can (likely) cause unexpected issues, +// but also solves issues with block entities such as item frames and lecterns as of v1.19.10. +const subChunkRequests = true + +// ViewChunk ... +func (s *Session) ViewChunk(pos world.ChunkPos, dim world.Dimension, blockEntities map[cube.Pos]world.Block, c *chunk.Chunk) { + if !s.conn.ClientCacheEnabled() { + s.sendNetworkChunk(pos, dim, c, blockEntities) + return + } + s.sendBlobHashes(pos, dim, c, blockEntities) +} + +// ViewSubChunks ... +func (s *Session) ViewSubChunks(centre world.SubChunkPos, offsets []protocol.SubChunkOffset, tx *world.Tx) { + r := tx.Range() + + entries := make([]protocol.SubChunkEntry, 0, len(offsets)) + transaction := make(map[uint64]struct{}) + for _, offset := range offsets { + ind := int16(centre.Y()) + int16(offset[1]) - int16(r[0])>>4 + if ind < 0 || ind > int16(r.Height()>>4) { + entries = append(entries, protocol.SubChunkEntry{Result: protocol.SubChunkResultIndexOutOfBounds, Offset: offset}) + continue + } + col, ok := s.chunkLoader.Chunk(world.ChunkPos{ + centre.X() + int32(offset[0]), + centre.Z() + int32(offset[2]), + }) + if !ok { + entries = append(entries, protocol.SubChunkEntry{Result: protocol.SubChunkResultChunkNotFound, Offset: offset}) + continue + } + entries = append(entries, s.subChunkEntry(offset, ind, col, transaction)) + } + if s.conn.ClientCacheEnabled() && len(transaction) > 0 { + s.blobMu.Lock() + s.openChunkTransactions = append(s.openChunkTransactions, transaction) + s.blobMu.Unlock() + } + dim, _ := world.DimensionID(tx.World().Dimension()) + s.writePacket(&packet.SubChunk{ + Dimension: int32(dim), + Position: protocol.SubChunkPos(centre), + CacheEnabled: s.conn.ClientCacheEnabled(), + SubChunkEntries: entries, + }) +} + +func (s *Session) subChunkEntry(offset protocol.SubChunkOffset, ind int16, col *world.Column, transaction map[uint64]struct{}) protocol.SubChunkEntry { + chunkMap := col.HeightMap() + subMapType, subMap := byte(protocol.HeightMapDataHasData), make([]int8, 256) + higher, lower := true, true + for x := uint8(0); x < 16; x++ { + for z := uint8(0); z < 16; z++ { + y, i := chunkMap.At(x, z), (uint16(z)<<4)|uint16(x) + otherInd := col.SubIndex(y) + switch { + case otherInd > ind: + subMap[i], lower = 16, false + case otherInd < ind: + subMap[i], higher = -1, false + default: + subMap[i], lower, higher = int8(y-col.SubY(otherInd)), false, false + } + } + } + if higher { + subMapType, subMap = protocol.HeightMapDataTooHigh, nil + } else if lower { + subMapType, subMap = protocol.HeightMapDataTooLow, nil + } + + sub := col.Sub()[ind] + if sub.Empty() { + return protocol.SubChunkEntry{ + Result: protocol.SubChunkResultSuccessAllAir, + HeightMapType: subMapType, + HeightMapData: subMap, + RenderHeightMapType: subMapType, + RenderHeightMapData: subMap, + Offset: offset, + } + } + + serialisedSubChunk := chunk.EncodeSubChunk(col.Chunk, chunk.NetworkEncoding, int(ind)) + + blockEntityBuf := bytes.NewBuffer(nil) + enc := nbt.NewEncoderWithEncoding(blockEntityBuf, nbt.NetworkLittleEndian) + for pos, b := range col.BlockEntities { + if n, ok := b.(world.NBTer); ok && col.SubIndex(int16(pos.Y())) == ind { + d := n.EncodeNBT() + d["x"], d["y"], d["z"] = int32(pos[0]), int32(pos[1]), int32(pos[2]) + _ = enc.Encode(d) + } + } + + entry := protocol.SubChunkEntry{ + Result: protocol.SubChunkResultSuccess, + RawPayload: append(serialisedSubChunk, blockEntityBuf.Bytes()...), + HeightMapType: subMapType, + HeightMapData: subMap, + RenderHeightMapType: subMapType, + RenderHeightMapData: subMap, + Offset: offset, + } + if s.conn.ClientCacheEnabled() { + if hash := xxhash.Sum64(serialisedSubChunk); s.trackBlob(hash, serialisedSubChunk) { + transaction[hash] = struct{}{} + + entry.BlobHash = hash + entry.RawPayload = blockEntityBuf.Bytes() + } + } + return entry +} + +// dimensionID returns the dimension ID of the world that the session is in. +func (s *Session) dimensionID(dim world.Dimension) int32 { + d, _ := world.DimensionID(dim) + return int32(d) +} + +// sendBlobHashes sends chunk blob hashes of the data of the chunk and stores the data in a map of blobs. Only +// data that the client doesn't yet have will be sent over the network. +func (s *Session) sendBlobHashes(pos world.ChunkPos, dim world.Dimension, c *chunk.Chunk, blockEntities map[cube.Pos]world.Block) { + if subChunkRequests { + biomes := chunk.EncodeBiomes(c, chunk.NetworkEncoding) + if hash := xxhash.Sum64(biomes); s.trackBlob(hash, biomes) { + s.writePacket(&packet.LevelChunk{ + Dimension: s.dimensionID(dim), + SubChunkCount: protocol.SubChunkRequestModeLimited, + Position: protocol.ChunkPos(pos), + HighestSubChunk: c.HighestFilledSubChunk(), + BlobHashes: []uint64{hash}, + RawPayload: []byte{0}, + CacheEnabled: true, + }) + return + } + } + + var ( + data = chunk.Encode(c, chunk.NetworkEncoding) + count = uint32(len(data.SubChunks)) + blobs = append(data.SubChunks, data.Biomes) + hashes = make([]uint64, len(blobs)) + m = make(map[uint64]struct{}, len(blobs)) + ) + for i, blob := range blobs { + h := xxhash.Sum64(blob) + hashes[i], m[h] = h, struct{}{} + } + + s.blobMu.Lock() + s.openChunkTransactions = append(s.openChunkTransactions, m) + if l := len(s.blobs); l > 4096 { + s.blobMu.Unlock() + s.conf.Log.Error("too many blobs pending", "n", l) + return + } + for i := range hashes { + s.blobs[hashes[i]] = blobs[i] + } + s.blobMu.Unlock() + + // Length of 1 byte for the border block count. + raw := bytes.NewBuffer(make([]byte, 1, 32)) + enc := nbt.NewEncoderWithEncoding(raw, nbt.NetworkLittleEndian) + for bp, b := range blockEntities { + if n, ok := b.(world.NBTer); ok { + d := n.EncodeNBT() + d["x"], d["y"], d["z"] = int32(bp[0]), int32(bp[1]), int32(bp[2]) + _ = enc.Encode(d) + } + } + + s.writePacket(&packet.LevelChunk{ + Dimension: s.dimensionID(dim), + Position: protocol.ChunkPos{pos.X(), pos.Z()}, + SubChunkCount: count, + CacheEnabled: true, + BlobHashes: hashes, + RawPayload: raw.Bytes(), + }) +} + +// sendNetworkChunk sends a network encoded chunk to the client. +func (s *Session) sendNetworkChunk(pos world.ChunkPos, dim world.Dimension, c *chunk.Chunk, blockEntities map[cube.Pos]world.Block) { + if subChunkRequests { + s.writePacket(&packet.LevelChunk{ + Dimension: s.dimensionID(dim), + SubChunkCount: protocol.SubChunkRequestModeLimited, + Position: protocol.ChunkPos(pos), + HighestSubChunk: c.HighestFilledSubChunk(), + RawPayload: append(chunk.EncodeBiomes(c, chunk.NetworkEncoding), 0), + }) + return + } + + data := chunk.Encode(c, chunk.NetworkEncoding) + chunkBuf := bytes.NewBuffer(nil) + for _, s := range data.SubChunks { + _, _ = chunkBuf.Write(s) + } + _, _ = chunkBuf.Write(data.Biomes) + + // Length of 1 byte for the border block count. + chunkBuf.WriteByte(0) + + enc := nbt.NewEncoderWithEncoding(chunkBuf, nbt.NetworkLittleEndian) + for bp, b := range blockEntities { + if n, ok := b.(world.NBTer); ok { + d := n.EncodeNBT() + d["x"], d["y"], d["z"] = int32(bp[0]), int32(bp[1]), int32(bp[2]) + _ = enc.Encode(d) + } + } + + s.writePacket(&packet.LevelChunk{ + Dimension: s.dimensionID(dim), + Position: protocol.ChunkPos{pos.X(), pos.Z()}, + SubChunkCount: uint32(len(data.SubChunks)), + RawPayload: append([]byte(nil), chunkBuf.Bytes()...), + }) +} + +// trackBlob attempts to track the given blob. If the player has too many pending blobs, it returns false and closes the +// connection. +func (s *Session) trackBlob(hash uint64, blob []byte) bool { + s.blobMu.Lock() + if l := len(s.blobs); l > 4096 { + s.blobMu.Unlock() + s.conf.Log.Error("too many blobs pending", "n", l) + return false + } + s.blobs[hash] = blob + s.blobMu.Unlock() + return true +} diff --git a/server/session/command.go b/server/session/command.go new file mode 100644 index 0000000..b18920f --- /dev/null +++ b/server/session/command.go @@ -0,0 +1,315 @@ +package session + +import ( + "math" + + "github.com/df-mc/dragonfly/server/cmd" + "github.com/go-gl/mathgl/mgl64" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" + "golang.org/x/text/language" +) + +// SendCommandOutput sends the output of a command to the player. It will be shown to the caller of the +// command, which might be the player or a websocket server. +func (s *Session) SendCommandOutput(output *cmd.Output, l language.Tag) { + if s == Nop { + return + } + messages := make([]protocol.CommandOutputMessage, 0, output.MessageCount()+output.ErrorCount()) + for _, message := range output.Messages() { + om := protocol.CommandOutputMessage{Success: true, Message: message.String()} + if t, ok := message.(translation); ok { + om.Message, om.Parameters = t.Resolve(l), t.Params(l) + } + messages = append(messages, om) + } + for _, err := range output.Errors() { + om := protocol.CommandOutputMessage{Message: err.Error()} + if t, ok := err.(translation); ok { + om.Message, om.Parameters = t.Resolve(l), t.Params(l) + } + messages = append(messages, om) + } + + s.writePacket(&packet.CommandOutput{ + CommandOrigin: s.handlers[packet.IDCommandRequest].(*CommandRequestHandler).origin, + OutputType: packet.CommandOutputTypeAllOutput, + SuccessCount: uint32(output.MessageCount()), + OutputMessages: messages, + }) +} + +type translation interface { + Resolve(l language.Tag) string + Params(l language.Tag) []string +} + +// BuildAvailableCommands builds an AvailableCommands packet and the runnable command map for the Source +// passed. The input map may contain aliases. It returns the AvailableCommands packet and the runnable command map +// for the commands that the Source can execute. +func BuildAvailableCommands( + commands map[string]cmd.Command, + src cmd.Source, + softEnums map[string]struct{}, +) (*packet.AvailableCommands, map[string]map[int]cmd.Runnable) { + m := make(map[string]map[int]cmd.Runnable, len(commands)) + + pk := &packet.AvailableCommands{} + + var enums []commandEnum + enumIndices := map[string]uint32{} + + var dynamicEnums []commandEnum + dynamicEnumIndices := map[string]uint32{} + + suffixIndices := map[string]uint32{} + + for alias, c := range commands { + if c.Name() != alias { + // Don't add duplicate entries for aliases. + continue + } + + if run := c.Runnables(src); len(run) > 0 { + m[alias] = run + } else { + continue + } + + params := c.Params(src) + overloads := make([]protocol.CommandOverload, len(params)) + + aliasesIndex := uint32(math.MaxUint32) + if len(c.Aliases()) > 0 { + aliasesIndex = uint32(len(enumIndices)) + enumIndices[c.Name()+"Aliases"] = aliasesIndex + enums = append(enums, commandEnum{Type: c.Name() + "Aliases", Options: c.Aliases()}) + } + + for i, params := range params { + for _, paramInfo := range params { + t, enum := valueToParamType(paramInfo, src) + suffix := paramInfo.Suffix + + opt := byte(0) + if suffix != "" { + index, ok := suffixIndices[suffix] + if !ok { + index = uint32(len(pk.Suffixes)) + suffixIndices[suffix] = index + pk.Suffixes = append(pk.Suffixes, suffix) + } + t = protocol.CommandArgSuffixed | index + } else { + t |= protocol.CommandArgValid + if len(enum.Options) > 0 || enum.Type != "" { + _, dynamic := softEnums[enum.Type] + if !dynamic { + index, ok := enumIndices[enum.Type] + if !ok { + index = uint32(len(enums)) + enumIndices[enum.Type] = index + enums = append(enums, enum) + } + t |= protocol.CommandArgEnum | index + } else { + index, ok := dynamicEnumIndices[enum.Type] + if !ok { + index = uint32(len(dynamicEnums)) + dynamicEnumIndices[enum.Type] = index + dynamicEnums = append(dynamicEnums, enum) + } + t |= protocol.CommandArgSoftEnum | index + } + } + } + overloads[i].Parameters = append(overloads[i].Parameters, protocol.CommandParameter{ + Name: paramInfo.Name, + Type: t, + Optional: paramInfo.Optional, + Options: opt, + }) + } + } + pk.Commands = append(pk.Commands, protocol.Command{ + Name: c.Name(), + Description: c.Description(), + AliasesOffset: aliasesIndex, + PermissionLevel: protocol.CommandPermissionLevelAny, + Overloads: overloads, + }) + } + + pk.DynamicEnums = make([]protocol.DynamicEnum, 0, len(dynamicEnums)) + for _, e := range dynamicEnums { + pk.DynamicEnums = append(pk.DynamicEnums, protocol.DynamicEnum{Type: e.Type, Values: e.Options}) + } + + enumValueIndices := make(map[string]uint32, len(enums)*3) + pk.EnumValues = make([]string, 0, len(enumValueIndices)) + + pk.Enums = make([]protocol.CommandEnum, 0, len(enums)) + for _, enum := range enums { + protoEnum := protocol.CommandEnum{Type: enum.Type} + for _, opt := range enum.Options { + index, ok := enumValueIndices[opt] + if !ok { + index = uint32(len(pk.EnumValues)) + enumValueIndices[opt] = index + pk.EnumValues = append(pk.EnumValues, opt) + } + protoEnum.ValueIndices = append(protoEnum.ValueIndices, index) + } + pk.Enums = append(pk.Enums, protoEnum) + } + return pk, m +} + +// sendAvailableCommands sends all available commands of the server. Once sent, they will be visible in the +// /help list and will be auto-completed. +func (s *Session) sendAvailableCommands(co Controllable, softEnums map[string]struct{}) map[string]map[int]cmd.Runnable { + commands := cmd.Commands() + pk, m := BuildAvailableCommands(commands, co, softEnums) + s.writePacket(pk) + return m +} + +type commandEnum struct { + Type string + Options []string +} + +// valueToParamType finds the command argument type of the value passed and returns it, in addition to creating +// an enum if applicable. +func valueToParamType(i cmd.ParamInfo, source cmd.Source) (t uint32, enum commandEnum) { + switch i.Value.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return protocol.CommandArgTypeInt, enum + case float32, float64: + return protocol.CommandArgTypeFloat, enum + case string: + return protocol.CommandArgTypeString, enum + case cmd.Varargs: + return protocol.CommandArgTypeRawText, enum + case cmd.Target, []cmd.Target: + return protocol.CommandArgTypeTarget, enum + case bool: + return 0, commandEnum{ + Type: "Boolean", + Options: []string{"true", "false"}, + } + case mgl64.Vec3: + return protocol.CommandArgTypePosition, enum + case cmd.SubCommand: + return 0, commandEnum{ + Type: "SubCommand" + i.Name, + Options: []string{i.Name}, + } + } + if enum, ok := i.Value.(cmd.Enum); ok { + return 0, commandEnum{ + Type: enum.Type(), + Options: enum.Options(source), + } + } + return protocol.CommandArgTypeValue, enum +} + +// resendCommands resends all commands that a Session has access to if the map of runnable commands passed does not +// match with the commands that the Session is currently allowed to execute. +// True is returned if the commands were resent. +func (s *Session) resendCommands( + before map[string]map[int]cmd.Runnable, + co Controllable, + softEnums map[string]struct{}, +) (map[string]map[int]cmd.Runnable, bool) { + commands := cmd.Commands() + m := make(map[string]map[int]cmd.Runnable, len(commands)) + + for alias, c := range commands { + if c.Name() == alias { + if run := c.Runnables(co); len(run) > 0 { + m[alias] = run + } + } + } + if len(before) != len(m) { + return s.sendAvailableCommands(co, softEnums), true + } + // First check for commands that were newly added. + for name, r := range m { + for k := range r { + if _, ok := before[name][k]; !ok { + return s.sendAvailableCommands(co, softEnums), true + } + } + } + // Then check for commands that a player could execute before, but no longer can. + for name, r := range before { + for k := range r { + if _, ok := m[name][k]; !ok { + return s.sendAvailableCommands(co, softEnums), true + } + } + } + return m, false +} + +// enums returns a map of all enums exposed to the Session and records the values those enums currently hold. +func (s *Session) enums(co Controllable) (map[string]cmd.Enum, map[string][]string) { + enums, enumValues := make(map[string]cmd.Enum), make(map[string][]string) + for alias, c := range cmd.Commands() { + if c.Name() == alias { + for _, params := range c.Params(co) { + for _, paramInfo := range params { + if enum, ok := paramInfo.Value.(cmd.Enum); ok { + enums[enum.Type()] = enum + enumValues[enum.Type()] = enum.Options(co) + } + } + } + } + } + return enums, enumValues +} + +// resendEnums checks the options of the enums passed against the values that were previously recorded. If they do not +// match, and the enum is in softEnums, the enum is resent via UpdateSoftEnum. If the enum is not yet in softEnums, +// it is added and the full AvailableCommands packet is resent. +func (s *Session) resendEnums( + enums map[string]cmd.Enum, + before map[string][]string, + softEnums map[string]struct{}, + r map[string]map[int]cmd.Runnable, + c Controllable, +) map[string]map[int]cmd.Runnable { + for name, enum := range enums { + valuesBefore := before[name] + values := enum.Options(c) + before[name] = values + + changed := false + if len(valuesBefore) != len(values) { + changed = true + } else { + for k, v := range values { + if valuesBefore[k] != v { + changed = true + break + } + } + } + if !changed { + continue + } + + if _, dynamic := softEnums[name]; dynamic { + s.writePacket(&packet.UpdateSoftEnum{EnumType: name, Options: values, ActionType: packet.SoftEnumActionSet}) + } else { + softEnums[name] = struct{}{} + r = s.sendAvailableCommands(c, softEnums) + } + } + return r +} diff --git a/server/session/controllable.go b/server/session/controllable.go new file mode 100644 index 0000000..8a924bf --- /dev/null +++ b/server/session/controllable.go @@ -0,0 +1,127 @@ +package session + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/cmd" + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/inventory" + "github.com/df-mc/dragonfly/server/player/chat" + "github.com/df-mc/dragonfly/server/player/debug" + "github.com/df-mc/dragonfly/server/player/dialogue" + "github.com/df-mc/dragonfly/server/player/form" + "github.com/df-mc/dragonfly/server/player/hud" + "github.com/df-mc/dragonfly/server/player/skin" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" + "github.com/google/uuid" + "golang.org/x/text/language" +) + +// Controllable represents an entity that may be controlled by a Session. Generally, Controllable is +// implemented in the form of a Player. +// Methods in Controllable will be added as Session needs them in order to handle packets. +type Controllable interface { + Name() string + world.Entity + item.User + dialogue.Submitter + form.Submitter + cmd.Source + chat.Subscriber + hud.Renderer + debug.Renderer + + Locale() language.Tag + + SetHeldItems(right, left item.Stack) + SetHeldSlot(slot int) error + + Move(deltaPos mgl64.Vec3, deltaYaw, deltaPitch float64) + + Speed() float64 + FlightSpeed() float64 + VerticalFlightSpeed() float64 + + Sleep(pos cube.Pos) + Wake() + + Chat(msg ...any) + ExecuteCommand(commandLine string) + GameMode() world.GameMode + SetGameMode(mode world.GameMode) + Effects() []effect.Effect + + UseItem() + ReleaseItem() + UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3) + UseItemOnEntity(e world.Entity) bool + BreakBlock(pos cube.Pos) + PickBlock(pos cube.Pos) + AttackEntity(e world.Entity) bool + Drop(s item.Stack) (n int) + SwingArm() + PunchAir() + + Health() float64 + MaxHealth() float64 + Absorption() float64 + Food() int + + ExperienceLevel() int + ExperienceProgress() float64 + SetExperienceLevel(level int) + + EnchantmentSeed() int64 + ResetEnchantmentSeed() + + Respawn() *world.EntityHandle + Dead() bool + + StartSneaking() + Sneaking() bool + StopSneaking() + StartSprinting() + Sprinting() bool + StopSprinting() + StartSwimming() + Swimming() bool + StopSwimming() + StartCrawling() + Crawling() bool + StopCrawling() + StartFlying() + Flying() bool + StopFlying() + StartGliding() + Gliding() bool + StopGliding() + Jump() + + StartBreaking(pos cube.Pos, face cube.Face) + ContinueBreaking(face cube.Face) + FinishBreaking() + AbortBreaking() + + Exhaust(points float64) + + OpenSign(pos cube.Pos, frontSide bool) + EditSign(pos cube.Pos, frontText, backText string) error + TurnLecternPage(pos cube.Pos, page int) error + + EnderChestInventory() *inventory.Inventory + MoveItemsToInventory() + + // UUID returns the UUID of the controllable. It must be unique for all controllable entities present in + // the server. + UUID() uuid.UUID + // XUID returns the XBOX Live User ID of the controllable. Every controllable must have one of these if + // they are authenticated via XBOX Live, as they must be connected to an XBOX Live account. + XUID() string + // Skin returns the skin of the controllable. Each controllable must have a skin, as it defines how the + // entity looks in the world. + Skin() skin.Skin + SetSkin(skin.Skin) + + UpdateDiagnostics(Diagnostics) +} diff --git a/server/session/diagnostics.go b/server/session/diagnostics.go new file mode 100644 index 0000000..6ca757c --- /dev/null +++ b/server/session/diagnostics.go @@ -0,0 +1,29 @@ +package session + +// Diagnostics represents the latest diagnostics data of the client. +type Diagnostics struct { + // AverageFramesPerSecond is the average amount of frames per second that the client has been + // running at. + AverageFramesPerSecond float64 + // AverageServerSimTickTime is the average time that the server spends simulating a single tick + // in milliseconds. + AverageServerSimTickTime float64 + // AverageClientSimTickTime is the average time that the client spends simulating a single tick + // in milliseconds. + AverageClientSimTickTime float64 + // AverageBeginFrameTime is the average time that the client spends beginning a frame in + // milliseconds. + AverageBeginFrameTime float64 + // AverageInputTime is the average time that the client spends processing input in milliseconds. + AverageInputTime float64 + // AverageRenderTime is the average time that the client spends rendering in milliseconds. + AverageRenderTime float64 + // AverageEndFrameTime is the average time that the client spends ending a frame in milliseconds. + AverageEndFrameTime float64 + // AverageRemainderTimePercent is the average percentage of time that the client spends on + // tasks that are not accounted for. + AverageRemainderTimePercent float64 + // AverageUnaccountedTimePercent is the average percentage of time that the client spends on + // unaccounted tasks. + AverageUnaccountedTimePercent float64 +} diff --git a/server/session/enchantment_texts.go b/server/session/enchantment_texts.go new file mode 100644 index 0000000..8538237 --- /dev/null +++ b/server/session/enchantment_texts.go @@ -0,0 +1,7 @@ +// Code generated by .github/workflows/push.yml; DO NOT EDIT + +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", "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 new file mode 100644 index 0000000..2cdfb2c --- /dev/null +++ b/server/session/entity_metadata.go @@ -0,0 +1,306 @@ +package session + +import ( + "math" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/entity" + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/potion" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" + "github.com/google/uuid" + "github.com/sandertv/gophertunnel/minecraft/protocol" +) + +// parseEntityMetadata returns an entity metadata object with default values. It is equivalent to setting +// all properties to their default values and disabling all flags. +func (s *Session) parseEntityMetadata(e world.Entity) protocol.EntityMetadata { + bb := e.H().Type().BBox(e) + m := protocol.NewEntityMetadata() + + m[protocol.EntityDataKeyWidth] = float32(bb.Width()) + m[protocol.EntityDataKeyHeight] = float32(bb.Height()) + m[protocol.EntityDataKeyEffectColor] = int32(0) + m[protocol.EntityDataKeyEffectAmbience] = byte(0) + m[protocol.EntityDataKeyColorIndex] = byte(0) + + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagHasGravity) + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagClimb) + if g, ok := e.H().Type().(glint); ok && g.Glint() { + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagEnchanted) + } + if e.H().Type() == entity.LingeringPotionType { + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagLingering) + } + s.addSpecificMetadata(e, m) + if ent, ok := e.(*entity.Ent); ok { + s.addSpecificMetadata(ent.Behaviour(), m) + } + return m +} + +func (s *Session) addSpecificMetadata(e any, m protocol.EntityMetadata) { + if sn, ok := e.(sneaker); ok && sn.Sneaking() { + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagSneaking) + } + if sp, ok := e.(sprinter); ok && sp.Sprinting() { + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagSprinting) + } + if sw, ok := e.(swimmer); ok && sw.Swimming() { + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagSwimming) + } + if cr, ok := e.(crawler); ok && cr.Crawling() { + m.SetFlag(protocol.EntityDataKeyFlagsTwo, protocol.EntityDataFlagCrawling&63) + } + if gl, ok := e.(glider); ok && gl.Gliding() { + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagGliding) + } + if bb, ok := e.(baby); ok && bb.Baby() { + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagBaby) + } + if b, ok := e.(breather); ok { + m[protocol.EntityDataKeyAirSupply] = int16(b.AirSupply().Milliseconds() / 50) + m[protocol.EntityDataKeyAirSupplyMax] = int16(b.MaxAirSupply().Milliseconds() / 50) + if b.Breathing() { + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagBreathing) + } + } + if i, ok := e.(invisible); ok && i.Invisible() { + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagInvisible) + } + if i, ok := e.(immobile); ok && i.Immobile() { + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagNoAI) + } + if o, ok := e.(onFire); ok && o.OnFireDuration() > 0 { + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagOnFire) + } + if u, ok := e.(using); ok && u.UsingItem() { + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagUsingItem) + } + if c, ok := e.(arrow); ok && c.Critical() { + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagCritical) + } + if g, ok := e.(gameMode); ok { + if g.GameMode().HasCollision() { + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagHasCollision) + } + } + if o, ok := e.(orb); ok { + m[protocol.EntityDataKeyValue] = int32(o.Experience()) + } + if f, ok := e.(firework); ok { + m[protocol.EntityDataKeyDisplayTileRuntimeID] = nbtconv.WriteItem(item.NewStack(f.Firework(), 1), false) + if o, ok := e.(owned); ok && f.Attached() && o.Owner() != nil { + m[protocol.EntityDataKeyCustomDisplay] = int64(s.handleRuntimeID(o.Owner())) + } + } else if o, ok := e.(owned); ok && o.Owner() != nil { + m[protocol.EntityDataKeyOwner] = int64(s.handleRuntimeID(o.Owner())) + } + if sc, ok := e.(scaled); ok { + m[protocol.EntityDataKeyScale] = float32(sc.Scale()) + } + if t, ok := e.(tnt); ok { + m[protocol.EntityDataKeyFuseTime] = int32(t.Fuse().Milliseconds() / 50) + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagIgnited) + } + if n, ok := e.(named); ok { + name := n.NameTag() + m[protocol.EntityDataKeyName] = name + if name == "" { + m[protocol.EntityDataKeyAlwaysShowNameTag] = uint8(0) + m.UnsetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagAlwaysShowName) + m.UnsetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagShowName) + } else { + m[protocol.EntityDataKeyAlwaysShowNameTag] = uint8(1) + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagAlwaysShowName) + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagShowName) + } + } + if sc, ok := e.(scoreTag); ok { + m[protocol.EntityDataKeyScore] = sc.ScoreTag() + } + if sl, ok := e.(sleeper); ok { + if pos, ok := sl.Sleeping(); ok { + m[protocol.EntityDataKeyBedPosition] = protocol.BlockPos{int32(pos[0]), int32(pos[1]), int32(pos[2])} + + // For some reason there is no such flag in gophertunnel. + m.SetFlag(protocol.EntityDataKeyPlayerFlags, 1) + } + } + if c, ok := e.(areaEffectCloud); ok { + m[protocol.EntityDataKeyDataRadius] = float32(c.Radius()) + + // We purposely fill these in with invalid values to disable the client-sided shrinking of the cloud. + m[protocol.EntityDataKeyDataDuration] = int32(math.MaxInt32) + m[protocol.EntityDataKeyDataChangeOnPickup] = float32(math.SmallestNonzeroFloat32) + m[protocol.EntityDataKeyDataChangeRate] = float32(math.SmallestNonzeroFloat32) + + colour, am := effect.ResultingColour(c.Effects()) + m[protocol.EntityDataKeyEffectColor] = nbtconv.Int32FromRGBA(colour) + if am { + m[protocol.EntityDataKeyEffectAmbience] = byte(1) + } else { + m[protocol.EntityDataKeyEffectAmbience] = byte(0) + } + } + + 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) + m[protocol.EntityDataKeyPlayerLastDeathPosition] = vec64To32(deathPos) + m[protocol.EntityDataKeyPlayerLastDeathDimension] = int32(dim) + } + m[protocol.EntityDataKeyPlayerHasDied] = boolByte(died) + } + if p, ok := e.(splash); ok { + m[protocol.EntityDataKeyAuxValueData] = int16(p.Potion().Uint8()) + if tip := p.Potion().Uint8(); tip > 4 { + m[protocol.EntityDataKeyCustomDisplay] = tip + 1 + } + } + if eff, ok := e.(effectBearer); ok { + var packedEffects int64 + + for _, ef := range eff.Effects() { + if !ef.ParticlesHidden() { + id, found := effect.ID(ef.Type()) + if !found { + continue + } + packedEffects = (packedEffects << 7) | int64(id<<1) + if ef.Ambient() { + packedEffects |= 1 + } + } + } + m[protocol.EntityDataKeyVisibleMobEffects] = packedEffects + } + if v, ok := e.(variable); ok { + m[protocol.EntityDataKeyVariant] = v.Variant() + } + if mv, ok := e.(markVariable); ok { + m[protocol.EntityDataKeyMarkVariant] = mv.MarkVariant() + } +} + +type sneaker interface { + Sneaking() bool +} + +type sprinter interface { + Sprinting() bool +} + +type swimmer interface { + Swimming() bool +} + +type crawler interface { + Crawling() bool +} + +type glider interface { + Gliding() bool +} + +type baby interface { + Baby() bool +} + +type breather interface { + Breathing() bool + AirSupply() time.Duration + MaxAirSupply() time.Duration +} + +type immobile interface { + Immobile() bool +} + +type invisible interface { + Invisible() bool +} + +type scaled interface { + Scale() float64 +} + +type owned interface { + Owner() *world.EntityHandle +} + +type named interface { + NameTag() string +} + +type scoreTag interface { + ScoreTag() string +} + +type splash interface { + Potion() potion.Potion +} + +type glint interface { + Glint() bool +} + +type areaEffectCloud interface { + effectBearer + Radius() float64 +} + +type onFire interface { + OnFireDuration() time.Duration +} + +type effectBearer interface { + Effects() []effect.Effect +} + +type using interface { + UsingItem() bool +} + +type arrow interface { + Critical() bool +} + +type orb interface { + Experience() int +} + +type firework interface { + Firework() item.Firework + Attached() bool +} + +type gameMode interface { + GameMode() world.GameMode +} + +type sleeper interface { + Sleeping() (cube.Pos, bool) +} + +type tnt interface { + Fuse() time.Duration +} + +type living interface { + UUID() uuid.UUID + DeathPosition() (mgl64.Vec3, world.Dimension, bool) +} + +type variable interface { + Variant() int32 +} + +type markVariable interface { + MarkVariant() int32 +} diff --git a/server/session/handler.go b/server/session/handler.go new file mode 100644 index 0000000..712cabf --- /dev/null +++ b/server/session/handler.go @@ -0,0 +1,13 @@ +package session + +import ( + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// packetHandler represents a type that is able to handle a specific type of incoming packets from the client. +type packetHandler interface { + // Handle handles an incoming packet from the client. The session of the client is passed to the packetHandler. + // Handle returns an error if the packet was in any way invalid. + Handle(p packet.Packet, s *Session, tx *world.Tx, c Controllable) error +} diff --git a/server/session/handler_anvil.go b/server/session/handler_anvil.go new file mode 100644 index 0000000..9086080 --- /dev/null +++ b/server/session/handler_anvil.go @@ -0,0 +1,270 @@ +package session + +import ( + "fmt" + "math/rand/v2" + + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/sandertv/gophertunnel/minecraft/protocol" +) + +const ( + // anvilInputSlot is the slot index of the input item in the anvil. + anvilInputSlot = 0x1 + // anvilMaterialSlot is the slot index of the material in the anvil. + anvilMaterialSlot = 0x2 +) + +// handleCraftRecipeOptional handles the CraftRecipeOptional request action, sent when taking a result from an anvil +// menu. It also contains information such as the new name of the item and the multi-recipe network ID. +func (h *ItemStackRequestHandler) handleCraftRecipeOptional(a *protocol.CraftRecipeOptionalStackRequestAction, s *Session, filterStrings []string, co Controllable, tx *world.Tx) (err error) { + // First check if there actually is an anvil opened. + if !s.containerOpened.Load() { + return fmt.Errorf("no anvil container opened") + } + + pos := *s.openedPos.Load() + anvil, ok := tx.Block(pos).(block.Anvil) + if !ok { + return fmt.Errorf("no anvil container opened") + } + if index := int(a.FilterStringIndex); len(filterStrings) > 0 && (index < 0 || index >= len(filterStrings)) { + return fmt.Errorf("filter string index %v is out of bounds", a.FilterStringIndex) + } + + input, _ := h.itemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerAnvilInput}, + Slot: anvilInputSlot, + }, s, tx) + if input.Empty() { + return fmt.Errorf("no item in input input slot") + } + material, _ := h.itemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerAnvilMaterial}, + Slot: anvilMaterialSlot, + }, s, tx) + result := input + + // The sum of the input's anvil cost as well as the material's anvil cost. + anvilCost := input.AnvilCost() + if !material.Empty() { + anvilCost += material.AnvilCost() + } + + // The material input may be empty (if the player is only renaming, for example). + var actionCost, renameCost, repairCount int + if !material.Empty() { + // First check if we are trying to repair the item with a material. + if repairable, ok := input.Item().(item.Repairable); ok && repairable.RepairableBy(material) { + result, actionCost, repairCount, err = repairItemWithMaterial(input, material, result) + if err != nil { + return err + } + } else { + _, book := material.Item().(item.EnchantedBook) + _, durable := input.Item().(item.Durable) + + // Ensure that the input item is repairable, or the material item is an enchanted book. If not, this is an + // invalid scenario, and we should return an error. + enchantedBook := book && len(material.Enchantments()) > 0 + if !enchantedBook && (input.Item() != material.Item() || !durable) { + return fmt.Errorf("input item is not repairable/same type or material item is not an enchanted book") + } + + // If the material is another durable item, we just need to increase the durability of the result by the + // material's durability at 12%. + if durable && !enchantedBook { + result, actionCost = repairItemWithDurable(input, material, result) + } + + // Merge enchantments on the material item onto the result item. + var hasCompatible, hasIncompatible bool + result, hasCompatible, hasIncompatible, actionCost = mergeEnchantments(input, material, result, actionCost, enchantedBook) + + // If we don't have any compatible enchantments and the input item isn't durable, then this is an invalid + // scenario, and we should return an error. + if !durable && hasIncompatible && !hasCompatible { + return fmt.Errorf("no compatible enchantments but have incompatible ones") + } + } + } + + // If we have a filter string, then the client is intending to rename the item. + if len(filterStrings) > 0 { + renameCost = 1 + actionCost += renameCost + result = result.WithCustomName(filterStrings[int(a.FilterStringIndex)]) + } + + // Calculate the total cost. (action cost + anvil cost) + cost := actionCost + anvilCost + if cost <= 0 { + return fmt.Errorf("no action was taken") + } + + // If our only action was renaming, the cost should never exceed 40. + if renameCost == actionCost && renameCost > 0 && cost >= 40 { + cost = 39 + } + + // We can bypass the "impossible cost" limit if we're in creative mode. + c := co.GameMode().CreativeInventory() + if cost >= 40 && !c { + return fmt.Errorf("impossible cost") + } + + // Ensure we have enough levels (or if we're in creative mode, ignore the cost) to perform the action. + level := co.ExperienceLevel() + if level < cost && !c { + return fmt.Errorf("not enough experience") + } else if !c { + co.SetExperienceLevel(level - cost) + } + + // If we had a result item, we need to calculate the new anvil cost and update it on the item. + if !result.Empty() { + updatedAnvilCost := result.AnvilCost() + if !material.Empty() && updatedAnvilCost < material.AnvilCost() { + updatedAnvilCost = material.AnvilCost() + } + if renameCost != actionCost || renameCost == 0 { + updatedAnvilCost = updatedAnvilCost*2 + 1 + } + result = result.WithAnvilCost(updatedAnvilCost) + } + + // If we're not in creative mode, we have a 12% chance of the anvil degrading down one state. If that is the case, we + // need to play the related sound and update the block state. Otherwise, we play a regular anvil use sound. + if !c && rand.Float64() < 0.12 { + damaged := anvil.Break() + if _, ok := damaged.(block.Air); ok { + tx.PlaySound(pos.Vec3Centre(), sound.AnvilBreak{}) + } else { + tx.PlaySound(pos.Vec3Centre(), sound.AnvilUse{}) + } + defer tx.SetBlock(pos, damaged, nil) + } else { + tx.PlaySound(pos.Vec3Centre(), sound.AnvilUse{}) + } + + h.setItemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerAnvilInput}, + Slot: anvilInputSlot, + }, item.Stack{}, s, tx) + if repairCount > 0 { + h.setItemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerAnvilMaterial}, + Slot: anvilMaterialSlot, + }, material.Grow(-repairCount), s, tx) + } else { + h.setItemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerAnvilMaterial}, + Slot: anvilMaterialSlot, + }, item.Stack{}, s, tx) + } + return h.createResults(s, tx, result) +} + +// repairItemWithMaterial is a helper function that repairs an item stack with a given material stack. It returns the new item +// stack, the cost, and the repaired items count. +func repairItemWithMaterial(input item.Stack, material item.Stack, result item.Stack) (item.Stack, int, int, error) { + // Calculate the durability delta using the maximum durability and the current durability. + delta := min(input.MaxDurability()-input.Durability(), input.MaxDurability()/4) + if delta <= 0 { + return item.Stack{}, 0, 0, fmt.Errorf("input item is already fully repaired") + } + + // While the durability delta is more than zero and the repaired count is under the material count, increase + // the durability of the result by the durability delta. + var cost, count int + for ; delta > 0 && count < material.Count(); count, delta = count+1, min(result.MaxDurability()-result.Durability(), result.MaxDurability()/4) { + result = result.WithDurability(result.Durability() + delta) + cost++ + } + return result, cost, count, nil +} + +// repairItemWithDurable is a helper function that repairs an item with another durable item stack. +func repairItemWithDurable(input item.Stack, durable item.Stack, result item.Stack) (item.Stack, int) { + durability := input.Durability() + durable.Durability() + input.MaxDurability()*12/100 + if durability > input.MaxDurability() { + durability = input.MaxDurability() + } + + // Ensure the durability is higher than the input's current durability. + var cost int + if durability > input.Durability() { + result = result.WithDurability(durability) + cost += 2 + } + return result, cost +} + +// mergeEnchantments merges the enchantments of the material item stack onto the result item stack and returns the result +// item stack, booleans indicating whether the enchantments had any compatible or incompatible enchantments, and the cost. +func mergeEnchantments(input item.Stack, material item.Stack, result item.Stack, cost int, enchantedBook bool) (item.Stack, bool, bool, int) { + var hasCompatible, hasIncompatible bool + for _, enchant := range material.Enchantments() { + // First ensure that the enchantment type is compatible with the input item. + enchantType := enchant.Type() + compatible := enchantType.CompatibleWithItem(input.Item()) + if _, ok := input.Item().(item.EnchantedBook); ok { + compatible = true + } + + // Then ensure that each input enchantment is compatible with this material enchantment. If one is not compatible, + // increase the cost by one. + for _, otherEnchant := range input.Enchantments() { + if otherType := otherEnchant.Type(); enchantType != otherType && !enchantType.CompatibleWithEnchantment(otherType) { + compatible = false + cost++ + } + } + + // Skip the enchantment if it isn't compatible with enchantments on the input item. + if !compatible { + hasIncompatible = true + continue + } + hasCompatible = true + + resultLevel := enchant.Level() + levelCost := resultLevel + + // Check if we have an enchantment of the same type on the input item. + if existingEnchant, ok := input.Enchantment(enchantType); ok { + if existingEnchant.Level() > resultLevel || (existingEnchant.Level() == resultLevel && resultLevel == enchantType.MaxLevel()) { + // The result level is either lower than the existing enchantment's level or is higher than the maximum + // level, so skip this enchantment. + hasIncompatible = true + continue + } else if existingEnchant.Level() == resultLevel { + // If the input level is equal to the material level, increase the result level by one. + resultLevel++ + } + // Update the level cost. (result level - existing level) + levelCost = resultLevel - existingEnchant.Level() + } + + // Now calculate the rarity cost. This is just the application cost of the rarity, however if the + // material is an enchanted book, then the rarity cost gets halved. If the new rarity cost is under one, + // it is set to one. + rarityCost := enchantType.Rarity().Cost() + if enchantedBook { + rarityCost = max(1, rarityCost/2) + } + + // Update the result item with the new enchantment. + result = result.WithEnchantments(item.NewEnchantment(enchantType, resultLevel)) + + // Update the cost appropriately. + cost += rarityCost * levelCost + if input.Count() > 1 { + cost = 40 + } + } + return result, hasCompatible, hasIncompatible, cost +} diff --git a/server/session/handler_beacon.go b/server/session/handler_beacon.go new file mode 100644 index 0000000..094b872 --- /dev/null +++ b/server/session/handler_beacon.go @@ -0,0 +1,78 @@ +package session + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol" +) + +// beaconInputSlot is the slot index of the input item in the beacon. +const beaconInputSlot = 0x1b + +// handleBeaconPayment handles the selection of effects in a beacon and the removal of the item used to pay +// for those effects. +func (h *ItemStackRequestHandler) handleBeaconPayment(a *protocol.BeaconPaymentStackRequestAction, s *Session, tx *world.Tx) error { + slot := protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerBeaconPayment}, + Slot: beaconInputSlot, + } + // First check if there actually is a beacon opened. + if !s.containerOpened.Load() { + return fmt.Errorf("no beacon container opened") + } + pos := *s.openedPos.Load() + beacon, ok := tx.Block(pos).(block.Beacon) + if !ok { + return fmt.Errorf("no beacon container opened") + } + + // Check if the item present in the beacon slot is valid. + payment, _ := h.itemInSlot(slot, s, tx) + if payable, ok := payment.Item().(item.BeaconPayment); !ok || !payable.PayableForBeacon() { + return fmt.Errorf("item %#v in beacon slot cannot be used as payment", payment) + } + + // Check if the effects are valid and allowed for the beacon's level. + if !h.validBeaconEffect(a.PrimaryEffect, beacon) { + return fmt.Errorf("primary effect selected is not allowed: %v for level %v", a.PrimaryEffect, beacon.Level()) + } else if !h.validBeaconEffect(a.SecondaryEffect, beacon) || (beacon.Level() < 4 && a.SecondaryEffect != 0) { + return fmt.Errorf("secondary effect selected is not allowed: %v for level %v", a.SecondaryEffect, beacon.Level()) + } + + primary, pOk := effect.ByID(int(a.PrimaryEffect)) + secondary, sOk := effect.ByID(int(a.SecondaryEffect)) + if pOk { + beacon.Primary = primary.(effect.LastingType) + } + if sOk { + beacon.Secondary = secondary.(effect.LastingType) + } + tx.SetBlock(pos, beacon, nil) + + // The client will send a Destroy action after this action, but we can't rely on that because the client + // could just not send it. + // We just ignore the next Destroy action and set the item to air here. + h.setItemInSlot(slot, item.Stack{}, s, tx) + h.ignoreDestroy = true + return nil +} + +// validBeaconEffect checks if the ID passed is a valid beacon effect. +func (h *ItemStackRequestHandler) validBeaconEffect(id int32, beacon block.Beacon) bool { + switch id { + case 1, 3: + return beacon.Level() >= 1 + case 8, 11: + return beacon.Level() >= 2 + case 5: + return beacon.Level() >= 3 + case 10: + return beacon.Level() >= 4 + case 0: + return true + } + return false +} diff --git a/server/session/handler_block_actor_data.go b/server/session/handler_block_actor_data.go new file mode 100644 index 0000000..7c52fcd --- /dev/null +++ b/server/session/handler_block_actor_data.go @@ -0,0 +1,111 @@ +package session + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/entity" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" + "strings" + "unicode/utf8" +) + +// BlockActorDataHandler handles an incoming BlockActorData packet from the client, sent for some block entities like +// signs when they are edited. +type BlockActorDataHandler struct{} + +// Handle ... +func (b BlockActorDataHandler) Handle(p packet.Packet, s *Session, tx *world.Tx, c Controllable) error { + pk := p.(*packet.BlockActorData) + if id, ok := pk.NBTData["id"]; ok { + pos := blockPosFromProtocol(pk.Position) + if !canReach(c, pos.Vec3Middle()) { + return fmt.Errorf("block at %v is not within reach", pos) + } + if id == "Sign" { + return b.handleSign(pk, pos, s, tx, c) + } + return fmt.Errorf("unhandled block actor data ID %v", id) + } + return fmt.Errorf("block actor data without 'id' tag: %v", pk.NBTData) +} + +// handleSign handles the BlockActorData packet sent when editing a sign. +func (b BlockActorDataHandler) handleSign(pk *packet.BlockActorData, pos cube.Pos, s *Session, tx *world.Tx, co Controllable) error { + if _, ok := tx.Block(pos).(block.Sign); !ok { + s.conf.Log.Debug("no sign at position of sign block actor data", "pos", pos.String()) + return nil + } + + frontText, err := b.textFromNBTData(pk.NBTData, true) + if err != nil { + return err + } + backText, err := b.textFromNBTData(pk.NBTData, false) + if err != nil { + return err + } + if err := co.EditSign(pos, frontText, backText); err != nil { + return err + } + return nil +} + +// textFromNBTData attempts to retrieve the text from the NBT data of specific sign from the BlockActorData packet. +func (b BlockActorDataHandler) textFromNBTData(data map[string]any, frontSide bool) (string, error) { + var sideData map[string]any + var side string + if frontSide { + frontSide, ok := data["FrontText"].(map[string]any) + if !ok { + return "", fmt.Errorf("sign block actor data 'FrontText' tag was not found or was not a map: %#v", data["FrontText"]) + } + sideData = frontSide + side = "front" + } else { + backSide, ok := data["BackText"].(map[string]any) + if !ok { + return "", fmt.Errorf("sign block actor data 'BackText' tag was not found or was not a map: %#v", data["BackText"]) + } + sideData = backSide + side = "back" + } + var text string + pkText, ok := sideData["Text"] + if !ok { + return "", fmt.Errorf("sign block actor data had no 'Text' tag for side %s", side) + } + if text, ok = pkText.(string); !ok { + return "", fmt.Errorf("sign block actor data 'Text' tag was not a string for side %s: %#v", side, pkText) + } + + // Verify that the text was valid. It must be valid UTF8 and not more than 100 characters long. + text = strings.TrimRight(text, "\n") + if len(text) > 256 { + return "", fmt.Errorf("sign block actor data text was longer than 256 characters for side %s", side) + } + if !utf8.ValidString(text) { + return "", fmt.Errorf("sign block actor data text was not valid UTF8 for side %s", side) + } + return text, nil +} + +// canReach checks if a player can reach a position with its current range. The range depends on if the player +// is either survival or creative mode. +func canReach(c Controllable, pos mgl64.Vec3) bool { + const ( + creativeRange = 14.0 + survivalRange = 8.0 + ) + if !c.GameMode().AllowsInteraction() { + return false + } + + eyes := entity.EyePosition(c) + if c.GameMode().CreativeInventory() { + return eyes.Sub(pos).Len() <= creativeRange && !c.Dead() + } + return eyes.Sub(pos).Len() <= survivalRange && !c.Dead() +} diff --git a/server/session/handler_block_pick_request.go b/server/session/handler_block_pick_request.go new file mode 100644 index 0000000..3fe2807 --- /dev/null +++ b/server/session/handler_block_pick_request.go @@ -0,0 +1,17 @@ +package session + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// BlockPickRequestHandler handles the BlockPickRequest packet. +type BlockPickRequestHandler struct{} + +// Handle ... +func (b BlockPickRequestHandler) Handle(p packet.Packet, _ *Session, _ *world.Tx, c Controllable) error { + pk := p.(*packet.BlockPickRequest) + c.PickBlock(cube.Pos{int(pk.Position.X()), int(pk.Position.Y()), int(pk.Position.Z())}) + return nil +} diff --git a/server/session/handler_book_edit.go b/server/session/handler_book_edit.go new file mode 100644 index 0000000..31df5e9 --- /dev/null +++ b/server/session/handler_book_edit.go @@ -0,0 +1,76 @@ +package session + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// BookEditHandler handles the BookEdit packet. +type BookEditHandler struct{} + +// Handle ... +func (b BookEditHandler) Handle(p packet.Packet, s *Session, _ *world.Tx, _ Controllable) error { + pk := p.(*packet.BookEdit) + + it, err := s.inv.Item(int(pk.InventorySlot)) + if err != nil { + return fmt.Errorf("invalid inventory slot index %v", pk.InventorySlot) + } + book, ok := it.Item().(item.BookAndQuill) + if !ok { + return fmt.Errorf("inventory slot %v does not contain a writable book", pk.InventorySlot) + } + + page := int(pk.PageNumber) + if page >= 50 || page < 0 { + return fmt.Errorf("page number %v is out of bounds", pk.PageNumber) + } + if len(pk.Text) > 256 { + return fmt.Errorf("text can not be longer than 256 bytes") + } + + slot := int(pk.InventorySlot) + switch pk.ActionType { + case packet.BookActionReplacePage: + book = book.SetPage(page, pk.Text) + case packet.BookActionAddPage: + if len(book.Pages) >= 50 { + return fmt.Errorf("unable to add page beyond 50") + } + if page >= len(book.Pages) && page <= len(book.Pages)+2 { + book = book.SetPage(page, "") + break + } + if _, ok := book.Page(page); !ok { + return fmt.Errorf("unable to insert page at %v", pk.PageNumber) + } + book = book.InsertPage(page, pk.Text) + case packet.BookActionDeletePage: + if _, ok := book.Page(page); !ok { + // We break here instead of returning an error because the client can be a page or two ahead in the UI then + // the actual pages representation server side. The client still sends the deletion indexes. + break + } + book = book.DeletePage(page) + case packet.BookActionSwapPages: + if pk.SecondaryPageNumber >= 50 { + return fmt.Errorf("page number out of bounds") + } + _, ok := book.Page(page) + _, ok2 := book.Page(int(pk.SecondaryPageNumber)) + if !ok || !ok2 { + // We break here instead of returning an error because the client can try to swap pages that don't exist. + // This happens as a result of the client being a page or two ahead in the UI then the actual pages + // representation server side. The client still sends the swap indexes. + break + } + book = book.SwapPages(page, int(pk.SecondaryPageNumber)) + case packet.BookActionSign: + _ = s.inv.SetItem(slot, it.WithItem(item.WrittenBook{Title: pk.Title, Author: pk.Author, Pages: book.Pages, Generation: item.OriginalGeneration()})) + return nil + } + _ = s.inv.SetItem(slot, it.WithItem(book)) + return nil +} diff --git a/server/session/handler_client_cache_blob_status.go b/server/session/handler_client_cache_blob_status.go new file mode 100644 index 0000000..a8cabe5 --- /dev/null +++ b/server/session/handler_client_cache_blob_status.go @@ -0,0 +1,54 @@ +package session + +import ( + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// ClientCacheBlobStatusHandler handles the ClientCacheBlobStatus packet. +type ClientCacheBlobStatusHandler struct { +} + +// Handle ... +func (c *ClientCacheBlobStatusHandler) Handle(p packet.Packet, s *Session, _ *world.Tx, _ Controllable) error { + pk := p.(*packet.ClientCacheBlobStatus) + + resp := &packet.ClientCacheMissResponse{Blobs: make([]protocol.CacheBlob, 0, len(pk.MissHashes))} + + s.blobMu.Lock() + for _, hit := range pk.HitHashes { + delete(s.blobs, hit) + c.resolveBlob(hit, s) + } + for _, miss := range pk.MissHashes { + blob, ok := s.blobs[miss] + if !ok { + // This is expected to happen sometimes, for example when we send the same block storage or biomes a lot of + // times in a short timeframe. There is no need to log this, it'll just cause unnecessary noise that doesn't + // actually aid in terms of information. + continue + } + resp.Blobs = append(resp.Blobs, protocol.CacheBlob{Hash: miss, Payload: blob}) + c.resolveBlob(miss, s) + } + s.blobMu.Unlock() + + if len(resp.Blobs) > 0 { + s.writePacket(resp) + } + return nil +} + +// resolveBlob resolves a blob hash in the session passed. It assumes s.blobMu is locked upon calling. +func (c *ClientCacheBlobStatusHandler) resolveBlob(hash uint64, s *Session) { + leftover := make([]map[uint64]struct{}, 0, len(s.openChunkTransactions)) + for _, m := range s.openChunkTransactions { + delete(m, hash) + if len(m) != 0 { + leftover = append(leftover, m) + } + } + s.openChunkTransactions = leftover + delete(s.blobs, hash) +} diff --git a/server/session/handler_command_request.go b/server/session/handler_command_request.go new file mode 100644 index 0000000..50112ae --- /dev/null +++ b/server/session/handler_command_request.go @@ -0,0 +1,25 @@ +package session + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// CommandRequestHandler handles the CommandRequest packet. +type CommandRequestHandler struct { + origin protocol.CommandOrigin +} + +// Handle ... +func (h *CommandRequestHandler) Handle(p packet.Packet, _ *Session, _ *world.Tx, c Controllable) error { + pk := p.(*packet.CommandRequest) + if pk.Internal { + return fmt.Errorf("command request packet must never have the internal field set to true") + } + + h.origin = pk.CommandOrigin + c.ExecuteCommand(pk.CommandLine) + return nil +} diff --git a/server/session/handler_container_close.go b/server/session/handler_container_close.go new file mode 100644 index 0000000..34a2f5d --- /dev/null +++ b/server/session/handler_container_close.go @@ -0,0 +1,40 @@ +package session + +import ( + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// ContainerCloseHandler handles the ContainerClose packet. +type ContainerCloseHandler struct{} + +// Handle ... +func (h *ContainerCloseHandler) Handle(p packet.Packet, s *Session, tx *world.Tx, c Controllable) error { + pk := p.(*packet.ContainerClose) + + c.MoveItemsToInventory() + + var containerType byte + switch pk.WindowID { + case 0: + // Closing of the normal inventory. + s.invOpened = false + case byte(s.openedWindowID.Load()): + containerType = byte(s.openedContainerID.Load()) + s.closeCurrentContainer(tx, true) + case 0xff: + // Sent when an inventory/container is opened at the same time as chat. + s.invOpened = false + if s.containerOpened.Load() { + s.closeCurrentContainer(tx, false) + } + return nil + default: + containerType = pk.ContainerType + } + s.writePacket(&packet.ContainerClose{ + WindowID: pk.WindowID, + ContainerType: containerType, + }) + return nil +} diff --git a/server/session/handler_crafting.go b/server/session/handler_crafting.go new file mode 100644 index 0000000..ae90c90 --- /dev/null +++ b/server/session/handler_crafting.go @@ -0,0 +1,312 @@ +package session + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/creative" + "github.com/df-mc/dragonfly/server/item/inventory" + "github.com/df-mc/dragonfly/server/item/recipe" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "math" + "slices" +) + +// handleCraft handles the CraftRecipe request action. +func (h *ItemStackRequestHandler) handleCraft(a *protocol.CraftRecipeStackRequestAction, s *Session, tx *world.Tx) error { + craft, ok := s.recipes[a.RecipeNetworkID] + if !ok { + // Try dynamic recipes if no static recipe matches + return h.tryDynamicCraft(s, tx, int(a.NumberOfCrafts)) + } + _, shaped := craft.(recipe.Shaped) + _, shapeless := craft.(recipe.Shapeless) + if !shaped && !shapeless { + return fmt.Errorf("recipe with network id %v is not a shaped or shapeless recipe", a.RecipeNetworkID) + } + if craft.Block() != "crafting_table" { + return fmt.Errorf("recipe with network id %v is not a crafting table recipe", a.RecipeNetworkID) + } + + timesCrafted := int(a.NumberOfCrafts) + if timesCrafted < 1 { + return fmt.Errorf("times crafted must be at least 1") + } + + size := s.craftingSize() + offset := s.craftingOffset() + consumed := make([]bool, size) + for _, expected := range craft.Input() { + var processed bool + for slot := offset; slot < offset+size; slot++ { + if consumed[slot-offset] { + // We've already consumed this slot, skip it. + continue + } + has, _ := s.ui.Item(int(slot)) + if has.Empty() != expected.Empty() || has.Count() < expected.Count()*timesCrafted { + // We can't process this item, as it's not a part of the recipe. + continue + } + if !matchingStacks(has, expected) { + // Not the same item. + continue + } + processed, consumed[slot-offset] = true, true + st := has.Grow(-expected.Count() * timesCrafted) + h.setItemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerCraftingInput}, + Slot: byte(slot), + }, st, s, tx) + break + } + if !processed { + return fmt.Errorf("recipe %v: could not consume expected item: %v", a.RecipeNetworkID, expected) + } + } + return h.createResults(s, tx, repeatStacks(craft.Output(), timesCrafted)...) +} + +// handleAutoCraft handles the AutoCraftRecipe request action. +func (h *ItemStackRequestHandler) handleAutoCraft(a *protocol.AutoCraftRecipeStackRequestAction, s *Session, tx *world.Tx) error { + craft, ok := s.recipes[a.RecipeNetworkID] + if !ok { + // Try dynamic recipes if no static recipe matches + return h.tryDynamicCraft(s, tx, int(a.TimesCrafted)) + } + _, shaped := craft.(recipe.Shaped) + _, shapeless := craft.(recipe.Shapeless) + if !shaped && !shapeless { + return fmt.Errorf("recipe with network id %v is not a shaped or shapeless recipe", a.RecipeNetworkID) + } + if craft.Block() != "crafting_table" { + return fmt.Errorf("recipe with network id %v is not a crafting table recipe", a.RecipeNetworkID) + } + + timesCrafted := int(a.TimesCrafted) + if timesCrafted < 1 { + return fmt.Errorf("times crafted must be at least 1") + } + + flattenedInputs := make([]recipe.Item, 0, len(craft.Input())) + for _, i := range craft.Input() { + if i.Empty() { + // We don't actually need this item - it's empty, so avoid putting it in our flattened inputs. + continue + } + + if ind := slices.IndexFunc(flattenedInputs, func(it recipe.Item) bool { + return matchingStacks(it, i) + }); ind >= 0 { + flattenedInputs[ind] = grow(i, flattenedInputs[ind].Count()) + continue + } + flattenedInputs = append(flattenedInputs, i) + } + + for _, expected := range flattenedInputs { + remaining := expected.Count() * timesCrafted + + for id, inv := range map[byte]*inventory.Inventory{ + protocol.ContainerCraftingInput: s.ui, + protocol.ContainerCombinedHotBarAndInventory: s.inv, + } { + for slot, has := range inv.Slots() { + if has.Empty() { + // We don't have this item, skip it. + continue + } + if !matchingStacks(has, expected) { + // Not the same item. + continue + } + + removal := has.Count() + if remaining < removal { + removal = remaining + } + remaining -= removal + + has = has.Grow(-removal) + h.setItemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: id}, + Slot: byte(slot), + }, has, s, tx) + if remaining == 0 { + // Consumed this item, so go to the next one. + break + } + } + if remaining == 0 { + // Consumed this item, so go to the next one. + break + } + } + if remaining != 0 { + return fmt.Errorf("recipe %v: could not consume expected item: %v", a.RecipeNetworkID, expected) + } + } + + return h.createResults(s, tx, repeatStacks(craft.Output(), timesCrafted)...) +} + +// handleCreativeCraft handles the CreativeCraft request action. +func (h *ItemStackRequestHandler) handleCreativeCraft(a *protocol.CraftCreativeStackRequestAction, s *Session, tx *world.Tx, c Controllable) error { + if !c.GameMode().CreativeInventory() { + return fmt.Errorf("can only craft creative items in gamemode creative/spectator") + } + index := a.CreativeItemNetworkID - 1 + if int(index) >= len(creative.Items()) { + return fmt.Errorf("creative item with network ID %v does not exist", index) + } + it := creative.Items()[index].Stack + it = it.Grow(it.MaxCount() - 1) + return h.createResults(s, tx, it) +} + +// craftingSize gets the crafting size based on the opened container ID. +func (s *Session) craftingSize() uint32 { + if s.openedContainerID.Load() == 1 { + return craftingGridSizeLarge + } + return craftingGridSizeSmall +} + +// craftingOffset gets the crafting offset based on the opened container ID. +func (s *Session) craftingOffset() uint32 { + if s.openedContainerID.Load() == 1 { + return craftingGridLargeOffset + } + return craftingGridSmallOffset +} + +// matchingStacks returns true if the two stacks are the same in a crafting scenario. +func matchingStacks(has, expected recipe.Item) bool { + switch expected := expected.(type) { + case item.Stack: + switch has := has.(type) { + case recipe.ItemTag: + name, _ := expected.Item().EncodeItem() + return has.Contains(name) + case item.Stack: + _, variants := expected.Value("variants") + if !variants { + return has.Comparable(expected) + } + nameOne, _ := has.Item().EncodeItem() + nameTwo, _ := expected.Item().EncodeItem() + return nameOne == nameTwo + } + panic(fmt.Errorf("client has unexpected recipe item %T", has)) + case recipe.ItemTag: + switch has := has.(type) { + case item.Stack: + name, _ := has.Item().EncodeItem() + return expected.Contains(name) + case recipe.ItemTag: + return has.Tag() == expected.Tag() + } + panic(fmt.Errorf("client has unexpected recipe item %T", has)) + } + panic(fmt.Errorf("tried to match with unexpected recipe item %T", expected)) +} + +// repeatStacks multiplies the count of all item stacks provided by the number of repetitions provided. Item +// stacks where the new count would exceed the item's max count are split into multiple item stacks. +func repeatStacks(items []item.Stack, repetitions int) []item.Stack { + output := make([]item.Stack, 0, len(items)) + for _, o := range items { + count, maxCount := o.Count(), o.MaxCount() + total := count * repetitions + + stacks := int(math.Ceil(float64(total) / float64(maxCount))) + for i := 0; i < stacks; i++ { + inc := min(total, maxCount) + total -= inc + + output = append(output, o.Grow(inc-count)) + } + } + return output +} + +func grow(i recipe.Item, count int) recipe.Item { + switch i := i.(type) { + case item.Stack: + return i.Grow(count) + case recipe.ItemTag: + return recipe.NewItemTag(i.Tag(), i.Count()+count) + } + panic(fmt.Errorf("unexpected recipe item %T", i)) +} + +// tryDynamicCraft attempts to match the items in the crafting grid with any registered dynamic recipes. +func (h *ItemStackRequestHandler) tryDynamicCraft(s *Session, tx *world.Tx, timesCrafted int) error { + if timesCrafted < 1 { + return fmt.Errorf("times crafted must be at least 1") + } + + size := s.craftingSize() + offset := s.craftingOffset() + + // Collect all items from the crafting grid + input := make([]recipe.Item, size) + for i := uint32(0); i < size; i++ { + slot := offset + i + it, _ := s.ui.Item(int(slot)) + if it.Empty() { + input[i] = item.Stack{} + } else { + input[i] = it + } + } + + // Try to match with any dynamic recipe + for _, dynamicRecipe := range recipe.DynamicRecipes() { + if dynamicRecipe.Block() != "crafting_table" { + continue + } + + output, ok := dynamicRecipe.Match(input) + if !ok { + continue + } + + // Found a matching dynamic recipe! Now validate ingredient counts and consume the items + // For dynamic recipes, we consume all non-empty slots, but we need to ensure each slot + // has enough items to craft timesCrafted times. + minStackCount := math.MaxInt + for i := uint32(0); i < size; i++ { + slot := offset + i + it, _ := s.ui.Item(int(slot)) + if !it.Empty() { + if it.Count() < minStackCount { + minStackCount = it.Count() + } + } + } + + // Cap timesCrafted to the minimum available stack count to prevent item duplication + if minStackCount < timesCrafted { + timesCrafted = minStackCount + } + + // Now consume the validated amount from each non-empty slot + for i := uint32(0); i < size; i++ { + slot := offset + i + it, _ := s.ui.Item(int(slot)) + if !it.Empty() { + // Consume one item from this slot per craft + st := it.Grow(-1 * timesCrafted) + h.setItemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerCraftingInput}, + Slot: byte(slot), + }, st, s, tx) + } + } + + return h.createResults(s, tx, repeatStacks(output, timesCrafted)...) + } + + return fmt.Errorf("no matching recipe found for crafting grid") +} diff --git a/server/session/handler_emote.go b/server/session/handler_emote.go new file mode 100644 index 0000000..8b2ec02 --- /dev/null +++ b/server/session/handler_emote.go @@ -0,0 +1,34 @@ +package session + +import ( + "github.com/df-mc/dragonfly/server/world" + "github.com/google/uuid" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" + "time" +) + +// EmoteHandler handles the Emote packet. +type EmoteHandler struct { + LastEmote time.Time +} + +// Handle ... +func (h *EmoteHandler) Handle(p packet.Packet, _ *Session, tx *world.Tx, c Controllable) error { + pk := p.(*packet.Emote) + + if pk.EntityRuntimeID != selfEntityRuntimeID { + return errSelfRuntimeID + } + if time.Since(h.LastEmote) < time.Second { + return nil + } + h.LastEmote = time.Now() + emote, err := uuid.Parse(pk.EmoteID) + if err != nil { + return err + } + for _, viewer := range tx.Viewers(c.Position()) { + viewer.ViewEmote(c, emote) + } + return nil +} diff --git a/server/session/handler_enchanting.go b/server/session/handler_enchanting.go new file mode 100644 index 0000000..80b373c --- /dev/null +++ b/server/session/handler_enchanting.go @@ -0,0 +1,330 @@ +package session + +import ( + "fmt" + "math" + "math/rand/v2" + "slices" + + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/internal/sliceutil" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +const ( + // enchantingInputSlot is the slot index of the input item in the enchanting table. + enchantingInputSlot = 0x0e + // enchantingLapisSlot is the slot index of the lapis in the enchanting table. + enchantingLapisSlot = 0x0f +) + +// handleEnchant handles the enchantment of an item using the CraftRecipe stack request action. +func (h *ItemStackRequestHandler) handleEnchant(a *protocol.CraftRecipeStackRequestAction, s *Session, tx *world.Tx, c Controllable) error { + // First ensure that the selected slot is not out of bounds. + if a.RecipeNetworkID > 2 { + return fmt.Errorf("invalid recipe network id: %d", a.RecipeNetworkID) + } + + // Now ensure we have an input and only one input. + input, err := s.ui.Item(enchantingInputSlot) + if err != nil { + return err + } + if input.Count() > 1 { + return fmt.Errorf("enchanting tables only accept one item at a time") + } + + // Determine the available enchantments using the session's enchantment seed. + allCosts, allEnchants := s.determineAvailableEnchantments(tx, c, *s.openedPos.Load(), input) + if len(allEnchants) == 0 { + return fmt.Errorf("can't enchant non-enchantable item") + } + + // Use the slot plus one as the cost. The requirement and enchantments can be found in the results from + // determineAvailableEnchantments using the same slot index. + cost := int(a.RecipeNetworkID + 1) + requirement := allCosts[a.RecipeNetworkID] + enchants := allEnchants[a.RecipeNetworkID] + + // If we don't have infinite resources, we need to deduct Lapis Lazuli and experience. + if !c.GameMode().CreativeInventory() { + // First ensure that the experience level is both underneath the requirement and the cost. + if c.ExperienceLevel() < requirement { + return fmt.Errorf("not enough levels to meet requirement") + } + if c.ExperienceLevel() < cost { + return fmt.Errorf("not enough levels to meet cost") + } + + // Then ensure that the player has input Lapis Lazuli, and enough of it to meet the cost. + lapis, err := s.ui.Item(enchantingLapisSlot) + if err != nil { + return err + } + if _, ok := lapis.Item().(item.LapisLazuli); !ok { + return fmt.Errorf("lapis lazuli was not input") + } + if lapis.Count() < cost { + return fmt.Errorf("not enough lapis lazuli to meet cost") + } + + // Deduct the experience and Lapis Lazuli. + c.SetExperienceLevel(c.ExperienceLevel() - cost) + h.setItemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerEnchantingMaterial}, + Slot: enchantingLapisSlot, + }, lapis.Grow(-cost), s, tx) + } + + // Reset the enchantment seed so different enchantments can be selected. + c.ResetEnchantmentSeed() + + // Clear the existing input item, and apply the new item into the crafting result slot of the UI. The client will + // automatically move the item into the input slot. + h.setItemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerEnchantingInput}, + Slot: enchantingInputSlot, + }, item.Stack{}, s, tx) + + return h.createResults(s, tx, input.WithEnchantments(enchants...)) +} + +// sendEnchantmentOptions sends a list of available enchantments to the client based on the client's enchantment seed +// and nearby bookshelves. +func (s *Session) sendEnchantmentOptions(tx *world.Tx, c Controllable, pos cube.Pos, stack item.Stack) { + // First determine the available enchantments for the given item stack. + selectedCosts, selectedEnchants := s.determineAvailableEnchantments(tx, c, pos, stack) + if len(selectedEnchants) == 0 { + // No available enchantments. + return + } + + // Build the protocol variant of the enchantment options. + options := make([]protocol.EnchantmentOption, 0, 3) + for i := 0; i < 3; i++ { + // First build the enchantment instances for each selected enchantment. + enchants := make([]protocol.EnchantmentInstance, 0, len(selectedEnchants[i])) + for _, enchant := range selectedEnchants[i] { + id, _ := item.EnchantmentID(enchant.Type()) + enchants = append(enchants, protocol.EnchantmentInstance{ + Type: byte(id), + Level: byte(enchant.Level()), + }) + } + + // Then build the enchantment option. We can use the slot as the RecipeNetworkID, since the IDs seem to be unique + // to enchanting tables only. We also only need to set the middle index of Enchantments. The other two serve + // an unknown purpose and can cause various unexpected issues. + options = append(options, protocol.EnchantmentOption{ + Name: enchantNames[rand.IntN(len(enchantNames))], + Cost: uint8(selectedCosts[i]), + RecipeNetworkID: uint32(i), + Enchantments: protocol.ItemEnchantments{ + Slot: int32(i), + Enchantments: [3][]protocol.EnchantmentInstance{1: enchants}, + }, + }) + } + + // Send the enchantment options to the client. + s.writePacket(&packet.PlayerEnchantOptions{Options: options}) +} + +// determineAvailableEnchantments returns a list of pseudo-random enchantments for the given item stack. +func (s *Session) determineAvailableEnchantments(tx *world.Tx, c Controllable, pos cube.Pos, stack item.Stack) ([]int, [][]item.Enchantment) { + // First ensure that the item is enchantable and does not already have any enchantments. + enchantable, ok := stack.Item().(item.Enchantable) + if !ok { + // We can't enchant this item. + return nil, nil + } + if len(stack.Enchantments()) > 0 { + // We can't enchant this item. + return nil, nil + } + + // Search for bookshelves around the enchanting table. Bookshelves help boost the value of the enchantments that + // are selected, resulting in enchantments that are rarer but also more expensive. + seed := uint64(c.EnchantmentSeed()) + random := rand.New(rand.NewPCG(seed, seed)) + bookshelves := searchBookshelves(tx, pos) + value := enchantable.EnchantmentValue() + + // Calculate the base cost, used to calculate the upper, middle, and lower level costs. + baseCost := random.IntN(8) + 1 + (bookshelves >> 1) + random.IntN(bookshelves+1) + + // Calculate the upper, middle, and lower level costs. + upperLevelCost := max(baseCost/3, 1) + middleLevelCost := baseCost*2/3 + 1 + lowerLevelCost := max(baseCost, bookshelves*2) + + // Create a list of available enchantments for each slot. + return []int{ + upperLevelCost, + middleLevelCost, + lowerLevelCost, + }, [][]item.Enchantment{ + createEnchantments(random, stack, value, upperLevelCost), + createEnchantments(random, stack, value, middleLevelCost), + createEnchantments(random, stack, value, lowerLevelCost), + } +} + +// treasureEnchantment represents an enchantment that may be a treasure enchantment. +type treasureEnchantment interface { + item.EnchantmentType + Treasure() bool +} + +// createEnchantments creates a list of enchantments for the given item stack and returns them. +func createEnchantments(random *rand.Rand, stack item.Stack, value, level int) []item.Enchantment { + // Calculate the "random bonus" for this level. This factor is used in calculating the enchantment cost, used + // during the selection of enchantments. + randomBonus := (random.Float64() + random.Float64() - 1.0) * 0.15 + + // Calculate the enchantment cost and clamp it to ensure it is always at least one with triangular distribution. + cost := level + 1 + random.IntN(value/4+1) + random.IntN(value/4+1) + cost = clamp(int(math.Round(float64(cost)+float64(cost)*randomBonus)), 1, math.MaxInt32) + + // Books are applicable to all enchantments, so make sure we have a flag for them here. + it := stack.Item() + _, book := it.(item.Book) + + // Now that we have our enchantment cost, we need to select the available enchantments. First, we iterate through + // each possible enchantment. + availableEnchants := make([]item.Enchantment, 0, len(item.Enchantments())) + for _, enchant := range item.Enchantments() { + if t, ok := enchant.(treasureEnchantment); ok && t.Treasure() { + // We then have to ensure that the enchantment is not a treasure enchantment, as those cannot be selected through + // the enchanting table. + continue + } + if !book && !enchant.CompatibleWithItem(it) { + // The enchantment is not compatible with the item. + continue + } + + // Now iterate through each possible level of the enchantment. + for i := enchant.MaxLevel(); i > 0; i-- { + // Use the level to calculate the minimum and maximum costs for this enchantment. + if minCost, maxCost := enchant.Cost(i); cost >= minCost && cost <= maxCost { + // If the cost is within the bounds, add the enchantment to the list of available enchantments. + availableEnchants = append(availableEnchants, item.NewEnchantment(enchant, i)) + break + } + } + } + if len(availableEnchants) == 0 { + // No available enchantments, so we can't really do much here. + return nil + } + + // Now we need to select the enchantments. + selectedEnchants := make([]item.Enchantment, 0, len(availableEnchants)) + + // Select the first enchantment using a weighted random algorithm, favouring enchantments that have a higher weight. + // These weights are based on the enchantment's rarity, with common and uncommon enchantments having a higher weight + // than rare and very rare enchantments. + enchant := weightedRandomEnchantment(random, availableEnchants) + selectedEnchants = append(selectedEnchants, enchant) + + // Remove the selected enchantment from the list of available enchantments, so we don't select it again. + ind := slices.Index(availableEnchants, enchant) + availableEnchants = slices.Delete(availableEnchants, ind, ind+1) + + // Based on the cost, select a random amount of additional enchantments. + for random.IntN(50) <= cost { + // Ensure that we don't have any conflicting enchantments. If so, remove them from the list of available + // enchantments. + lastEnchant := selectedEnchants[len(selectedEnchants)-1] + if availableEnchants = sliceutil.Filter(availableEnchants, func(enchant item.Enchantment) bool { + return lastEnchant.Type().CompatibleWithEnchantment(enchant.Type()) + }); len(availableEnchants) == 0 { + // We've exhausted all available enchantments. + break + } + + // Select another enchantment using the same weighted random algorithm. + enchant = weightedRandomEnchantment(random, availableEnchants) + selectedEnchants = append(selectedEnchants, enchant) + + // Remove the selected enchantment from the list of available enchantments, so we don't select it again. + ind = slices.Index(availableEnchants, enchant) + availableEnchants = slices.Delete(availableEnchants, ind, ind+1) + + // Halve the cost, so we have a lower chance of selecting another enchantment. + cost /= 2 + } + return selectedEnchants +} + +// searchBookshelves searches for nearby bookshelves around the position passed, and returns the amount found. +func searchBookshelves(tx *world.Tx, pos cube.Pos) (shelves int) { + for x := -1; x <= 1; x++ { + for z := -1; z <= 1; z++ { + for y := 0; y <= 1; y++ { + if x == 0 && z == 0 { + // Ignore the centre block. + continue + } + if _, ok := tx.Block(pos.Add(cube.Pos{x, y, z})).(block.Air); !ok { + // There must be a one block space between the bookshelf and the player. + continue + } + + // Check for a bookshelf two blocks away. + if _, ok := tx.Block(pos.Add(cube.Pos{x * 2, y, z * 2})).(block.Bookshelf); ok { + shelves++ + } + if x != 0 && z != 0 { + // Check for a bookshelf two blocks away on the X axis. + if _, ok := tx.Block(pos.Add(cube.Pos{x * 2, y, z})).(block.Bookshelf); ok { + shelves++ + } + // Check for a bookshelf two blocks away on the Z axis. + if _, ok := tx.Block(pos.Add(cube.Pos{x, y, z * 2})).(block.Bookshelf); ok { + shelves++ + } + } + + if shelves >= 15 { + // We've found enough bookshelves. + return 15 + } + } + } + } + return shelves +} + +// weightedRandomEnchantment returns a random enchantment from the given list of enchantments using the rarity weight of +// each enchantment. +func weightedRandomEnchantment(rs *rand.Rand, enchants []item.Enchantment) item.Enchantment { + var totalWeight int + for _, e := range enchants { + totalWeight += e.Type().Rarity().Weight() + } + r := rs.IntN(totalWeight) + for _, e := range enchants { + r -= e.Type().Rarity().Weight() + if r < 0 { + return e + } + } + panic("should never happen") +} + +// clamp clamps a value into the given range. +func clamp(value, min, max int) int { + if value < min { + return min + } + if value > max { + return max + } + return value +} diff --git a/server/session/handler_grindstone.go b/server/session/handler_grindstone.go new file mode 100644 index 0000000..7787bd0 --- /dev/null +++ b/server/session/handler_grindstone.go @@ -0,0 +1,127 @@ +package session + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/world" + "math" + "math/rand/v2" + + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/entity" + "github.com/df-mc/dragonfly/server/item" + "github.com/sandertv/gophertunnel/minecraft/protocol" +) + +const ( + // grindstoneFirstInputSlot is the slot index of the first input item in the grindstone. + grindstoneFirstInputSlot = 0x10 + // grindstoneSecondInputSlot is the slot index of the second input item in the grindstone. + grindstoneSecondInputSlot = 0x11 +) + +// handleGrindstoneCraft handles a CraftGrindstoneRecipe stack request action made using a grindstone. +func (h *ItemStackRequestHandler) handleGrindstoneCraft(s *Session, tx *world.Tx, c Controllable) error { + // First check if there actually is a grindstone opened. + if !s.containerOpened.Load() { + return fmt.Errorf("no grindstone container opened") + } + if _, ok := tx.Block(*s.openedPos.Load()).(block.Grindstone); !ok { + return fmt.Errorf("no grindstone container opened") + } + + // Next, get both input items and ensure they are comparable. + firstInput, _ := h.itemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerGrindstoneInput}, + Slot: grindstoneFirstInputSlot, + }, s, tx) + secondInput, _ := h.itemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerGrindstoneAdditional}, + Slot: grindstoneSecondInputSlot, + }, s, tx) + if firstInput.Empty() && secondInput.Empty() { + return fmt.Errorf("input item(s) are empty") + } + if firstInput.Count() > 1 || secondInput.Count() > 1 { + return fmt.Errorf("input item(s) are not single items") + } + + resultStack := nonZeroItem(firstInput, secondInput) + if !firstInput.Empty() && !secondInput.Empty() { + name, meta := firstInput.Item().EncodeItem() + name2, meta2 := secondInput.Item().EncodeItem() + if name != name2 || meta != meta2 { + return fmt.Errorf("input items must be the same type") + } + if _, ok := firstInput.Item().(item.Durable); !ok { + return fmt.Errorf("input items must be durable") + } + + // We add the enchantments to the result stack in order to calculate the gained experience. These enchantments + // are stripped when creating the result. + resultStack = firstInput.WithEnchantments(secondInput.Enchantments()...) + + // Merge the durability of the two input items at 5%. + maxDurability := firstInput.MaxDurability() + firstDurability, secondDurability := firstInput.Durability(), secondInput.Durability() + + resultStack = resultStack.WithDurability(firstDurability + secondDurability + maxDurability*5/100) + } + + for _, o := range entity.NewExperienceOrbs(entity.EyePosition(c), experienceFromEnchantments(resultStack)) { + tx.AddEntity(o) + } + + h.setItemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerGrindstoneInput}, + Slot: grindstoneFirstInputSlot, + }, item.Stack{}, s, tx) + h.setItemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerGrindstoneAdditional}, + Slot: grindstoneSecondInputSlot, + }, item.Stack{}, s, tx) + return h.createResults(s, tx, stripPossibleEnchantments(resultStack)) +} + +// curseEnchantment represents an enchantment that may be a curse enchantment. +type curseEnchantment interface { + Curse() bool +} + +// experienceFromEnchantments returns the amount of experience that is gained from the enchantments on the given stack. +func experienceFromEnchantments(stack item.Stack) int { + var totalCost int + for _, enchant := range stack.Enchantments() { + if _, ok := enchant.Type().(curseEnchantment); ok { + continue + } + cost, _ := enchant.Type().Cost(enchant.Level()) + totalCost += cost + } + if totalCost == 0 { + // No cost, no experience. + return 0 + } + + minExperience := int(math.Ceil(float64(totalCost) / 2)) + return minExperience + rand.IntN(minExperience) +} + +// stripPossibleEnchantments strips all enchantments possible, excluding curses. +func stripPossibleEnchantments(stack item.Stack) item.Stack { + for _, enchant := range stack.Enchantments() { + if _, ok := enchant.Type().(curseEnchantment); ok { + continue + } + stack = stack.WithoutEnchantments(enchant.Type()) + } + return stack.WithAnvilCost(0) +} + +// nonZeroItem returns the item.Stack that exists out of two input items. The function expects at least one of the +// items to be non-empty. +func nonZeroItem(first, second item.Stack) item.Stack { + if first.Empty() { + return second + } + return first +} diff --git a/server/session/handler_interact.go b/server/session/handler_interact.go new file mode 100644 index 0000000..31ef655 --- /dev/null +++ b/server/session/handler_interact.go @@ -0,0 +1,42 @@ +package session + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// InteractHandler handles the packet.Interact. +type InteractHandler struct{} + +// Handle ... +func (h *InteractHandler) Handle(p packet.Packet, s *Session, _ *world.Tx, c Controllable) error { + pk := p.(*packet.Interact) + pos := c.Position() + + switch pk.ActionType { + case packet.InteractActionMouseOverEntity: + // We don't need this action. + case packet.InteractActionOpenInventory: + if s.invOpened { + // When there is latency, this might end up being sent multiple times. If we send a ContainerOpen + // multiple times, the client crashes. + return nil + } + s.invOpened = true + s.writePacket(&packet.ContainerOpen{ + WindowID: 0, + ContainerType: 0xff, + ContainerEntityUniqueID: -1, + ContainerPosition: protocol.BlockPos{ + int32(pos[0]), + int32(pos[1]), + int32(pos[2]), + }, + }) + default: + return fmt.Errorf("unexpected interact packet action %v", pk.ActionType) + } + return nil +} diff --git a/server/session/handler_inventory_transaction.go b/server/session/handler_inventory_transaction.go new file mode 100644 index 0000000..9cb4c38 --- /dev/null +++ b/server/session/handler_inventory_transaction.go @@ -0,0 +1,205 @@ +package session + +import ( + "fmt" + "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/inventory" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// InventoryTransactionHandler handles the InventoryTransaction packet. +type InventoryTransactionHandler struct{} + +// Handle ... +func (h *InventoryTransactionHandler) Handle(p packet.Packet, s *Session, tx *world.Tx, c Controllable) (err error) { + pk := p.(*packet.InventoryTransaction) + + if len(pk.LegacySetItemSlots) > 2 { + return fmt.Errorf("too many slot sync requests in inventory transaction") + } + + defer func() { + // The client has requested the server to resend the specified slots even if they haven't changed server-side. + // Handling these requests is necessary to ensure the client's inventory remains in sync with the server. + for _, slot := range pk.LegacySetItemSlots { + if len(slot.Slots) > 2 { + err = fmt.Errorf("too many slots in slot sync request") + return + } + switch slot.ContainerID { + case protocol.ContainerOffhand: + s.sendInv(s.offHand, protocol.WindowIDOffHand) + case protocol.ContainerInventory: + for _, slot := range slot.Slots { + if i, err := s.inv.Item(int(slot)); err == nil { + s.sendItem(i, int(slot), protocol.WindowIDInventory) + } + } + } + } + }() + + switch data := pk.TransactionData.(type) { + case *protocol.NormalTransactionData: + h.resendInventories(s) + // Always resend inventories with normal transactions. Most of the time we do not use these + // transactions, so we're best off making sure the client and server stay in sync. + if err := h.handleNormalTransaction(pk, s, c); err != nil { + s.conf.Log.Debug("process packet: InventoryTransaction: verify Normal transaction actions: " + err.Error()) + } + return + case *protocol.MismatchTransactionData: + // Just resend the inventory and don't do anything. + h.resendInventories(s) + return + case *protocol.UseItemOnEntityTransactionData: + if err = s.VerifyAndSetHeldSlot(int(data.HotBarSlot), stackToItem(s.br, data.HeldItem.Stack), c); err != nil { + return + } + return h.handleUseItemOnEntityTransaction(data, s, tx, c) + case *protocol.UseItemTransactionData: + if err = s.VerifyAndSetHeldSlot(int(data.HotBarSlot), stackToItem(s.br, data.HeldItem.Stack), c); err != nil { + return + } + return h.handleUseItemTransaction(data, s, c) + case *protocol.ReleaseItemTransactionData: + if err = s.VerifyAndSetHeldSlot(int(data.HotBarSlot), stackToItem(s.br, data.HeldItem.Stack), c); err != nil { + return + } + return h.handleReleaseItemTransaction(c) + } + return fmt.Errorf("unhandled inventory transaction type %T", pk.TransactionData) +} + +// resendInventories resends all inventories of the player. +func (h *InventoryTransactionHandler) resendInventories(s *Session) { + s.sendInv(s.inv, protocol.WindowIDInventory) + s.sendInv(s.ui, protocol.WindowIDUI) + s.sendInv(s.offHand, protocol.WindowIDOffHand) + s.sendInv(s.armour.Inventory(), protocol.WindowIDArmour) +} + +// handleNormalTransaction ... +func (h *InventoryTransactionHandler) handleNormalTransaction(pk *packet.InventoryTransaction, s *Session, c Controllable) error { + if len(pk.Actions) != 2 { + return fmt.Errorf("expected two actions for dropping an item, got %d", len(pk.Actions)) + } + + var ( + slot int + count int + expected item.Stack + ) + for _, action := range pk.Actions { + switch { + case action.SourceType == protocol.InventoryActionSourceWorld && action.InventorySlot == 0: + if old := stackToItem(s.br, action.OldItem.Stack); !old.Empty() { + return fmt.Errorf("unexpected non-empty old item in transaction action: %#v", action.OldItem) + } + count = int(action.NewItem.Stack.Count) + case action.SourceType == protocol.InventoryActionSourceContainer && action.WindowID == protocol.WindowIDInventory: + if expected = stackToItem(s.br, action.OldItem.Stack); expected.Empty() { + return fmt.Errorf("unexpected empty old item in transaction action: %#v", action.OldItem) + } + slot = int(action.InventorySlot) + default: + return fmt.Errorf("unexpected action type in drop item transaction") + } + } + + actual, _ := s.inv.Item(slot) + if count < 1 { + return fmt.Errorf("expected at least one item to be dropped, got %d", count) + } + if count > actual.Count() { + return fmt.Errorf("tried to throw %v items, but held only %v in slot", count, actual.Count()) + } + if !expected.Equal(actual) { + return fmt.Errorf("different item thrown than held in slot: %#v was thrown but held %#v", expected, actual) + } + + // Explicitly don't re-use the thrown variable. This item was supplied by the user, and if some + // logic in the Comparable() method was flawed, users would be able to cheat with item properties. + // Only grow or shrink the held item to prevent any such issues. + res := actual.Grow(count - actual.Count()) + if err := call(event.C(inventory.Holder(c)), int(*s.heldSlot), res, s.inv.Handler().HandleDrop); err != nil { + return err + } + + n := c.Drop(res) + _ = s.inv.SetItem(slot, actual.Grow(-n)) + return nil +} + +// handleUseItemOnEntityTransaction ... +func (h *InventoryTransactionHandler) handleUseItemOnEntityTransaction(data *protocol.UseItemOnEntityTransactionData, s *Session, tx *world.Tx, c Controllable) error { + s.swingingArm.Store(true) + defer s.swingingArm.Store(false) + + if data.TargetEntityRuntimeID == selfEntityRuntimeID { + return fmt.Errorf("invalid entity interaction: players cannot interact with themselves") + } + + handle, ok := s.entityFromRuntimeID(data.TargetEntityRuntimeID) + if !ok { + // In some cases, for example when a falling block entity solidifies, latency may allow attacking an entity that + // no longer exists server side. This is expected, so we shouldn't kick the player. + s.conf.Log.Debug("invalid entity interaction: no entity with runtime ID", "ID", data.TargetEntityRuntimeID) + return nil + } + e, ok := handle.Entity(tx) + if !ok { + s.conf.Log.Debug("invalid entity interaction: entity is not in the same world (anymore)", "ID", data.TargetEntityRuntimeID) + return nil + } + var valid bool + switch data.ActionType { + case protocol.UseItemOnEntityActionInteract: + valid = c.UseItemOnEntity(e) + case protocol.UseItemOnEntityActionAttack: + valid = c.AttackEntity(e) + default: + return fmt.Errorf("unhandled UseItemOnEntity ActionType %v", data.ActionType) + } + if !valid { + slot := int(*s.heldSlot) + it, _ := s.inv.Item(slot) + s.sendItem(it, slot, protocol.WindowIDInventory) + } + return nil +} + +// handleUseItemTransaction ... +func (h *InventoryTransactionHandler) handleUseItemTransaction(data *protocol.UseItemTransactionData, s *Session, c Controllable) error { + pos := cube.Pos{int(data.BlockPosition[0]), int(data.BlockPosition[1]), int(data.BlockPosition[2])} + s.swingingArm.Store(true) + defer s.swingingArm.Store(false) + + // We reset the inventory so that we can send the held item update without the client already + // having done that client-side. + // Because of the new inventory system, the client will expect a transaction confirmation, but instead of doing that + // it's much easier to just resend the inventory. + h.resendInventories(s) + + switch data.ActionType { + case protocol.UseItemActionBreakBlock: + c.BreakBlock(pos) + case protocol.UseItemActionClickBlock: + c.UseItemOnBlock(pos, cube.Face(data.BlockFace), vec32To64(data.ClickedPosition)) + case protocol.UseItemActionClickAir: + c.UseItem() + default: + return fmt.Errorf("unhandled UseItem ActionType %v", data.ActionType) + } + return nil +} + +// handleReleaseItemTransaction ... +func (h *InventoryTransactionHandler) handleReleaseItemTransaction(c Controllable) error { + c.ReleaseItem() + return nil +} diff --git a/server/session/handler_item_stack_request.go b/server/session/handler_item_stack_request.go new file mode 100644 index 0000000..5420e33 --- /dev/null +++ b/server/session/handler_item_stack_request.go @@ -0,0 +1,511 @@ +package session + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/entity" + "github.com/df-mc/dragonfly/server/event" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/inventory" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" + "math" + "time" +) + +// ItemStackRequestHandler handles the ItemStackRequest packet. It handles the actions done within the +// inventory. +type ItemStackRequestHandler struct { + currentRequest int32 + + changes map[byte]map[byte]changeInfo + responseChanges map[int32]map[*inventory.Inventory]map[byte]responseChange + + pendingResults []item.Stack + + current time.Time + ignoreDestroy bool +} + +// responseChange represents a change in a specific item stack response. It holds the timestamp of the +// response which is used to get rid of changes that the client will have received. +type responseChange struct { + id int32 + timestamp time.Time +} + +// changeInfo holds information on a slot change initiated by an item stack request. It holds both the new and the old +// item information and is used for reverting and verifying. +type changeInfo struct { + after protocol.StackResponseSlotInfo + before item.Stack +} + +// Handle ... +func (h *ItemStackRequestHandler) Handle(p packet.Packet, s *Session, tx *world.Tx, c Controllable) error { + pk := p.(*packet.ItemStackRequest) + h.current = time.Now() + + s.inTransaction.Store(true) + defer s.inTransaction.Store(false) + + for _, req := range pk.Requests { + if err := h.handleRequest(req, s, tx, c); err != nil { + // Item stacks being out of sync isn't uncommon, so don't error. Just debug the error and let the + // revert do its work. + s.conf.Log.Debug("process packet: ItemStackRequest: resolve item stack request: " + err.Error()) + } + } + return nil +} + +// handleRequest resolves a single item stack request from the client. +func (h *ItemStackRequestHandler) handleRequest(req protocol.ItemStackRequest, s *Session, tx *world.Tx, c Controllable) (err error) { + h.currentRequest = req.RequestID + defer func() { + if err != nil { + h.reject(req.RequestID, s, tx) + return + } + h.resolve(req.RequestID, s) + h.ignoreDestroy = false + }() + + for _, action := range req.Actions { + switch a := action.(type) { + case *protocol.TakeStackRequestAction: + err = h.handleTake(a, s, tx, c) + case *protocol.PlaceStackRequestAction: + err = h.handlePlace(a, s, tx, c) + case *protocol.SwapStackRequestAction: + err = h.handleSwap(a, s, tx, c) + case *protocol.DestroyStackRequestAction: + err = h.handleDestroy(a, s, tx, c) + case *protocol.DropStackRequestAction: + err = h.handleDrop(a, s, tx, c) + case *protocol.BeaconPaymentStackRequestAction: + err = h.handleBeaconPayment(a, s, tx) + case *protocol.CraftRecipeStackRequestAction: + if s.containerOpened.Load() { + var special bool + switch tx.Block(*s.openedPos.Load()).(type) { + case block.SmithingTable: + err, special = h.handleSmithing(a, s, tx), true + case block.Stonecutter: + err, special = h.handleStonecutting(a, s, tx), true + case block.EnchantingTable: + err, special = h.handleEnchant(a, s, tx, c), true + } + if special { + // This was a "special action" and was handled, so we can move onto the next action. + break + } + } + err = h.handleCraft(a, s, tx) + case *protocol.AutoCraftRecipeStackRequestAction: + err = h.handleAutoCraft(a, s, tx) + case *protocol.CraftRecipeOptionalStackRequestAction: + err = h.handleCraftRecipeOptional(a, s, req.FilterStrings, c, tx) + case *protocol.CraftLoomRecipeStackRequestAction: + err = h.handleLoomCraft(a, s, tx) + case *protocol.CraftGrindstoneRecipeStackRequestAction: + err = h.handleGrindstoneCraft(s, tx, c) + case *protocol.CraftCreativeStackRequestAction: + err = h.handleCreativeCraft(a, s, tx, c) + case *protocol.MineBlockStackRequestAction: + err = h.handleMineBlock(a, s, tx) + case *protocol.CreateStackRequestAction: + err = h.handleCreate(a, s, tx) + case *protocol.ConsumeStackRequestAction, *protocol.CraftResultsDeprecatedStackRequestAction: + // Don't do anything with this. + default: + return fmt.Errorf("unhandled stack request action %#v", action) + } + if err != nil { + err = fmt.Errorf("%T: %w", action, err) + return + } + } + return +} + +// handleTake handles a Take stack request action. +func (h *ItemStackRequestHandler) handleTake(a *protocol.TakeStackRequestAction, s *Session, tx *world.Tx, c Controllable) error { + return h.handleTransfer(a.Source, a.Destination, a.Count, s, tx, c) +} + +// handlePlace handles a Place stack request action. +func (h *ItemStackRequestHandler) handlePlace(a *protocol.PlaceStackRequestAction, s *Session, tx *world.Tx, c Controllable) error { + return h.handleTransfer(a.Source, a.Destination, a.Count, s, tx, c) +} + +// handleTransfer handles the transferring of x count from a source slot to a destination slot. +func (h *ItemStackRequestHandler) handleTransfer(from, to protocol.StackRequestSlotInfo, count byte, s *Session, tx *world.Tx, c Controllable) error { + if err := h.verifySlots(s, tx, from, to); err != nil { + return fmt.Errorf("source slot out of sync: %w", err) + } + i, _ := h.itemInSlot(from, s, tx) + dest, _ := h.itemInSlot(to, s, tx) + if !i.Comparable(dest) { + return fmt.Errorf("client tried transferring %v to %v, but the stacks are incomparable", i, dest) + } + if i.Count() < int(count) { + return fmt.Errorf("client tried subtracting %v from item count, but there are only %v", count, i.Count()) + } + if (dest.Count()+int(count) > dest.MaxCount()) && !dest.Empty() { + return fmt.Errorf("client tried adding %v to item count %v, but max is %v", count, dest.Count(), dest.MaxCount()) + } + if dest.Empty() { + dest = i.Grow(-math.MaxInt32) + } + + invA, _ := s.invByID(int32(from.Container.ContainerID), tx) + invB, _ := s.invByID(int32(to.Container.ContainerID), tx) + + ctx := event.C(inventory.Holder(c)) + _ = call(ctx, int(from.Slot), i.Grow(int(count)-i.Count()), invA.Handler().HandleTake) + err := call(ctx, int(to.Slot), i.Grow(int(count)-i.Count()), invB.Handler().HandlePlace) + if err != nil { + return err + } + + h.setItemInSlot(from, i.Grow(-int(count)), s, tx) + h.setItemInSlot(to, dest.Grow(int(count)), s, tx) + h.collectRewards(s, invA, int(from.Slot), tx, c) + return nil +} + +// handleSwap handles a Swap stack request action. +func (h *ItemStackRequestHandler) handleSwap(a *protocol.SwapStackRequestAction, s *Session, tx *world.Tx, c Controllable) error { + if err := h.verifySlots(s, tx, a.Source, a.Destination); err != nil { + return fmt.Errorf("slot out of sync: %w", err) + } + i, _ := h.itemInSlot(a.Source, s, tx) + dest, _ := h.itemInSlot(a.Destination, s, tx) + + invA, _ := s.invByID(int32(a.Source.Container.ContainerID), tx) + invB, _ := s.invByID(int32(a.Destination.Container.ContainerID), tx) + + ctx := event.C(inventory.Holder(c)) + _ = call(ctx, int(a.Source.Slot), i, invA.Handler().HandleTake) + _ = call(ctx, int(a.Source.Slot), dest, invA.Handler().HandlePlace) + _ = call(ctx, int(a.Destination.Slot), dest, invB.Handler().HandleTake) + err := call(ctx, int(a.Destination.Slot), i, invB.Handler().HandlePlace) + if err != nil { + return err + } + + h.setItemInSlot(a.Source, dest, s, tx) + h.setItemInSlot(a.Destination, i, s, tx) + h.collectRewards(s, invA, int(a.Source.Slot), tx, c) + h.collectRewards(s, invA, int(a.Destination.Slot), tx, c) + return nil +} + +// collectRewards checks if the source inventory has rewards for the player, for example, experience rewards when +// smelting. If it does, it will drop the rewards at the player's location. +func (h *ItemStackRequestHandler) collectRewards(s *Session, inv *inventory.Inventory, slot int, tx *world.Tx, c Controllable) { + if inv == s.openedWindow.Load() && s.containerOpened.Load() && slot == inv.Size()-1 { + if f, ok := tx.Block(*s.openedPos.Load()).(smelter); ok { + for _, o := range entity.NewExperienceOrbs(entity.EyePosition(c), f.ResetExperience()) { + tx.AddEntity(o) + } + } + } +} + +// handleDestroy handles the destroying of an item by moving it into the creative inventory. +func (h *ItemStackRequestHandler) handleDestroy(a *protocol.DestroyStackRequestAction, s *Session, tx *world.Tx, c Controllable) error { + if h.ignoreDestroy { + return nil + } + if !c.GameMode().CreativeInventory() { + return fmt.Errorf("can only destroy items in gamemode creative/spectator") + } + if err := h.verifySlot(a.Source, s, tx); err != nil { + return fmt.Errorf("source slot out of sync: %w", err) + } + i, _ := h.itemInSlot(a.Source, s, tx) + if i.Count() < int(a.Count) { + return fmt.Errorf("client attempted to destroy %v items, but only %v present", a.Count, i.Count()) + } + + h.setItemInSlot(a.Source, i.Grow(-int(a.Count)), s, tx) + return nil +} + +// handleDrop handles the dropping of an item by moving it outside the inventory while having the +// inventory opened. +func (h *ItemStackRequestHandler) handleDrop(a *protocol.DropStackRequestAction, s *Session, tx *world.Tx, c Controllable) error { + if err := h.verifySlot(a.Source, s, tx); err != nil { + return fmt.Errorf("source slot out of sync: %w", err) + } + i, _ := h.itemInSlot(a.Source, s, tx) + if i.Count() < int(a.Count) { + return fmt.Errorf("client attempted to drop %v items, but only %v present", a.Count, i.Count()) + } + + inv, _ := s.invByID(int32(a.Source.Container.ContainerID), tx) + if err := call(event.C(inventory.Holder(c)), int(a.Source.Slot), i.Grow(int(a.Count)-i.Count()), inv.Handler().HandleDrop); err != nil { + return err + } + + n := c.Drop(i.Grow(int(a.Count) - i.Count())) + h.setItemInSlot(a.Source, i.Grow(-n), s, tx) + return nil +} + +// handleMineBlock handles the action associated with a block being mined by the player. This seems to be a workaround +// by Mojang to deal with the durability changes client-side. +func (h *ItemStackRequestHandler) handleMineBlock(a *protocol.MineBlockStackRequestAction, s *Session, tx *world.Tx) error { + slot := protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerInventory}, + Slot: byte(a.HotbarSlot), + StackNetworkID: a.StackNetworkID, + } + if err := h.verifySlot(slot, s, tx); err != nil { + return err + } + + // Update the slots through ItemStackResponses, don't actually do anything special with this action. + i, _ := h.itemInSlot(slot, s, tx) + h.setItemInSlot(slot, i, s, tx) + return nil +} + +// handleCreate handles the CreateStackRequestAction sent by the client when a recipe outputs more than one item. It +// contains a result slot, which should map to one of the output items. From there, the server should create the relevant +// output as usual. +func (h *ItemStackRequestHandler) handleCreate(a *protocol.CreateStackRequestAction, s *Session, tx *world.Tx) error { + slot := int(a.ResultsSlot) + if slot >= len(h.pendingResults) { + return fmt.Errorf("invalid pending result slot: %v", a.ResultsSlot) + } + + res := h.pendingResults[slot] + if res.Empty() { + return fmt.Errorf("tried duplicating created result: %v", slot) + } + h.pendingResults[slot] = item.Stack{} + + h.setItemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerCreatedOutput}, + Slot: craftingResult, + }, res, s, tx) + return nil +} + +// defaultCreation represents the CreateStackRequestAction used for single-result crafts. +var defaultCreation = &protocol.CreateStackRequestAction{} + +// createResults creates a new craft result and adds it to the list of pending craft results. +func (h *ItemStackRequestHandler) createResults(s *Session, tx *world.Tx, result ...item.Stack) error { + h.pendingResults = append(h.pendingResults, result...) + if len(result) > 1 { + // With multiple results, the client notifies the server on when to create the results. + return nil + } + return h.handleCreate(defaultCreation, s, tx) +} + +// verifySlots verifies a list of slots passed. +func (h *ItemStackRequestHandler) verifySlots(s *Session, tx *world.Tx, slots ...protocol.StackRequestSlotInfo) error { + for _, slot := range slots { + if err := h.verifySlot(slot, s, tx); err != nil { + return err + } + } + return nil +} + +// verifySlot checks if the slot passed by the client is the same as that expected by the server. +func (h *ItemStackRequestHandler) verifySlot(slot protocol.StackRequestSlotInfo, s *Session, tx *world.Tx) error { + if err := h.tryAcknowledgeChanges(s, tx, slot); err != nil { + return err + } + if len(h.responseChanges) > 256 { + return fmt.Errorf("too many unacknowledged request slot changes") + } + inv, _ := s.invByID(int32(slot.Container.ContainerID), tx) + + i, err := h.itemInSlot(slot, s, tx) + if err != nil { + return err + } + clientID, err := h.resolveID(inv, slot) + if err != nil { + return err + } + // The client seems to send negative stack network IDs for predictions, which we can ignore. We'll simply + // override this network ID later. + if id := item_id(i); id != clientID { + return fmt.Errorf("stack ID mismatch: client expected %v, but server had %v", clientID, id) + } + return nil +} + +// resolveID resolves the stack network ID in the slot passed. If it is negative, it points to an earlier +// request, in which case it will look it up in the changes of an earlier response to a request to find the +// actual stack network ID in the slot. If it is positive, the ID will be returned again. +func (h *ItemStackRequestHandler) resolveID(inv *inventory.Inventory, slot protocol.StackRequestSlotInfo) (int32, error) { + if slot.StackNetworkID >= 0 { + return slot.StackNetworkID, nil + } + containerChanges, ok := h.responseChanges[slot.StackNetworkID] + if !ok { + return 0, fmt.Errorf("slot pointed to stack request %v, but request could not be found", slot.StackNetworkID) + } + changes, ok := containerChanges[inv] + if !ok { + return 0, fmt.Errorf("slot pointed to stack request %v with container %v, but that container was not changed in the request", slot.StackNetworkID, slot.Container.ContainerID) + } + actual, ok := changes[slot.Slot] + if !ok { + return 0, fmt.Errorf("slot pointed to stack request %v with container %v and slot %v, but that slot was not changed in the request", slot.StackNetworkID, slot.Container.ContainerID, slot.Slot) + } + return actual.id, nil +} + +// tryAcknowledgeChanges iterates through all cached response changes and checks if the stack request slot +// info passed from the client has the right stack network ID in any of the stored slots. If this is the case, +// that entry is removed, so that the maps are cleaned up eventually. +func (h *ItemStackRequestHandler) tryAcknowledgeChanges(s *Session, tx *world.Tx, slot protocol.StackRequestSlotInfo) error { + inv, ok := s.invByID(int32(slot.Container.ContainerID), tx) + if !ok { + return fmt.Errorf("could not find container with id %v", slot.Container.ContainerID) + } + + for requestID, containerChanges := range h.responseChanges { + for newInv, changes := range containerChanges { + for slotIndex, val := range changes { + if (slot.Slot == slotIndex && slot.StackNetworkID >= 0 && newInv == inv) || h.current.Sub(val.timestamp) > time.Second*5 { + delete(changes, slotIndex) + } + } + if len(changes) == 0 { + delete(containerChanges, newInv) + } + } + if len(containerChanges) == 0 { + delete(h.responseChanges, requestID) + } + } + return nil +} + +// itemInSlot looks for the item in the slot as indicated by the slot info passed. +func (h *ItemStackRequestHandler) itemInSlot(slot protocol.StackRequestSlotInfo, s *Session, tx *world.Tx) (item.Stack, error) { + inv, ok := s.invByID(int32(slot.Container.ContainerID), tx) + if !ok { + return item.Stack{}, fmt.Errorf("unable to find container with ID %v", slot.Container.ContainerID) + } + + sl := int(slot.Slot) + if inv == s.offHand { + sl = 0 + } + + i, err := inv.Item(sl) + if err != nil { + return i, err + } + return i, nil +} + +// setItemInSlot sets an item stack in the slot of a container present in the slot info. +func (h *ItemStackRequestHandler) setItemInSlot(slot protocol.StackRequestSlotInfo, i item.Stack, s *Session, tx *world.Tx) { + inv, _ := s.invByID(int32(slot.Container.ContainerID), tx) + + sl := int(slot.Slot) + if inv == s.offHand { + sl = 0 + } + + before, _ := inv.Item(sl) + _ = inv.SetItem(sl, i) + + respSlot := protocol.StackResponseSlotInfo{ + Slot: slot.Slot, + HotbarSlot: slot.Slot, + Count: byte(i.Count()), + StackNetworkID: item_id(i), + DurabilityCorrection: int32(i.MaxDurability() - i.Durability()), + } + + if h.changes[slot.Container.ContainerID] == nil { + h.changes[slot.Container.ContainerID] = map[byte]changeInfo{} + } + h.changes[slot.Container.ContainerID][slot.Slot] = changeInfo{ + after: respSlot, + before: before, + } + + if h.responseChanges[h.currentRequest] == nil { + h.responseChanges[h.currentRequest] = map[*inventory.Inventory]map[byte]responseChange{} + } + if h.responseChanges[h.currentRequest][inv] == nil { + h.responseChanges[h.currentRequest][inv] = map[byte]responseChange{} + } + h.responseChanges[h.currentRequest][inv][slot.Slot] = responseChange{ + id: respSlot.StackNetworkID, + timestamp: h.current, + } +} + +// resolve resolves the request with the ID passed. +func (h *ItemStackRequestHandler) resolve(id int32, s *Session) { + info := make([]protocol.StackResponseContainerInfo, 0, len(h.changes)) + for container, slotInfo := range h.changes { + slots := make([]protocol.StackResponseSlotInfo, 0, len(slotInfo)) + for _, slot := range slotInfo { + slots = append(slots, slot.after) + } + info = append(info, protocol.StackResponseContainerInfo{ + Container: protocol.FullContainerName{ContainerID: container}, + SlotInfo: slots, + }) + } + s.writePacket(&packet.ItemStackResponse{Responses: []protocol.ItemStackResponse{{ + Status: protocol.ItemStackResponseStatusOK, + RequestID: id, + ContainerInfo: info, + }}}) + + h.changes = map[byte]map[byte]changeInfo{} + h.pendingResults = nil +} + +// reject rejects the item stack request sent by the client so that it is reverted client-side. +func (h *ItemStackRequestHandler) reject(id int32, s *Session, tx *world.Tx) { + s.writePacket(&packet.ItemStackResponse{ + Responses: []protocol.ItemStackResponse{{ + Status: protocol.ItemStackResponseStatusError, + RequestID: id, + }}, + }) + + // Revert changes that we already made for valid actions. + for container, slots := range h.changes { + for slot, info := range slots { + inv, _ := s.invByID(int32(container), tx) + _ = inv.SetItem(int(slot), info.before) + } + } + + h.changes = map[byte]map[byte]changeInfo{} + h.pendingResults = nil +} + +// call uses an event.Context, slot and item.Stack to call the event handler function passed. An error is returned if +// the event.Context was cancelled either before or after the call. +func call(ctx *inventory.Context, slot int, it item.Stack, f func(ctx *inventory.Context, slot int, it item.Stack)) error { + if ctx.Cancelled() { + return fmt.Errorf("action was cancelled") + } + f(ctx, slot, it) + if ctx.Cancelled() { + return fmt.Errorf("action was cancelled") + } + return nil +} diff --git a/server/session/handler_lectern_update.go b/server/session/handler_lectern_update.go new file mode 100644 index 0000000..8bd23eb --- /dev/null +++ b/server/session/handler_lectern_update.go @@ -0,0 +1,24 @@ +package session + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// LecternUpdateHandler handles the LecternUpdate packet, sent when a player interacts with a lectern. +type LecternUpdateHandler struct{} + +// Handle ... +func (LecternUpdateHandler) Handle(p packet.Packet, _ *Session, tx *world.Tx, c Controllable) error { + pk := p.(*packet.LecternUpdate) + pos := blockPosFromProtocol(pk.Position) + if !canReach(c, pos.Vec3Middle()) { + return fmt.Errorf("block at %v is not within reach", pos) + } + if _, ok := tx.Block(pos).(block.Lectern); !ok { + return fmt.Errorf("block at %v is not a lectern", pos) + } + return c.TurnLecternPage(pos, int(pk.Page)) +} diff --git a/server/session/handler_loom.go b/server/session/handler_loom.go new file mode 100644 index 0000000..66db27d --- /dev/null +++ b/server/session/handler_loom.go @@ -0,0 +1,100 @@ +package session + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol" +) + +const ( + // loomInputSlot is the slot index of the input item in the loom table. + loomInputSlot = 0x09 + // loomDyeSlot is the slot index of the dye item in the loom table. + loomDyeSlot = 0x0a + // loomPatternSlot is the slot index of the pattern item in the loom table. + loomPatternSlot = 0x0b +) + +// handleLoomCraft handles a CraftLoomRecipe stack request action made using a loom table. +func (h *ItemStackRequestHandler) handleLoomCraft(a *protocol.CraftLoomRecipeStackRequestAction, s *Session, tx *world.Tx) error { + // First check if there actually is a loom opened. + if _, ok := tx.Block(*s.openedPos.Load()).(block.Loom); !ok || !s.containerOpened.Load() { + return fmt.Errorf("no loom container opened") + } + timesCrafted := int(a.TimesCrafted) + if timesCrafted < 1 { + return fmt.Errorf("times crafted must be least 1") + } + + // Next, check if the input slot has a valid banner item. + input, _ := h.itemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerLoomInput}, + Slot: loomInputSlot, + }, s, tx) + if input.Count() < timesCrafted { + return fmt.Errorf("input item count is less than times crafted") + } + b, ok := input.Item().(block.Banner) + if !ok { + return fmt.Errorf("input item is not a banner") + } + if b.Illager { + return fmt.Errorf("input item is an illager banner") + } + + // Do the same with the input dye. + dye, _ := h.itemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerLoomDye}, + Slot: loomDyeSlot, + }, s, tx) + if dye.Count() < timesCrafted { + return fmt.Errorf("dye item count is less than times crafted") + } + d, ok := dye.Item().(item.Dye) + if !ok { + return fmt.Errorf("dye item is not a dye") + } + + // The action contains the pattern that the client wanted to apply, so parse the ID and check if it is a valid + // pattern. + expectedPattern, exists := block.BannerPatternByID(a.Pattern) + if !exists { + return fmt.Errorf("unknown banner pattern id %q", a.Pattern) + } + + // Some banner patterns have equivalent banner pattern items that are required to craft the pattern. If the expected + // pattern has a pattern item, check if the player input the correct pattern item. + pattern, _ := h.itemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerLoomMaterial}, + Slot: loomPatternSlot, + }, s, tx) + if expectedPatternItem, hasPatternItem := expectedPattern.Item(); hasPatternItem { + if pattern.Empty() { + return fmt.Errorf("pattern item is empty but the pattern is required") + } + p, ok := pattern.Item().(item.BannerPattern) + if !ok { + return fmt.Errorf("pattern item is not a banner pattern") + } + if expectedPatternItem != p.Type { + return fmt.Errorf("pattern item does not match the expected pattern") + } + } + + // Add a new pattern layer onto the banner, and create the result. + b.Patterns = append(b.Patterns, block.BannerPatternLayer{ + Type: expectedPattern, + Colour: d.Colour, + }) + h.setItemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerLoomInput}, + Slot: loomInputSlot, + }, input.Grow(-timesCrafted), s, tx) + h.setItemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerLoomDye}, + Slot: loomDyeSlot, + }, dye.Grow(-timesCrafted), s, tx) + return h.createResults(s, tx, input.WithItem(b)) +} diff --git a/server/session/handler_mob_equipment.go b/server/session/handler_mob_equipment.go new file mode 100644 index 0000000..09ca8b7 --- /dev/null +++ b/server/session/handler_mob_equipment.go @@ -0,0 +1,29 @@ +package session + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// MobEquipmentHandler handles the MobEquipment packet. +type MobEquipmentHandler struct{} + +// Handle ... +func (*MobEquipmentHandler) Handle(p packet.Packet, s *Session, tx *world.Tx, c Controllable) error { + pk := p.(*packet.MobEquipment) + + if pk.EntityRuntimeID != selfEntityRuntimeID { + return errSelfRuntimeID + } + switch pk.WindowID { + case protocol.WindowIDOffHand: + // This window ID is expected, but we don't handle it. + return nil + case protocol.WindowIDInventory: + return s.VerifyAndSetHeldSlot(int(pk.InventorySlot), stackToItem(s.br, pk.NewItem.Stack), c) + default: + return fmt.Errorf("only main inventory should be involved in slot change, got window ID %v", pk.WindowID) + } +} diff --git a/server/session/handler_modal_form_response.go b/server/session/handler_modal_form_response.go new file mode 100644 index 0000000..9a384c9 --- /dev/null +++ b/server/session/handler_modal_form_response.go @@ -0,0 +1,45 @@ +package session + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/player/form" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" + "sync" + "sync/atomic" +) + +// ModalFormResponseHandler handles the ModalFormResponse packet. +type ModalFormResponseHandler struct { + mu sync.Mutex + forms map[uint32]form.Form + currentID atomic.Uint32 +} + +// Handle ... +func (h *ModalFormResponseHandler) Handle(p packet.Packet, _ *Session, tx *world.Tx, c Controllable) error { + pk := p.(*packet.ModalFormResponse) + + h.mu.Lock() + f, ok := h.forms[pk.FormID] + delete(h.forms, pk.FormID) + h.mu.Unlock() + + resp, exists := pk.ResponseData.Value() + if !ok && !exists { + // Sometimes the client seems to send a second response with no data, which would cause the player to be kicked + // by the server. This should patch that. + return nil + } + if !exists || len(resp) == 0 { + // The form was cancelled: The cross in the top right corner was clicked. + resp = nil + } + if !ok { + return fmt.Errorf("no form with ID %v currently opened", pk.FormID) + } + if err := f.SubmitJSON(resp, c, tx); err != nil { + return fmt.Errorf("error submitting form data: %w", err) + } + return nil +} diff --git a/server/session/handler_npc_request.go b/server/session/handler_npc_request.go new file mode 100644 index 0000000..30e9d33 --- /dev/null +++ b/server/session/handler_npc_request.go @@ -0,0 +1,32 @@ +package session + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/player/dialogue" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// NPCRequestHandler handles the NPCRequest packet. +type NPCRequestHandler struct { + dialogue dialogue.Dialogue + entityRuntimeID uint64 +} + +// Handle ... +func (h *NPCRequestHandler) Handle(p packet.Packet, s *Session, tx *world.Tx, c Controllable) error { + pk := p.(*packet.NPCRequest) + if h.entityRuntimeID == 0 { + // No dialogue is currently open for this session, so there is nothing to submit or close. + return nil + } + switch pk.RequestType { + case packet.NPCRequestActionExecuteAction: + if err := h.dialogue.Submit(uint(pk.ActionType), c, tx); err != nil { + return fmt.Errorf("error submitting dialogue: %w", err) + } + case packet.NPCRequestActionExecuteClosingCommands: + h.dialogue.Close(c, tx) + } + return nil +} diff --git a/server/session/handler_player_action.go b/server/session/handler_player_action.go new file mode 100644 index 0000000..4d38d99 --- /dev/null +++ b/server/session/handler_player_action.go @@ -0,0 +1,63 @@ +package session + +import ( + "fmt" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// PlayerActionHandler handles the PlayerAction packet. +type PlayerActionHandler struct{} + +// Handle ... +func (*PlayerActionHandler) Handle(p packet.Packet, s *Session, _ *world.Tx, c Controllable) error { + pk := p.(*packet.PlayerAction) + + return handlePlayerAction(pk.ActionType, pk.BlockFace, pk.BlockPosition, pk.EntityRuntimeID, s, c) +} + +// handlePlayerAction handles an action performed by a player, found in packet.PlayerAction and packet.PlayerAuthInput. +func handlePlayerAction(action int32, face int32, pos protocol.BlockPos, entityRuntimeID uint64, s *Session, c Controllable) error { + if entityRuntimeID != selfEntityRuntimeID { + return errSelfRuntimeID + } + switch action { + case protocol.PlayerActionStartSleeping, protocol.PlayerActionRespawn, protocol.PlayerActionDimensionChangeDone: + // Don't do anything for these actions. + case protocol.PlayerActionStopSleeping: + c.Wake() + case protocol.PlayerActionStartBreak, protocol.PlayerActionContinueDestroyBlock: + s.swingingArm.Store(true) + defer s.swingingArm.Store(false) + + s.breakingPos = cube.Pos{int(pos[0]), int(pos[1]), int(pos[2])} + c.StartBreaking(s.breakingPos, cube.Face(face)) + case protocol.PlayerActionAbortBreak: + c.AbortBreaking() + case protocol.PlayerActionPredictDestroyBlock, protocol.PlayerActionStopBreak: + s.swingingArm.Store(true) + defer s.swingingArm.Store(false) + c.FinishBreaking() + case protocol.PlayerActionCrackBreak: + // Don't do anything for this action. It is no longer used. Block + // cracking is done fully server-side. + case protocol.PlayerActionStartItemUseOn: + // TODO: Properly utilize these actions. + case protocol.PlayerActionStopItemUseOn: + c.ReleaseItem() + case protocol.PlayerActionStartBuildingBlock: + // Don't do anything for this action. + case protocol.PlayerActionCreativePlayerDestroyBlock: + // Don't do anything for this action. + case protocol.PlayerActionMissedSwing: + s.swingingArm.Store(true) + defer s.swingingArm.Store(false) + c.PunchAir() + default: + return fmt.Errorf("unhandled ActionType %v", action) + } + return nil +} diff --git a/server/session/handler_player_auth_input.go b/server/session/handler_player_auth_input.go new file mode 100644 index 0000000..f75fcba --- /dev/null +++ b/server/session/handler_player_auth_input.go @@ -0,0 +1,182 @@ +package session + +import ( + "fmt" + "math" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl32" + "github.com/go-gl/mathgl/mgl64" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// PlayerAuthInputHandler handles the PlayerAuthInput packet. +type PlayerAuthInputHandler struct{} + +// Handle ... +func (h PlayerAuthInputHandler) Handle(p packet.Packet, s *Session, tx *world.Tx, c Controllable) error { + pk := p.(*packet.PlayerAuthInput) + if err := h.handleMovement(pk, s, c); err != nil { + return err + } + return h.handleActions(pk, s, tx, c) +} + +// handleMovement handles the movement part of the packet.PlayerAuthInput. +func (h PlayerAuthInputHandler) handleMovement(pk *packet.PlayerAuthInput, s *Session, c Controllable) error { + yaw, pitch := c.Rotation().Elem() + pos := c.Position() + + reference := []float64{pitch, yaw, yaw, pos[0], pos[1], pos[2]} + for i, v := range [...]*float32{&pk.Pitch, &pk.Yaw, &pk.HeadYaw, &pk.Position[0], &pk.Position[1], &pk.Position[2]} { + f := float64(*v) + if math.IsNaN(f) || math.IsInf(f, 1) || math.IsInf(f, 0) { + // Sometimes, the PlayerAuthInput packet is in fact sent with NaN/INF after being teleported (to another + // world), see #425. For this reason, we don't actually return an error if this happens, because this will + // result in the player being kicked. Just log it and replace the NaN value with the one we have tracked + // server-side. + s.conf.Log.Debug("process packet: PlayerAuthInput: found nan/inf values. assuming server-side values", "pos", fmt.Sprint(pk.Position), "yaw", pk.Yaw, "head-yaw", pk.HeadYaw, "pitch", pk.Pitch) + *v = float32(reference[i]) + } + } + + pk.Position = pk.Position.Sub(mgl32.Vec3{0, 1.62}) // Sub the base offset of players from the pos. + + newPos := vec32To64(pk.Position) + deltaPos, deltaYaw, deltaPitch := newPos.Sub(pos), float64(pk.Yaw)-yaw, float64(pk.Pitch)-pitch + if mgl64.FloatEqual(deltaPos.Len(), 0) && mgl64.FloatEqual(deltaYaw, 0) && mgl64.FloatEqual(deltaPitch, 0) { + // The PlayerAuthInput packet is sent every tick, so don't do anything if the position and rotation + // were unchanged. + return nil + } + + if expected := s.teleportPos.Load(); expected != nil { + if newPos.Sub(*expected).Len() > 1 { + // The player has moved before it received the teleport packet. Ignore this movement entirely and + // wait for the client to sync itself back to the server. Once we get a movement that is close + // enough to the teleport position, we'll allow the player to move around again. + return nil + } + s.teleportPos.Store(nil) + } + + s.moving = true + c.Move(deltaPos, deltaYaw, deltaPitch) + return nil +} + +// handleActions handles the actions with the world that are present in the PlayerAuthInput packet. +func (h PlayerAuthInputHandler) handleActions(pk *packet.PlayerAuthInput, s *Session, tx *world.Tx, c Controllable) error { + if pk.InputData.Load(packet.InputFlagPerformItemInteraction) { + if err := h.handleUseItemData(pk.ItemInteractionData, s, c); err != nil { + return err + } + } + if pk.InputData.Load(packet.InputFlagPerformBlockActions) { + if err := h.handleBlockActions(pk.BlockActions, s, c); err != nil { + return err + } + } + h.handleInputFlags(pk.InputData, s, c) + + if pk.InputData.Load(packet.InputFlagPerformItemStackRequest) { + s.inTransaction.Store(true) + defer s.inTransaction.Store(false) + + // As of 1.18 this is now used for sending item stack requests such as when mining a block. + sh := s.handlers[packet.IDItemStackRequest].(*ItemStackRequestHandler) + if err := sh.handleRequest(pk.ItemStackRequest, s, tx, c); err != nil { + // Item stacks being out of sync isn't uncommon, so don't error. Just debug the error and let the + // revert do its work. + s.conf.Log.Debug("process packet: PlayerAuthInput: resolve item stack request: " + err.Error()) + } + } + return nil +} + +// handleInputFlags handles the toggleable input flags set in a PlayerAuthInput packet. +func (h PlayerAuthInputHandler) handleInputFlags(flags protocol.Bitset, s *Session, c Controllable) { + if flags.Load(packet.InputFlagStartSprinting) { + c.StartSprinting() + } + if flags.Load(packet.InputFlagStopSprinting) { + c.StopSprinting() + } + if flags.Load(packet.InputFlagStartSneaking) { + c.StartSneaking() + } + if flags.Load(packet.InputFlagStopSneaking) { + c.StopSneaking() + } + if flags.Load(packet.InputFlagStartSwimming) { + c.StartSwimming() + } + if flags.Load(packet.InputFlagStopSwimming) { + c.StopSwimming() + } + if flags.Load(packet.InputFlagStartGliding) { + c.StartGliding() + } + if flags.Load(packet.InputFlagStopGliding) { + c.StopGliding() + } + if flags.Load(packet.InputFlagStartJumping) { + c.Jump() + } + if flags.Load(packet.InputFlagStartCrawling) { + c.StartCrawling() + } + if flags.Load(packet.InputFlagStopCrawling) { + c.StopCrawling() + } + if flags.Load(packet.InputFlagMissedSwing) { + s.swingingArm.Store(true) + defer s.swingingArm.Store(false) + c.PunchAir() + } + if flags.Load(packet.InputFlagStartFlying) { + if !c.GameMode().AllowsFlying() { + s.conf.Log.Debug("process packet: PlayerAuthInput: flying flag enabled while unable to fly") + s.SendAbilities(c) + } else { + c.StartFlying() + } + } + if flags.Load(packet.InputFlagStopFlying) { + c.StopFlying() + } +} + +// handleUseItemData handles the protocol.UseItemTransactionData found in a packet.PlayerAuthInput. +func (h PlayerAuthInputHandler) handleUseItemData(data protocol.UseItemTransactionData, s *Session, c Controllable) error { + s.swingingArm.Store(true) + defer s.swingingArm.Store(false) + + held, _ := c.HeldItems() + if !held.Equal(stackToItem(s.br, data.HeldItem.Stack)) { + s.conf.Log.Debug("process packet: PlayerAuthInput: UseItemTransaction: mismatch between actual held item and client held item") + return nil + } + pos := cube.Pos{int(data.BlockPosition[0]), int(data.BlockPosition[1]), int(data.BlockPosition[2])} + + // Seems like this is only used for breaking blocks at the moment. + switch data.ActionType { + case protocol.UseItemActionBreakBlock: + c.BreakBlock(pos) + default: + return fmt.Errorf("unhandled UseItem ActionType for PlayerAuthInput packet %v", data.ActionType) + } + return nil +} + +// handleBlockActions handles a slice of protocol.PlayerBlockAction present in a PlayerAuthInput packet. +func (h PlayerAuthInputHandler) handleBlockActions(a []protocol.PlayerBlockAction, s *Session, c Controllable) error { + for _, action := range a { + if err := handlePlayerAction(action.Action, action.Face, action.BlockPos, selfEntityRuntimeID, s, c); err != nil { + return err + } + } + return nil +} diff --git a/server/session/handler_player_skin.go b/server/session/handler_player_skin.go new file mode 100644 index 0000000..0dad371 --- /dev/null +++ b/server/session/handler_player_skin.go @@ -0,0 +1,24 @@ +package session + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// PlayerSkinHandler handles the PlayerSkin packet. +type PlayerSkinHandler struct{} + +// Handle ... +func (PlayerSkinHandler) Handle(p packet.Packet, _ *Session, _ *world.Tx, c Controllable) error { + pk := p.(*packet.PlayerSkin) + + playerSkin, err := protocolToSkin(pk.Skin) + if err != nil { + return fmt.Errorf("error decoding skin: %w", err) + } + + c.SetSkin(playerSkin) + + return nil +} diff --git a/server/session/handler_request_ability.go b/server/session/handler_request_ability.go new file mode 100644 index 0000000..3222919 --- /dev/null +++ b/server/session/handler_request_ability.go @@ -0,0 +1,23 @@ +package session + +import ( + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// RequestAbilityHandler handles the RequestAbility packet. +type RequestAbilityHandler struct{} + +// Handle ... +func (a RequestAbilityHandler) Handle(p packet.Packet, s *Session, _ *world.Tx, c Controllable) error { + pk := p.(*packet.RequestAbility) + if pk.Ability == packet.AbilityFlying { + if !c.GameMode().AllowsFlying() { + s.conf.Log.Debug("process packet: RequestAbility: flying flag enabled while unable to fly") + s.SendAbilities(c) + return nil + } + c.StartFlying() + } + return nil +} diff --git a/server/session/handler_request_chunk_radius.go b/server/session/handler_request_chunk_radius.go new file mode 100644 index 0000000..355bfbf --- /dev/null +++ b/server/session/handler_request_chunk_radius.go @@ -0,0 +1,24 @@ +package session + +import ( + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// RequestChunkRadiusHandler handles the RequestChunkRadius packet. +type RequestChunkRadiusHandler struct{} + +// Handle ... +func (*RequestChunkRadiusHandler) Handle(p packet.Packet, s *Session, tx *world.Tx, _ Controllable) error { + pk := p.(*packet.RequestChunkRadius) + + if pk.ChunkRadius > s.maxChunkRadius { + pk.ChunkRadius = s.maxChunkRadius + } + s.chunkRadius = pk.ChunkRadius + + s.chunkLoader.ChangeRadius(tx, int(pk.ChunkRadius)) + + s.writePacket(&packet.ChunkRadiusUpdated{ChunkRadius: s.chunkRadius}) + return nil +} diff --git a/server/session/handler_respawn.go b/server/session/handler_respawn.go new file mode 100644 index 0000000..93be9a7 --- /dev/null +++ b/server/session/handler_respawn.go @@ -0,0 +1,23 @@ +package session + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// RespawnHandler handles the Respawn packet. +type RespawnHandler struct{} + +// Handle ... +func (*RespawnHandler) Handle(p packet.Packet, _ *Session, _ *world.Tx, c Controllable) error { + pk := p.(*packet.Respawn) + if pk.EntityRuntimeID != selfEntityRuntimeID { + return errSelfRuntimeID + } + if pk.State != packet.RespawnStateClientReadyToSpawn { + return fmt.Errorf("respawn state must always be %v, but got %v", packet.RespawnStateClientReadyToSpawn, pk.State) + } + c.Respawn() + return nil +} diff --git a/server/session/handler_server_bound_diagnostics.go b/server/session/handler_server_bound_diagnostics.go new file mode 100644 index 0000000..1afa4c6 --- /dev/null +++ b/server/session/handler_server_bound_diagnostics.go @@ -0,0 +1,26 @@ +package session + +import ( + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// ServerBoundDiagnosticsHandler handles diagnostic updates from the client. +type ServerBoundDiagnosticsHandler struct{} + +// Handle ... +func (h *ServerBoundDiagnosticsHandler) Handle(p packet.Packet, _ *Session, _ *world.Tx, c Controllable) error { + pk := p.(*packet.ServerBoundDiagnostics) + c.UpdateDiagnostics(Diagnostics{ + AverageFramesPerSecond: float64(pk.AverageFramesPerSecond), + AverageServerSimTickTime: float64(pk.AverageServerSimTickTime), + AverageClientSimTickTime: float64(pk.AverageClientSimTickTime), + AverageBeginFrameTime: float64(pk.AverageBeginFrameTime), + AverageInputTime: float64(pk.AverageInputTime), + AverageRenderTime: float64(pk.AverageRenderTime), + AverageEndFrameTime: float64(pk.AverageEndFrameTime), + AverageRemainderTimePercent: float64(pk.AverageRemainderTimePercent), + AverageUnaccountedTimePercent: float64(pk.AverageUnaccountedTimePercent), + }) + return nil +} diff --git a/server/session/handler_server_bound_loading_screen.go b/server/session/handler_server_bound_loading_screen.go new file mode 100644 index 0000000..529d1cf --- /dev/null +++ b/server/session/handler_server_bound_loading_screen.go @@ -0,0 +1,34 @@ +package session + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" + "sync/atomic" +) + +// ServerBoundLoadingScreenHandler handles loading screen updates from the clients. It is used to ensure that +// the server knows when the client is loading a screen, and when it is done loading it. +type ServerBoundLoadingScreenHandler struct { + currentID atomic.Uint32 + expectedID atomic.Uint32 +} + +// Handle ... +func (h *ServerBoundLoadingScreenHandler) Handle(p packet.Packet, s *Session, _ *world.Tx, _ Controllable) error { + pk := p.(*packet.ServerBoundLoadingScreen) + v, ok := pk.LoadingScreenID.Value() + expected := h.expectedID.Load() + + switch { + case !ok || expected == 0: + return nil + case v != expected: + return fmt.Errorf("expected loading screen ID %d, got %d", expected, v) + case pk.Type == packet.LoadingScreenTypeEnd: + s.changingDimension.Store(false) + h.expectedID.Store(0) + } + + return nil +} diff --git a/server/session/handler_smithing.go b/server/session/handler_smithing.go new file mode 100644 index 0000000..e9f26cd --- /dev/null +++ b/server/session/handler_smithing.go @@ -0,0 +1,91 @@ +package session + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/recipe" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol" +) + +const ( + // smithingInputSlot is the slot index of the input item in the smithing table. + smithingInputSlot = 0x33 + // smithingMaterialSlot is the slot index of the material in the smithing table. + smithingMaterialSlot = 0x34 + // smithingTemplateSlot is the slot index of the template item in the smithing table. + smithingTemplateSlot = 0x35 +) + +// handleSmithing handles a CraftRecipe stack request action made using a smithing table. +func (h *ItemStackRequestHandler) handleSmithing(a *protocol.CraftRecipeStackRequestAction, s *Session, tx *world.Tx) error { + // First, check the recipe and ensure it is valid for the smithing table. + craft, ok := s.recipes[a.RecipeNetworkID] + if !ok { + return fmt.Errorf("recipe with network id %v does not exist", a.RecipeNetworkID) + } + if craft.Block() != "smithing_table" { + return fmt.Errorf("recipe with network id %v is not a smithing table recipe", a.RecipeNetworkID) + } + switch craft.(type) { + case recipe.SmithingTransform, recipe.SmithingTrim: + default: + return fmt.Errorf("recipe with network id %v is not a smithing recipe", a.RecipeNetworkID) + } + + // Check if the input item and material item match what the recipe requires. + expectedInputs := craft.Input() + input, _ := h.itemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerSmithingTableInput}, + Slot: smithingInputSlot, + }, s, tx) + if !matchingStacks(input, expectedInputs[0]) { + return fmt.Errorf("input item is not the same as expected input") + } + material, _ := h.itemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerSmithingTableMaterial}, + Slot: smithingMaterialSlot, + }, s, tx) + if !matchingStacks(material, expectedInputs[1]) { + return fmt.Errorf("material item is not the same as expected material") + } + template, _ := h.itemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerSmithingTableTemplate}, + Slot: smithingTemplateSlot, + }, s, tx) + if !matchingStacks(template, expectedInputs[2]) { + return fmt.Errorf("template item is not the same as expected template") + } + + // Create the output using the input stack as reference and the recipe's output item type. + h.setItemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerSmithingTableInput}, + Slot: smithingInputSlot, + }, input.Grow(-1), s, tx) + h.setItemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerSmithingTableMaterial}, + Slot: smithingMaterialSlot, + }, material.Grow(-1), s, tx) + h.setItemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerSmithingTableTemplate}, + Slot: smithingTemplateSlot, + }, template.Grow(-1), s, tx) + + if _, ok = craft.(recipe.SmithingTrim); ok { + var trim item.ArmourTrim + if t, ok := template.Item().(item.SmithingTemplate); ok { + trim.Template = t.Template + } else { + return fmt.Errorf("template item is not a smithing template") + } + if trim.Material, ok = material.Item().(item.ArmourTrimMaterial); !ok { + return fmt.Errorf("material item is not an armour trim material") + } + trimmable, ok := input.Item().(item.Trimmable) + if !ok { + return fmt.Errorf("input item is not trimmable") + } + return h.createResults(s, tx, input.WithItem(trimmable.WithTrim(trim))) + } + return h.createResults(s, tx, input.WithItem(craft.Output()[0].Item())) +} diff --git a/server/session/handler_stonecutter.go b/server/session/handler_stonecutter.go new file mode 100644 index 0000000..846d4de --- /dev/null +++ b/server/session/handler_stonecutter.go @@ -0,0 +1,49 @@ +package session + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/item/recipe" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol" +) + +// stonecutterInputSlot is the slot index of the input item in the stonecutter. +const stonecutterInputSlot = 0x03 + +// handleStonecutting handles a CraftRecipe stack request action made using a stonecutter. +func (h *ItemStackRequestHandler) handleStonecutting(a *protocol.CraftRecipeStackRequestAction, s *Session, tx *world.Tx) error { + craft, ok := s.recipes[a.RecipeNetworkID] + if !ok { + return fmt.Errorf("recipe with network id %v does not exist", a.RecipeNetworkID) + } + if _, shapeless := craft.(recipe.Shapeless); !shapeless { + return fmt.Errorf("recipe with network id %v is not a shapeless recipe", a.RecipeNetworkID) + } + if craft.Block() != "stonecutter" { + return fmt.Errorf("recipe with network id %v is not a stonecutter recipe", a.RecipeNetworkID) + } + + timesCrafted := int(a.NumberOfCrafts) + if timesCrafted < 1 { + return fmt.Errorf("times crafted must be at least 1") + } + + expectedInputs := craft.Input() + input, _ := h.itemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerStonecutterInput}, + Slot: stonecutterInputSlot, + }, s, tx) + if input.Count() < timesCrafted { + return fmt.Errorf("input item count is less than number of crafts") + } + if !matchingStacks(input, expectedInputs[0]) { + return fmt.Errorf("input item is not the same as expected input") + } + + output := craft.Output() + h.setItemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerStonecutterInput}, + Slot: stonecutterInputSlot, + }, input.Grow(-timesCrafted), s, tx) + return h.createResults(s, tx, repeatStacks(output, timesCrafted)...) +} diff --git a/server/session/handler_sub_chunk_request.go b/server/session/handler_sub_chunk_request.go new file mode 100644 index 0000000..60db165 --- /dev/null +++ b/server/session/handler_sub_chunk_request.go @@ -0,0 +1,28 @@ +package session + +import ( + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// SubChunkRequestHandler handles sub-chunk requests from the client. The server will respond with a packet containing +// the requested sub-chunks. +type SubChunkRequestHandler struct{} + +// Handle ... +func (*SubChunkRequestHandler) Handle(p packet.Packet, s *Session, tx *world.Tx, _ Controllable) error { + pk := p.(*packet.SubChunkRequest) + if dimID, _ := world.DimensionID(tx.World().Dimension()); pk.Dimension != int32(dimID) { + // Outdated sub chunk request from a previous dimension. + s.writePacket(&packet.SubChunk{ + Dimension: pk.Dimension, + Position: pk.Position, + CacheEnabled: s.conn.ClientCacheEnabled(), + SubChunkEntries: []protocol.SubChunkEntry{}, + }) + return nil + } + s.ViewSubChunks(world.SubChunkPos(pk.Position), pk.Offsets, tx) + return nil +} diff --git a/server/session/handler_text.go b/server/session/handler_text.go new file mode 100644 index 0000000..124b748 --- /dev/null +++ b/server/session/handler_text.go @@ -0,0 +1,27 @@ +package session + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// TextHandler handles the Text packet. +type TextHandler struct{} + +// Handle ... +func (TextHandler) Handle(p packet.Packet, s *Session, _ *world.Tx, c Controllable) error { + pk := p.(*packet.Text) + + if pk.TextType != packet.TextTypeChat { + return fmt.Errorf("TextType should always be Chat (%v), but got %v", packet.TextTypeChat, pk.TextType) + } + if pk.SourceName != s.conn.IdentityData().DisplayName { + return fmt.Errorf("SourceName must be equal to DisplayName") + } + if pk.XUID != s.conn.IdentityData().XUID { + return fmt.Errorf("XUID must be equal to player's XUID") + } + c.Chat(pk.Message) + return nil +} diff --git a/server/session/player.go b/server/session/player.go new file mode 100644 index 0000000..e039bb4 --- /dev/null +++ b/server/session/player.go @@ -0,0 +1,1271 @@ +package session + +import ( + "encoding/json" + "fmt" + "image/color" + "maps" + "math" + "net" + "slices" + "time" + _ "unsafe" // Imported for compiler directives. + + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/entity" + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/creative" + "github.com/df-mc/dragonfly/server/item/inventory" + "github.com/df-mc/dragonfly/server/item/recipe" + "github.com/df-mc/dragonfly/server/player/debug" + "github.com/df-mc/dragonfly/server/player/dialogue" + "github.com/df-mc/dragonfly/server/player/form" + "github.com/df-mc/dragonfly/server/player/hud" + "github.com/df-mc/dragonfly/server/player/skin" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl32" + "github.com/go-gl/mathgl/mgl64" + "github.com/google/uuid" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// StopShowingEntity stops showing a world.Entity to the Session. It will be completely invisible until a call to +// StartShowingEntity is made. +func (s *Session) StopShowingEntity(e world.Entity) { + s.entityMutex.Lock() + _, ok := s.hiddenEntities[e.H().UUID()] + if !ok { + s.hiddenEntities[e.H().UUID()] = struct{}{} + } + s.entityMutex.Unlock() + + if !ok { + s.HideEntity(e) + } +} + +// StartShowingEntity starts showing a world.Entity to the Session that was previously hidden using StopShowingEntity. +func (s *Session) StartShowingEntity(e world.Entity) { + s.entityMutex.Lock() + _, ok := s.hiddenEntities[e.H().UUID()] + if ok { + delete(s.hiddenEntities, e.H().UUID()) + } + s.entityMutex.Unlock() + + if ok { + s.ViewEntity(e) + s.ViewEntityState(e) + s.ViewEntityItems(e) + s.ViewEntityArmour(e) + } +} + +// closeCurrentContainer closes the container the player might currently have open. +func (s *Session) closeCurrentContainer(tx *world.Tx, clientRequested bool) { + if !s.closeWindow(clientRequested) { + return + } + + pos := *s.openedPos.Load() + b := tx.Block(pos) + if container, ok := b.(block.Container); ok { + container.RemoveViewer(s, tx, pos) + } else if enderChest, ok := b.(block.EnderChest); ok { + enderChest.RemoveViewer(tx, pos) + } +} + +// SendRespawn spawns the Controllable entity of the session client-side in the world, provided it has died. +func (s *Session) SendRespawn(pos mgl64.Vec3, c Controllable) { + s.writePacket(&packet.Respawn{ + Position: vec64To32(pos.Add(entityOffset(c))), + State: packet.RespawnStateReadyToSpawn, + EntityRuntimeID: selfEntityRuntimeID, + }) +} + +// SendPlayerSpawn updates the player's spawn point on the client-side. There is currently little reason +// to do so other than to prevent the client-side "Respawn point set" message when sleeping in a bed. +func (s *Session) SendPlayerSpawn(pos mgl64.Vec3) { + blockPos := protocol.BlockPos{int32(pos[0]), int32(pos[1]), int32(pos[2])} + s.writePacket(&packet.SetSpawnPosition{ + SpawnType: packet.SpawnTypePlayer, + Position: blockPos, + Dimension: packet.DimensionOverworld, + SpawnPosition: blockPos, + }) +} + +// sendBiomes sends all the vanilla biomes to the session. +func (s *Session) sendBiomes() { + definitions, stringList := world.BiomeDefinitions() + s.writePacket(&packet.BiomeDefinitionList{ + BiomeDefinitions: definitions, + StringList: stringList, + }) +} + +// sendRecipes sends the current crafting recipes to the session. +func (s *Session) sendRecipes() { + recipes := make([]protocol.Recipe, 0, len(recipe.Recipes())) + potionRecipes := make([]protocol.PotionRecipe, 0) + potionContainerChange := make([]protocol.PotionContainerChangeRecipe, 0) + + for index, i := range recipe.Recipes() { + networkID := uint32(index) + 1 + s.recipes[networkID] = i + + switch i := i.(type) { + case recipe.Shapeless: + recipes = append(recipes, &protocol.ShapelessRecipe{ + RecipeID: uuid.New().String(), + Priority: int32(i.Priority()), + Input: stacksToIngredientItems(s.br, i.Input()), + Output: stacksToRecipeStacks(s.br, i.Output()), + Block: i.Block(), + RecipeNetworkID: networkID, + }) + case recipe.Shaped: + recipes = append(recipes, &protocol.ShapedRecipe{ + RecipeID: uuid.New().String(), + Priority: int32(i.Priority()), + Width: int32(i.Shape().Width()), + Height: int32(i.Shape().Height()), + Input: stacksToIngredientItems(s.br, i.Input()), + Output: stacksToRecipeStacks(s.br, i.Output()), + Block: i.Block(), + RecipeNetworkID: networkID, + }) + case recipe.SmithingTransform: + input, output := stacksToIngredientItems(s.br, i.Input()), stacksToRecipeStacks(s.br, i.Output()) + recipes = append(recipes, &protocol.SmithingTransformRecipe{ + RecipeID: uuid.New().String(), + Base: input[0], + Addition: input[1], + Template: input[2], + Result: output[0], + Block: i.Block(), + RecipeNetworkID: networkID, + }) + case recipe.SmithingTrim: + input := stacksToIngredientItems(s.br, i.Input()) + recipes = append(recipes, &protocol.SmithingTrimRecipe{ + RecipeID: uuid.New().String(), + Base: input[0], + Addition: input[1], + Template: input[2], + Block: i.Block(), + RecipeNetworkID: networkID, + }) + case recipe.Potion: + inputRuntimeID, inputMeta, _ := world.ItemRuntimeID(i.Input()[0].(item.Stack).Item()) + reagentRuntimeID, reagentMeta, _ := world.ItemRuntimeID(i.Input()[1].(item.Stack).Item()) + outputRuntimeID, outputMeta, _ := world.ItemRuntimeID(i.Output()[0].Item()) + + potionRecipes = append(potionRecipes, protocol.PotionRecipe{ + InputPotionID: inputRuntimeID, + InputPotionMetadata: int32(inputMeta), + ReagentItemID: reagentRuntimeID, + ReagentItemMetadata: int32(reagentMeta), + OutputPotionID: outputRuntimeID, + OutputPotionMetadata: int32(outputMeta), + }) + + case recipe.PotionContainerChange: + inputRuntimeID, _, _ := world.ItemRuntimeID(i.Input()[0].(item.Stack).Item()) + reagentRuntimeID, _, _ := world.ItemRuntimeID(i.Input()[1].(item.Stack).Item()) + outputRuntimeID, _, _ := world.ItemRuntimeID(i.Output()[0].Item()) + + potionContainerChange = append(potionContainerChange, protocol.PotionContainerChangeRecipe{ + InputItemID: inputRuntimeID, + ReagentItemID: reagentRuntimeID, + OutputItemID: outputRuntimeID, + }) + } + } + s.writePacket(&packet.CraftingData{Recipes: recipes, PotionRecipes: potionRecipes, PotionContainerChangeRecipes: potionContainerChange, ClearRecipes: true}) +} + +// sendArmourTrimData sends the armour trim data. +func (s *Session) sendArmourTrimData() { + var trimPatterns []protocol.TrimPattern + var trimMaterials []protocol.TrimMaterial + + for _, t := range item.SmithingTemplates() { + if t == item.TemplateNetheriteUpgrade() { + continue + } + name, _ := item.SmithingTemplate{Template: t}.EncodeItem() + trimPatterns = append(trimPatterns, protocol.TrimPattern{ + ItemName: name, + PatternID: t.String(), + }) + } + + for _, i := range item.ArmourTrimMaterials() { + if material, ok := i.(item.ArmourTrimMaterial); ok { + name, _ := i.EncodeItem() + + trimMaterials = append(trimMaterials, protocol.TrimMaterial{ + MaterialID: material.TrimMaterial(), + Colour: material.MaterialColour(), + ItemName: name, + }) + } + } + + s.writePacket(&packet.TrimData{Patterns: trimPatterns, Materials: trimMaterials}) +} + +// sendInv sends the inventory passed to the client with the window ID. +func (s *Session) sendInv(inv *inventory.Inventory, windowID uint32) { + pk := &packet.InventoryContent{ + WindowID: windowID, + Content: make([]protocol.ItemInstance, 0, inv.Size()), + } + for _, i := range inv.Slots() { + pk.Content = append(pk.Content, instanceFromItem(s.br, i)) + } + s.writePacket(pk) +} + +// sendItem sends the item stack passed to the client with the window ID and slot passed. +func (s *Session) sendItem(item item.Stack, slot int, windowID uint32) { + s.writePacket(&packet.InventorySlot{ + WindowID: windowID, + Slot: uint32(slot), + NewItem: instanceFromItem(s.br, item), + }) +} + +const ( + craftingGridSizeSmall = 4 + craftingGridSizeLarge = 9 + craftingGridSmallOffset = 28 + craftingGridLargeOffset = 32 + craftingResult = 50 +) + +// smelter is an interface representing a block used to smelt items. +type smelter interface { + // ResetExperience resets the collected experience of the smelter, and returns the amount of experience that was reset. + ResetExperience() int +} + +// invByID attempts to return an inventory by the ID passed. If found, the inventory is returned and the bool +// returned is true. +func (s *Session) invByID(id int32, tx *world.Tx) (*inventory.Inventory, bool) { + switch id { + case protocol.ContainerCraftingInput, protocol.ContainerCreatedOutput, protocol.ContainerCursor: + // UI inventory. + return s.ui, true + case protocol.ContainerHotBar, protocol.ContainerInventory, protocol.ContainerCombinedHotBarAndInventory: + // Hotbar 'inventory', rest of inventory, inventory when container is opened. + return s.inv, true + case protocol.ContainerOffhand: + return s.offHand, true + case protocol.ContainerArmor: + // Armour inventory. + return s.armour.Inventory(), true + default: + if !s.containerOpened.Load() { + return nil, false + } + switch id { + case protocol.ContainerLevelEntity: + return s.openedWindow.Load(), true + case protocol.ContainerBarrel: + if _, barrel := tx.Block(*s.openedPos.Load()).(block.Barrel); barrel { + return s.openedWindow.Load(), true + } + case protocol.ContainerBeaconPayment: + if _, beacon := tx.Block(*s.openedPos.Load()).(block.Beacon); beacon { + return s.ui, true + } + case protocol.ContainerBrewingStandInput, protocol.ContainerBrewingStandResult, protocol.ContainerBrewingStandFuel: + if _, brewingStand := tx.Block(*s.openedPos.Load()).(block.BrewingStand); brewingStand { + return s.openedWindow.Load(), true + } + case protocol.ContainerAnvilInput, protocol.ContainerAnvilMaterial: + if _, anvil := tx.Block(*s.openedPos.Load()).(block.Anvil); anvil { + return s.ui, true + } + case protocol.ContainerSmithingTableTemplate, protocol.ContainerSmithingTableInput, protocol.ContainerSmithingTableMaterial: + if _, smithing := tx.Block(*s.openedPos.Load()).(block.SmithingTable); smithing { + return s.ui, true + } + case protocol.ContainerLoomInput, protocol.ContainerLoomDye, protocol.ContainerLoomMaterial: + if _, loom := tx.Block(*s.openedPos.Load()).(block.Loom); loom { + return s.ui, true + } + case protocol.ContainerStonecutterInput: + if _, ok := tx.Block(*s.openedPos.Load()).(block.Stonecutter); ok { + return s.ui, true + } + case protocol.ContainerGrindstoneInput, protocol.ContainerGrindstoneAdditional: + if _, ok := tx.Block(*s.openedPos.Load()).(block.Grindstone); ok { + return s.ui, true + } + case protocol.ContainerEnchantingInput, protocol.ContainerEnchantingMaterial: + if _, enchanting := tx.Block(*s.openedPos.Load()).(block.EnchantingTable); enchanting { + return s.ui, true + } + case protocol.ContainerFurnaceIngredient, protocol.ContainerFurnaceFuel, protocol.ContainerFurnaceResult, + protocol.ContainerBlastFurnaceIngredient, protocol.ContainerSmokerIngredient: + if _, ok := tx.Block(*s.openedPos.Load()).(smelter); ok { + return s.openedWindow.Load(), true + } + } + } + return nil, false +} + +// Disconnect disconnects the client and ultimately closes the session. If the message passed is non-empty, +// it will be shown to the client. +func (s *Session) Disconnect(message string) { + if s != Nop { + _ = s.conn.WritePacket(&packet.Disconnect{ + HideDisconnectionScreen: message == "", + Message: message, + }) + _ = s.conn.Flush() + } +} + +// SendSpeed sends the speed of the player in an UpdateAttributes packet, so that it is updated client-side. +func (s *Session) SendSpeed(speed float64) { + s.writePacket(&packet.UpdateAttributes{ + EntityRuntimeID: selfEntityRuntimeID, + Attributes: []protocol.Attribute{{ + AttributeValue: protocol.AttributeValue{ + Name: "minecraft:movement", + Value: float32(speed), + Max: math.MaxFloat32, + }, + DefaultMax: math.MaxFloat32, + Default: 0.1, + }}, + }) +} + +// SendFood ... +func (s *Session) SendFood(food int, saturation, exhaustion float64) { + s.writePacket(&packet.UpdateAttributes{ + EntityRuntimeID: selfEntityRuntimeID, + Attributes: []protocol.Attribute{ + { + AttributeValue: protocol.AttributeValue{ + Name: "minecraft:player.hunger", + Value: float32(food), + Max: 20, + }, + DefaultMax: 20, + Default: 20, + }, + { + AttributeValue: protocol.AttributeValue{ + Name: "minecraft:player.saturation", + Value: float32(saturation), + Max: 20, + }, + DefaultMax: 20, + Default: 20, + }, + { + AttributeValue: protocol.AttributeValue{ + Name: "minecraft:player.exhaustion", + Value: float32(exhaustion), + Max: 5, + }, + DefaultMax: 5, + }, + }, + }) +} + +// SendDialogue sends an NPC dialogue to the client of the connection. The Submit method of the dialogue is +// called when the client interacts with a button in the dialogue. +func (s *Session) SendDialogue(d dialogue.Dialogue, e world.Entity) { + b, _ := json.Marshal(d) + + h := s.handlers[packet.IDNPCRequest].(*NPCRequestHandler) + h.dialogue = d + h.entityRuntimeID = s.entityRuntimeID(e) + + metadata := s.parseEntityMetadata(e) + metadata[protocol.EntityDataKeyHasNPC] = uint8(1) + + disp := d.Display() + disp.EntityOffset = disp.EntityOffset.Add(entityOffset(e)) + display, _ := json.Marshal(map[string]any{"portrait_offsets": disp}) + metadata[protocol.EntityDataKeyNPCData] = string(display) + + s.writePacket(&packet.SetActorData{ + EntityRuntimeID: h.entityRuntimeID, + EntityMetadata: metadata, + }) + s.writePacket(&packet.NPCDialogue{ + EntityUniqueID: h.entityRuntimeID, + ActionType: packet.NPCDialogueActionOpen, + Dialogue: d.Body(), + SceneName: "default", + NPCName: d.Title(), + ActionJSON: string(b), + }) +} + +func (s *Session) CloseDialogue() { + h := s.handlers[packet.IDNPCRequest].(*NPCRequestHandler) + if h.entityRuntimeID == 0 { + return + } + + s.writePacket(&packet.NPCDialogue{ + EntityUniqueID: h.entityRuntimeID, + ActionType: packet.NPCDialogueActionClose, + }) + h.entityRuntimeID = 0 +} + +// SendForm sends a form to the client of the connection. The Submit method of the form is called when the +// client submits the form. +func (s *Session) SendForm(f form.Form) { + b, _ := json.Marshal(f) + + h := s.handlers[packet.IDModalFormResponse].(*ModalFormResponseHandler) + id := h.currentID.Add(1) + + h.mu.Lock() + if len(h.forms) > 10 { + s.conf.Log.Debug("SendForm: more than 10 active forms: dropping an existing one") + for k := range h.forms { + delete(h.forms, k) + break + } + } + h.forms[id] = f + h.mu.Unlock() + + s.writePacket(&packet.ModalFormRequest{ + FormID: id, + FormData: b, + }) +} + +// CloseForm closes any forms that the player currently has open. If the player has no forms open, nothing +// happens. +func (s *Session) CloseForm() { + s.writePacket(&packet.ClientBoundCloseForm{}) +} + +// Transfer transfers the player to a server with the IP and port passed. +func (s *Session) Transfer(ip net.IP, port int) { + s.writePacket(&packet.Transfer{ + Address: ip.String(), + Port: uint16(port), + }) +} + +// SendGameMode sends the game mode of the Controllable entity of the session to the client. It makes sure the right +// flags are set to create the full game mode. +func (s *Session) SendGameMode(c Controllable) { + if s == Nop { + return + } + s.writePacket(&packet.SetPlayerGameType{GameType: gameTypeFromMode(c.GameMode())}) + s.SendAbilities(c) +} + +// SendAbilities sends the abilities of the Controllable entity of the session to the client. +func (s *Session) SendAbilities(c Controllable) { + mode, abilities := c.GameMode(), uint32(0) + if mode.AllowsFlying() { + abilities |= protocol.AbilityMayFly + if c.Flying() { + abilities |= protocol.AbilityFlying + } + } + if !mode.HasCollision() { + abilities |= protocol.AbilityNoClip + defer c.StartFlying() + // If the client is currently on the ground and turned to spectator mode, it will be unable to sprint during + // flight. In order to allow this, we force the client to be flying through a MovePlayer packet. + s.ViewEntityTeleport(c, c.Position()) + } + if !mode.AllowsTakingDamage() { + abilities |= protocol.AbilityInvulnerable + } + if mode.CreativeInventory() { + abilities |= protocol.AbilityInstantBuild + } + if mode.AllowsEditing() { + abilities |= protocol.AbilityBuild | protocol.AbilityMine + } + if mode.AllowsInteraction() { + abilities |= protocol.AbilityDoorsAndSwitches | protocol.AbilityOpenContainers | protocol.AbilityAttackPlayers | protocol.AbilityAttackMobs + } + s.writePacket(&packet.UpdateAbilities{AbilityData: protocol.AbilityData{ + EntityUniqueID: selfEntityRuntimeID, + PlayerPermissions: packet.PermissionLevelMember, + CommandPermissions: protocol.CommandPermissionLevelAny, + Layers: []protocol.AbilityLayer{ + { + Type: protocol.AbilityLayerTypeBase, + Abilities: protocol.AbilityCount - 1, + Values: abilities, + FlySpeed: float32(c.FlightSpeed()), + VerticalFlySpeed: float32(c.VerticalFlightSpeed()), + WalkSpeed: protocol.AbilityBaseWalkSpeed, + }, + }, + }}) +} + +// SendHealth sends the health and max health to the player. +func (s *Session) SendHealth(health, max, absorption float64) { + s.writePacket(&packet.UpdateAttributes{ + EntityRuntimeID: selfEntityRuntimeID, + Attributes: []protocol.Attribute{{ + AttributeValue: protocol.AttributeValue{ + Name: "minecraft:health", + Value: float32(math.Ceil(health)), + Max: float32(math.Ceil(max)), + }, + DefaultMax: 20, + Default: 20, + }, { + AttributeValue: protocol.AttributeValue{ + Name: "minecraft:absorption", + Value: float32(math.Ceil(absorption)), + Max: float32(math.MaxFloat32), + }, + DefaultMax: float32(math.MaxFloat32), + }}, + }) +} + +// SendEffect sends an effects passed to the player. +func (s *Session) SendEffect(e effect.Effect) { + s.SendEffectRemoval(e.Type()) + id, _ := effect.ID(e.Type()) + dur := e.Duration() / (time.Second / 20) + if e.Infinite() { + dur = -1 + } + s.writePacket(&packet.MobEffect{ + EntityRuntimeID: selfEntityRuntimeID, + Operation: packet.MobEffectAdd, + EffectType: int32(id), + Amplifier: int32(e.Level() - 1), + Particles: !e.ParticlesHidden(), + Duration: int32(dur), + Ambient: e.Ambient(), + }) +} + +// SendEffectRemoval sends the removal of an effect passed. +func (s *Session) SendEffectRemoval(e effect.Type) { + id, ok := effect.ID(e) + if !ok { + panic(fmt.Sprintf("unregistered effect type %T", e)) + } + s.writePacket(&packet.MobEffect{ + EntityRuntimeID: selfEntityRuntimeID, + Operation: packet.MobEffectRemove, + EffectType: int32(id), + }) +} + +// SendGameRules sends all the provided game rules to the player. Once sent, they will be immediately updated +// on the client if they are valid. +func (s *Session) sendGameRules(gameRules []protocol.GameRule) { + s.writePacket(&packet.GameRulesChanged{GameRules: gameRules}) +} + +// EnableCoordinates will either enable or disable coordinates for the player depending on the value given. +func (s *Session) EnableCoordinates(enable bool) { + //noinspection SpellCheckingInspection + s.sendGameRules([]protocol.GameRule{{Name: "showcoordinates", Value: enable}}) +} + +// EnableInstantRespawn will either enable or disable instant respawn for the player depending on the value given. +func (s *Session) EnableInstantRespawn(enable bool) { + //noinspection SpellCheckingInspection + s.sendGameRules([]protocol.GameRule{{Name: "doimmediaterespawn", Value: enable}}) +} + +// HandleInventories starts handling the inventories of the Controllable entity of the session. It sends packets when +// slots in the inventory are changed. +func (s *Session) HandleInventories(tx *world.Tx, c Controllable, inv, offHand, enderChest, ui *inventory.Inventory, armour *inventory.Armour, heldSlot *uint32) { + s.inv = inv + s.inv.SlotFunc(s.broadcastInvFunc(tx, c)) + s.offHand = offHand + s.offHand.SlotFunc(s.broadcastOffHandFunc(tx, c)) + s.enderChest = enderChest + s.enderChest.SlotFunc(s.broadcastEnderChestFunc(tx, c)) + s.armour = armour + s.armour.Inventory().SlotFunc(s.broadcastArmourFunc(tx, c)) + s.ui = ui + s.ui.SlotFunc(s.uiInventoryFunc(tx, c)) + s.heldSlot = heldSlot +} + +func (s *Session) broadcastInvFunc(tx *world.Tx, c Controllable) inventory.SlotFunc { + return func(slot int, _, after item.Stack) { + if slot == int(*s.heldSlot) { + for _, viewer := range tx.Viewers(c.Position()) { + viewer.ViewEntityItems(c) + } + } + if !s.inTransaction.Load() { + s.sendItem(after, slot, protocol.WindowIDInventory) + } + } +} + +func (s *Session) broadcastEnderChestFunc(tx *world.Tx, _ Controllable) inventory.SlotFunc { + return func(slot int, _, after item.Stack) { + if !s.inTransaction.Load() { + if _, ok := tx.Block(*s.openedPos.Load()).(block.EnderChest); ok { + s.ViewSlotChange(slot, after) + } + } + } +} + +func (s *Session) broadcastOffHandFunc(tx *world.Tx, c Controllable) inventory.SlotFunc { + return func(slot int, _, after item.Stack) { + for _, viewer := range tx.Viewers(c.Position()) { + viewer.ViewEntityItems(c) + } + if !s.inTransaction.Load() { + i, _ := s.offHand.Item(0) + s.writePacket(&packet.InventoryContent{ + WindowID: protocol.WindowIDOffHand, + Content: []protocol.ItemInstance{instanceFromItem(s.br, i)}, + }) + } + } +} + +func (s *Session) broadcastArmourFunc(tx *world.Tx, c Controllable) inventory.SlotFunc { + return func(slot int, before, after item.Stack) { + inTransaction := s.inTransaction.Load() + if !inTransaction { + s.sendItem(after, slot, protocol.WindowIDArmour) + } + if before.Comparable(after) && before.Empty() == after.Empty() { + // Only send armour if the item type actually changed. + return + } + for _, viewer := range tx.Viewers(c.Position()) { + viewer.ViewEntityArmour(c) + } + + if !after.Empty() && inTransaction { + tx.PlaySound(entity.EyePosition(c), sound.EquipItem{Item: after.Item()}) + } + } +} + +// uiInventoryFunc handles an update to the UI inventory, used for updating enchantment options and possibly more +// in the future. +func (s *Session) uiInventoryFunc(tx *world.Tx, c Controllable) inventory.SlotFunc { + return func(slot int, _, after item.Stack) { + if slot == enchantingInputSlot && s.containerOpened.Load() { + pos := *s.openedPos.Load() + if _, enchanting := tx.Block(pos).(block.EnchantingTable); enchanting { + s.sendEnchantmentOptions(tx, c, pos, after) + } + } + s.sendInv(s.ui, protocol.WindowIDUI) + } +} + +// SendHeldSlot sends the currently held hotbar slot. +func (s *Session) SendHeldSlot(slot int, c Controllable, force bool) { + if s.changingSlot.Load() && !force { + return + } + mainHand, _ := c.HeldItems() + s.writePacket(&packet.MobEquipment{ + EntityRuntimeID: selfEntityRuntimeID, + NewItem: instanceFromItem(s.br, mainHand), + InventorySlot: byte(slot), + HotBarSlot: byte(slot), + }) +} + +// VerifyAndSetHeldSlot verifies if the slot passed is a valid hotbar slot and +// if the expected item.Stack is in it. Afterwards, it changes the held slot +// of the player. +func (s *Session) VerifyAndSetHeldSlot(slot int, expected item.Stack, c Controllable) error { + if err := s.VerifySlot(slot, expected); err != nil { + return err + } + s.changingSlot.Store(true) + defer s.changingSlot.Store(false) + return c.SetHeldSlot(slot) +} + +// VerifySlot verifies if the slot passed is a valid hotbar slot and if the +// expected item.Stack is in it. +func (s *Session) VerifySlot(slot int, expected item.Stack) error { + // The slot that the player might have selected must be within the hotbar: + // The held item cannot be in a different place in the inventory. + if slot < 0 || slot > 8 { + return fmt.Errorf("slot exceeds hotbar range 0-8: slot is %v", slot) + } + clientSideItem := expected + actual, _ := s.inv.Item(slot) + + // The item the client claims to have must be identical to the one we have + // registered server-side. + if !clientSideItem.Equal(actual) { + s.sendItem(actual, slot, protocol.WindowIDInventory) + // Only ever debug these as they are frequent and expected to happen + // whenever client and server get out of sync. + s.conf.Log.Debug("verify slot: client-side item was not equal to server-side item", "client-held", clientSideItem.String(), "server-held", actual.String()) + } + return nil +} + +// SendExperience sends the experience level and progress from the given experience manager to the player. +func (s *Session) SendExperience(level int, progress float64) { + s.writePacket(&packet.UpdateAttributes{ + EntityRuntimeID: selfEntityRuntimeID, + Attributes: []protocol.Attribute{ + { + AttributeValue: protocol.AttributeValue{ + Name: "minecraft:player.level", + Value: float32(level), + Max: float32(math.MaxInt32), + }, + DefaultMax: float32(math.MaxInt32), + }, + { + AttributeValue: protocol.AttributeValue{ + Name: "minecraft:player.experience", + Value: float32(progress), + Max: 1, + }, + DefaultMax: 1, + }, + }, + }) +} + +// SendChargeItemComplete sends a packet to indicate that the item charging process has been completed. +func (s *Session) SendChargeItemComplete() { + s.writePacket(&packet.ActorEvent{ + EntityRuntimeID: selfEntityRuntimeID, + EventType: packet.ActorEventFinishedChargingItem, + }) +} + +// ShowHudElement shows a HUD element to the player if it is not already shown. If the element is waiting to +// be hidden, it will be removed from the updates and remain visible to the player. +func (s *Session) ShowHudElement(e hud.Element) { + s.hudMu.Lock() + defer s.hudMu.Unlock() + + if _, ok := s.hiddenHud[e]; ok { + s.hudUpdates[e] = true + } else if _, ok = s.hudUpdates[e]; ok { + delete(s.hudUpdates, e) + } +} + +// HideHudElement hides a HUD element from the player if it is not already hidden. If the element is waiting +// to be shown, it will be removed from the updates and remain hidden from the player. +func (s *Session) HideHudElement(e hud.Element) { + s.hudMu.Lock() + defer s.hudMu.Unlock() + + if _, ok := s.hiddenHud[e]; !ok { + s.hudUpdates[e] = false + } else if _, ok = s.hudUpdates[e]; ok { + delete(s.hudUpdates, e) + } +} + +// HudElementHidden checks if a HUD element is currently hidden from the player. +func (s *Session) HudElementHidden(e hud.Element) bool { + s.hudMu.RLock() + defer s.hudMu.RUnlock() + + if _, ok := s.hiddenHud[e]; ok { + return true + } + vis, ok := s.hudUpdates[e] + return ok && !vis +} + +// SendHudUpdates sends any pending HUD updates to the player. The updates are batched to reduce the number +// of packets being sent. Up to 2 packets will be sent, one for showing elements and one for hiding elements. +func (s *Session) SendHudUpdates() { + s.hudMu.Lock() + if len(s.hudUpdates) == 0 { + s.hudMu.Unlock() + return + } + var show, hide []int32 + for e, vis := range s.hudUpdates { + if vis { + show = append(show, int32(e.Uint8())) + delete(s.hiddenHud, e) + } else { + hide = append(hide, int32(e.Uint8())) + s.hiddenHud[e] = struct{}{} + } + } + s.hudUpdates = make(map[hud.Element]bool) + s.hudMu.Unlock() + + if len(show) > 0 { + s.writePacket(&packet.SetHud{Elements: show, Visibility: packet.HudVisibilityReset}) + } + if len(hide) > 0 { + s.writePacket(&packet.SetHud{Elements: hide, Visibility: packet.HudVisibilityHide}) + } +} + +// AddDebugShape adds a debug shape to be rendered to the player. If the shape already exists, it will be +// updated with the new information. +func (s *Session) AddDebugShape(shape debug.Shape) { + if s == Nop { + return + } + s.queueDebugShapeUpdate(debugShapeUpdate{id: shape.ShapeID(), shape: shape}) +} + +// RemoveDebugShape removes a debug shape from the player by its unique identifier. +func (s *Session) RemoveDebugShape(shape debug.Shape) { + if s == Nop { + return + } + s.queueDebugShapeUpdate(debugShapeUpdate{id: shape.ShapeID()}) +} + +// VisibleDebugShapes returns a slice of all debug shapes that are currently being shown to the player. +func (s *Session) VisibleDebugShapes() []debug.Shape { + s.debugShapesMu.RLock() + defer s.debugShapesMu.RUnlock() + + return slices.Collect(maps.Values(s.debugShapes)) +} + +// RemoveAllDebugShapes removes all rendered debug shapes from the player, as well as any shapes that have +// not yet been rendered. +func (s *Session) RemoveAllDebugShapes() { + if s == Nop { + return + } + s.debugShapesMu.Lock() + defer s.debugShapesMu.Unlock() + + s.debugShapeUpdates = s.debugShapeUpdates[:0] + for id := range s.debugShapes { + s.debugShapeUpdates = append(s.debugShapeUpdates, debugShapeUpdate{id: id}) + } +} + +// SendDebugShapes sends any pending additions/removals of debug shapes to the player. Shapes should be sent +// every tick to allow for batching and time-efficient updates. +func (s *Session) SendDebugShapes(dim world.Dimension) { + s.debugShapesMu.Lock() + updates := s.debugShapeUpdates + if len(updates) == 0 { + s.debugShapesMu.Unlock() + return + } + + shapes := make([]protocol.PrimitiveShape, 0, len(updates)) + for _, update := range updates { + if update.shape == nil { + delete(s.debugShapes, update.id) + shapes = append(shapes, protocol.PrimitiveShape{ + NetworkID: uint64(update.id), + DimensionID: protocol.Option(s.dimensionID(dim)), + ExtraShapeData: &protocol.LastShape{}, + }) + continue + } + s.debugShapes[update.id] = update.shape + shapes = append(shapes, debugShapeToProtocol(update.shape, dim, s.shapeAttachedEntityRuntimeID(update.shape))) + } + s.debugShapeUpdates = s.debugShapeUpdates[:0] + s.debugShapesMu.Unlock() + + s.writePacket(&packet.PrimitiveShapes{Shapes: shapes}) +} + +// queueDebugShapeUpdate queues a debug shape mutation to be applied the next time debug shapes are sent. +func (s *Session) queueDebugShapeUpdate(update debugShapeUpdate) { + s.debugShapesMu.Lock() + defer s.debugShapesMu.Unlock() + s.debugShapeUpdates = append(s.debugShapeUpdates, update) +} + +// valueOrDefault returns the value passed if it is not the zero value of the type T, otherwise it returns +// the default value provided. +func valueOrDefault[T comparable](v, def T) T { + var zero T + if v == zero { + return def + } + return v +} + +// stackFromItem converts an item.Stack to its network ItemStack representation. +func stackFromItem(br world.BlockRegistry, it item.Stack) protocol.ItemStack { + if it.Empty() { + return protocol.ItemStack{} + } + + var blockRuntimeID uint32 + if b, ok := it.Item().(world.Block); ok { + blockRuntimeID = br.BlockRuntimeID(b) + } + + rid, meta, _ := world.ItemRuntimeID(it.Item()) + + return protocol.ItemStack{ + ItemType: protocol.ItemType{ + NetworkID: rid, + MetadataValue: uint32(meta), + }, + HasNetworkID: true, + Count: uint16(it.Count()), + BlockRuntimeID: int32(blockRuntimeID), + NBTData: nbtconv.WriteItem(it, false), + } +} + +// stackToItem converts a network ItemStack representation back to an item.Stack. +func stackToItem(br world.BlockRegistry, it protocol.ItemStack) item.Stack { + t, ok := world.ItemByRuntimeID(it.NetworkID, int16(it.MetadataValue)) + if !ok { + t = block.Air{} + } + if it.BlockRuntimeID > 0 { + // It shouldn't matter if it (for whatever reason) wasn't able to get the block runtime ID, + // since on the next line, we assert that the block is an item. If it didn't succeed, it'll + // return air anyway. + b, _ := br.BlockByRuntimeID(uint32(it.BlockRuntimeID)) + if t, ok = b.(world.Item); !ok { + t = block.Air{} + } + } + //noinspection SpellCheckingInspection + if nbter, ok := t.(world.NBTer); ok && len(it.NBTData) != 0 { + t = nbter.DecodeNBT(it.NBTData).(world.Item) + } + s := item.NewStack(t, int(it.Count)) + return nbtconv.Item(it.NBTData, &s) +} + +// instanceFromItem converts an item.Stack to its network ItemInstance representation. +func instanceFromItem(br world.BlockRegistry, it item.Stack) protocol.ItemInstance { + return protocol.ItemInstance{ + StackNetworkID: item_id(it), + Stack: stackFromItem(br, it), + } +} + +// stacksToRecipeStacks converts a list of item.Stacks to their protocol representation with damage stripped for recipes. +func stacksToRecipeStacks(br world.BlockRegistry, inputs []item.Stack) []protocol.ItemStack { + items := make([]protocol.ItemStack, 0, len(inputs)) + for _, i := range inputs { + items = append(items, deleteDamage(stackFromItem(br, i))) + } + return items +} + +// stacksToIngredientItems converts a list of item.Stacks to recipe ingredient items used over the network. +func stacksToIngredientItems(_ world.BlockRegistry, inputs []recipe.Item) []protocol.ItemDescriptorCount { + items := make([]protocol.ItemDescriptorCount, 0, len(inputs)) + for _, i := range inputs { + var d protocol.ItemDescriptor = &protocol.InvalidItemDescriptor{} + switch i := i.(type) { + case item.Stack: + if i.Empty() { + items = append(items, protocol.ItemDescriptorCount{Descriptor: &protocol.InvalidItemDescriptor{}}) + continue + } + rid, meta, ok := world.ItemRuntimeID(i.Item()) + if !ok { + panic("should never happen") + } + if _, ok = i.Value("variants"); ok { + meta = math.MaxInt16 // Used to indicate that the item has multiple selectable variants. + } + d = &protocol.DefaultItemDescriptor{ + NetworkID: int16(rid), + MetadataValue: meta, + } + case recipe.ItemTag: + d = &protocol.ItemTagItemDescriptor{Tag: i.Tag()} + } + items = append(items, protocol.ItemDescriptorCount{ + Descriptor: d, + Count: int32(i.Count()), + }) + } + return items +} + +// creativeContent returns all creative groups, and creative inventory items as protocol item stacks. +func creativeContent(br world.BlockRegistry) ([]protocol.CreativeGroup, []protocol.CreativeItem) { + groups := make([]protocol.CreativeGroup, 0, len(creative.Groups())) + for _, group := range creative.Groups() { + groups = append(groups, protocol.CreativeGroup{ + Category: int32(group.Category.Uint8()), + Name: group.Name, + Icon: deleteDamage(stackFromItem(br, group.Icon)), + }) + } + + it := make([]protocol.CreativeItem, 0, len(creative.Items())) + for index, i := range creative.Items() { + group := slices.IndexFunc(creative.Groups(), func(group creative.Group) bool { + return group.Name == i.Group + }) + if group < 0 { + continue + } + it = append(it, protocol.CreativeItem{ + CreativeItemNetworkID: uint32(index) + 1, + Item: deleteDamage(stackFromItem(br, i.Stack)), + GroupIndex: uint32(group), + }) + } + return groups, it +} + +// deleteDamage strips the damage from a protocol item. +func deleteDamage(st protocol.ItemStack) protocol.ItemStack { + delete(st.NBTData, "Damage") + return st +} + +// protocolToSkin converts protocol.Skin to skin.Skin. +func protocolToSkin(sk protocol.Skin) (s skin.Skin, err error) { + if sk.SkinID == "" { + return skin.Skin{}, fmt.Errorf("SkinID must not be an empty string") + } + + s = skin.New(int(sk.SkinImageWidth), int(sk.SkinImageHeight)) + s.Persona = sk.PersonaSkin + s.Pix = sk.SkinData + s.Model = sk.SkinGeometry + s.PlayFabID = sk.PlayFabID + s.FullID = sk.FullID + + s.Cape = skin.NewCape(int(sk.CapeImageWidth), int(sk.CapeImageHeight)) + s.Cape.Pix = sk.CapeData + + m := make(map[string]any) + if err = json.Unmarshal(sk.SkinGeometry, &m); err != nil { + return skin.Skin{}, fmt.Errorf("SkinGeometry was not a valid JSON string: %v", err) + } + + if s.ModelConfig, err = skin.DecodeModelConfig(sk.SkinResourcePatch); err != nil { + return skin.Skin{}, fmt.Errorf("SkinResourcePatch was not a valid JSON string: %v", err) + } + + for _, anim := range sk.Animations { + var t skin.AnimationType + switch anim.AnimationType { + case protocol.SkinAnimationHead: + t = skin.AnimationHead + case protocol.SkinAnimationBody32x32: + t = skin.AnimationBody32x32 + case protocol.SkinAnimationBody128x128: + t = skin.AnimationBody128x128 + default: + return skin.Skin{}, fmt.Errorf("invalid animation type: %v", anim.AnimationType) + } + + animation := skin.NewAnimation(int(anim.ImageWidth), int(anim.ImageHeight), int(anim.ExpressionType), t) + animation.FrameCount = int(anim.FrameCount) + animation.Pix = anim.ImageData + + s.Animations = append(s.Animations, animation) + } + return +} + +// shapeAttachedEntityRuntimeID returns the runtime ID of the entity attached to a debug shape. +func (s *Session) shapeAttachedEntityRuntimeID(shape debug.Shape) int64 { + var handle *world.EntityHandle + switch shape := shape.(type) { + case *debug.Arrow: + handle = shape.Entity + case *debug.Box: + handle = shape.Entity + case *debug.Circle: + handle = shape.Entity + case *debug.Line: + handle = shape.Entity + case *debug.Sphere: + 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 + } + return int64(s.handleRuntimeID(handle)) +} + +// debugShapeToProtocol converts a debug shape to its protocol representation. It also provides defaults +// for some fields such as colour, scale and other per-shape properties. +func debugShapeToProtocol(shape debug.Shape, dim world.Dimension, attachedEntityID int64) protocol.PrimitiveShape { + dimID, _ := world.DimensionID(dim) + ps := protocol.PrimitiveShape{ + NetworkID: uint64(shape.ShapeID()), + DimensionID: protocol.Option(int32(dimID)), + } + if attachedEntityID > 0 { + ps.AttachedToEntityID = protocol.Option(attachedEntityID) + } + white := color.RGBA{R: 255, G: 255, B: 255, A: 255} + switch shape := shape.(type) { + case *debug.Arrow: + 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{ + ArrowEndLocation: protocol.Option(vec64To32(shape.EndPosition)), + ArrowHeadLength: protocol.Option(valueOrDefault(float32(shape.HeadLength), 1)), + ArrowHeadRadius: protocol.Option(valueOrDefault(float32(shape.HeadRadius), 0.5)), + Segments: protocol.Option(valueOrDefault(uint8(shape.HeadSegments), 4)), + } + case *debug.Box: + 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(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(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(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(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)) + if shape.LockRotation { + ps.Rotation = protocol.Option(vec64To32(shape.Rotation)) + } + textData := &protocol.TextShape{ + Text: shape.Text, + UseRotation: shape.LockRotation, + DepthTest: !shape.DisableDepthTest, + ShowBackface: !shape.HideBackface, + ShowBackfaceText: !shape.HideBackfaceText, + } + switch { + case shape.HideBackground: + textData.BackgroundColour = protocol.Option(color.RGBA{}) + case shape.BackgroundColour != (color.RGBA{}): + 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)) + } + return ps +} + +// gameTypeFromMode returns the game type ID from the game mode passed. +func gameTypeFromMode(mode world.GameMode) int32 { + if mode.AllowsFlying() && mode.CreativeInventory() { + return packet.GameTypeCreative + } + if !mode.Visible() && !mode.HasCollision() { + return packet.GameTypeSurvivalSpectator + } + return packet.GameTypeSurvival +} + +// The following functions use the go:linkname directive in order to make sure the item.byID and item.toID +// functions do not need to be exported. + +// noinspection ALL +// +//go:linkname item_id github.com/df-mc/dragonfly/server/item.id +func item_id(s item.Stack) int32 diff --git a/server/session/session.go b/server/session/session.go new file mode 100644 index 0000000..cdd1a69 --- /dev/null +++ b/server/session/session.go @@ -0,0 +1,598 @@ +package session + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net" + "sync" + "sync/atomic" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/cmd" + "github.com/df-mc/dragonfly/server/item/inventory" + "github.com/df-mc/dragonfly/server/item/recipe" + "github.com/df-mc/dragonfly/server/player/chat" + "github.com/df-mc/dragonfly/server/player/debug" + "github.com/df-mc/dragonfly/server/player/form" + "github.com/df-mc/dragonfly/server/player/hud" + "github.com/df-mc/dragonfly/server/player/skin" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" + "github.com/google/uuid" + "github.com/sandertv/gophertunnel/minecraft" + "github.com/sandertv/gophertunnel/minecraft/nbt" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/protocol/login" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// Session handles incoming packets from connections and sends outgoing packets by providing a thin layer +// of abstraction over direct packets. A Session basically 'controls' an entity. +type Session struct { + conf Config + once, connOnce sync.Once + + ent *world.EntityHandle + conn Conn + handlers map[uint32]packetHandler + packets chan packet.Packet + + currentScoreboard atomic.Pointer[string] + currentLines atomic.Pointer[[]string] + + chunkLoader *world.Loader + chunkRadius, maxChunkRadius int32 + + emoteChatMuted bool + + teleportPos atomic.Pointer[mgl64.Vec3] + + entityMutex sync.RWMutex + // currentEntityRuntimeID holds the runtime ID assigned to the last entity. It is incremented for every + // entity spawned to the session. + currentEntityRuntimeID uint64 + // entityRuntimeIDs holds the runtime IDs of entities shown to the session. + entityRuntimeIDs map[*world.EntityHandle]uint64 + entities map[uint64]*world.EntityHandle + hiddenEntities map[uuid.UUID]struct{} + + // heldSlot is the slot in the inventory that the controllable is holding. + heldSlot *uint32 + inv, offHand, enderChest, ui *inventory.Inventory + armour *inventory.Armour + + // joinSkin is the first skin that the player joined with. It is sent on + // spawn for the player list, but otherwise updated immediately when the + // player is viewed. + joinSkin skin.Skin + + breakingPos cube.Pos + + inTransaction, containerOpened atomic.Bool + openedWindowID atomic.Uint32 + openedContainerID atomic.Uint32 + openedWindow atomic.Pointer[inventory.Inventory] + openedPos atomic.Pointer[cube.Pos] + swingingArm atomic.Bool + changingSlot atomic.Bool + changingDimension atomic.Bool + moving bool + + lastChunkPos world.ChunkPos + + recipes map[uint32]recipe.Recipe + + blobMu sync.Mutex + blobs map[uint64][]byte + openChunkTransactions []map[uint64]struct{} + invOpened bool + + hudMu sync.RWMutex + hudUpdates map[hud.Element]bool + hiddenHud map[hud.Element]struct{} + + debugShapesMu sync.RWMutex + debugShapes map[int]debug.Shape + debugShapeUpdates []debugShapeUpdate + + viewLayer *world.ViewLayer + + closeBackground chan struct{} + + br world.BlockRegistry +} + +// debugShapeUpdate represents a pending debug shape mutation. If shape is nil, the update removes the +// debug shape with the matching ID. Updates are applied in order when the session sends debug shapes. +type debugShapeUpdate struct { + id int + shape debug.Shape +} + +// Conn represents a connection that packets are read from and written to by a Session. In addition, it holds some +// information on the identity of the Session. +type Conn interface { + io.Closer + // IdentityData returns the login.IdentityData of a Conn. It contains the UUID, XUID and username of the connection. + IdentityData() login.IdentityData + // ClientData returns the login.ClientData of a Conn. This includes less sensitive data of the player like its skin, + // language code and other non-essential information. + ClientData() login.ClientData + // ClientCacheEnabled specifies if the Conn has the client cache, used for caching chunks client-side, enabled or + // not. Some platforms, like the Nintendo Switch, have this disabled at all times. + ClientCacheEnabled() bool + // ChunkRadius returns the chunk radius as requested by the client at the other end of the Conn. + ChunkRadius() int + // Latency returns the current latency measured over the Conn. + Latency() time.Duration + // Flush flushes the packets buffered by the Conn, sending all of them out immediately. + Flush() error + // RemoteAddr returns the remote network address. + RemoteAddr() net.Addr + // ReadPacket reads a packet.Packet from the Conn. An error is returned if a deadline was set that was + // exceeded or if the Conn was closed while awaiting a packet. + ReadPacket() (pk packet.Packet, err error) + // WritePacket writes a packet.Packet to the Conn. An error is returned if the Conn was closed before sending the + // packet. + WritePacket(pk packet.Packet) error + // StartGameContext starts the game for the Conn with a context to cancel it. + StartGameContext(ctx context.Context, data minecraft.GameData) error +} + +// Nop represents a no-operation session. It does not do anything when sending a packet to it. +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 + +// errSelfRuntimeID is an error returned during packet handling for fields that refer to the player itself and +// must therefore always be 1. +var errSelfRuntimeID = errors.New("invalid entity runtime ID: runtime ID for self must always be 1") + +type Config struct { + Log *slog.Logger + + MaxChunkRadius int + + EmoteChatMuted bool + + JoinMessage, QuitMessage chat.Translation + + HandleStop func(*world.Tx, Controllable) + // BlockRegistry overrides the registry used for network serialization. If nil, world.DefaultBlockRegistry is used. + BlockRegistry world.BlockRegistry +} + +func (conf Config) New(conn Conn) *Session { + r := conn.ChunkRadius() + if r > conf.MaxChunkRadius { + r = conf.MaxChunkRadius + _ = conn.WritePacket(&packet.ChunkRadiusUpdated{ChunkRadius: int32(r)}) + } + if conf.Log == nil { + conf.Log = slog.Default() + } + conf.Log = conf.Log.With("name", conn.IdentityData().DisplayName, "uuid", conn.IdentityData().Identity, "raddr", conn.RemoteAddr().String()) + + s := &Session{} + *s = Session{ + openChunkTransactions: make([]map[uint64]struct{}, 0, 8), + closeBackground: make(chan struct{}), + handlers: map[uint32]packetHandler{}, + packets: make(chan packet.Packet, 256), + entityRuntimeIDs: map[*world.EntityHandle]uint64{}, + entities: map[uint64]*world.EntityHandle{}, + hiddenEntities: map[uuid.UUID]struct{}{}, + blobs: map[uint64][]byte{}, + chunkRadius: int32(r), + maxChunkRadius: int32(conf.MaxChunkRadius), + emoteChatMuted: conf.EmoteChatMuted, + conn: conn, + currentEntityRuntimeID: 1, + heldSlot: new(uint32), + recipes: make(map[uint32]recipe.Recipe), + conf: conf, + hudUpdates: make(map[hud.Element]bool), + hiddenHud: make(map[hud.Element]struct{}), + debugShapes: make(map[int]debug.Shape), + debugShapeUpdates: make([]debugShapeUpdate, 0, 256), + } + s.viewLayer = world.NewViewLayer(s) + s.openedWindow.Store(inventory.New(1, nil)) + s.openedPos.Store(&cube.Pos{}) + + var scoreboardName string + var scoreboardLines []string + s.currentScoreboard.Store(&scoreboardName) + s.currentLines.Store(&scoreboardLines) + + if conf.BlockRegistry == nil { + s.br = world.DefaultBlockRegistry + } else { + s.br = conf.BlockRegistry + } + + s.registerHandlers() + s.sendBiomes() + groups, items := creativeContent(s.br) + s.writePacket(&packet.CreativeContent{Groups: groups, Items: items}) + s.sendRecipes() + s.sendArmourTrimData() + s.SendSpeed(0.1) + go func() { + for { + select { + case <-s.closeBackground: + return + case pk := <-s.packets: + _ = conn.WritePacket(pk) + } + } + }() + return s +} + +// SetHandle sets the world.EntityHandle of the Session and attaches a skin to +// other players on join. +func (s *Session) SetHandle(handle *world.EntityHandle, skin skin.Skin) { + s.ent = handle + s.entityRuntimeIDs[handle] = selfEntityRuntimeID + s.entities[selfEntityRuntimeID] = handle + + s.joinSkin = skin + sessions.Add(s) +} + +// Spawn makes the Controllable passed spawn in the world.World. +// The function passed will be called when the session stops running. +func (s *Session) Spawn(c Controllable, tx *world.Tx) { + s.SendHealth(c.Health(), c.MaxHealth(), c.Absorption()) + s.SendExperience(c.ExperienceLevel(), c.ExperienceProgress()) + s.SendFood(c.Food(), 0, 0) + + pos := c.Position() + s.chunkLoader = world.NewLoader(int(s.chunkRadius), tx.World(), s) + s.chunkLoader.Move(tx, pos) + s.writePacket(&packet.NetworkChunkPublisherUpdate{ + Position: protocol.BlockPos{int32(pos[0]), int32(pos[1]), int32(pos[2])}, + Radius: uint32(s.chunkRadius) << 4, + }) + + s.sendAvailableEntities(tx.World()) + + c.SetGameMode(c.GameMode()) + for _, e := range c.Effects() { + s.SendEffect(e) + } + s.ViewEntityState(c) + + s.sendInv(s.inv, protocol.WindowIDInventory) + s.sendInv(s.ui, protocol.WindowIDUI) + s.sendInv(s.offHand, protocol.WindowIDOffHand) + s.sendInv(s.armour.Inventory(), protocol.WindowIDArmour) + + chat.Global.Subscribe(c) + if !s.conf.JoinMessage.Zero() { + chat.Global.Writet(s.conf.JoinMessage, s.conn.IdentityData().DisplayName) + } + + go s.background() + go s.handlePackets() +} + +// Close closes the session, which in turn closes the controllable and the connection that the session +// manages. Close ensures the method only runs code on the first call. +func (s *Session) Close(tx *world.Tx, c Controllable) { + s.once.Do(func() { + s.close(tx, c) + }) +} + +// close closes the session, which in turn closes the controllable and the connection that the session +// manages. +func (s *Session) close(tx *world.Tx, c Controllable) { + c.MoveItemsToInventory() + s.closeCurrentContainer(tx, false) + if s.viewLayer != nil { + _ = s.viewLayer.Close() + } + + s.conf.HandleStop(tx, c) + + // Clear the inventories so that they no longer hold references to the connection. + _ = s.inv.Close() + _ = s.offHand.Close() + _ = s.armour.Close() + + s.chunkLoader.Close(tx) + + if !s.conf.QuitMessage.Zero() { + chat.Global.Writet(s.conf.QuitMessage, s.conn.IdentityData().DisplayName) + } + chat.Global.Unsubscribe(c) + + // Note: Be aware of where RemoveEntity is called. This must not be done too + // early. + tx.RemoveEntity(c) + _ = s.ent.Close() + + // This should always be called last due to the timing of the removal of + // entity runtime IDs. + sessions.Remove(s, c) + s.entityMutex.Lock() + clear(s.entityRuntimeIDs) + clear(s.entities) + s.entityMutex.Unlock() +} + +// CloseConnection closes the underlying connection of the session so that the session ends up being closed +// eventually. +func (s *Session) CloseConnection() { + s.connOnce.Do(func() { + _ = s.conn.Close() + close(s.closeBackground) + }) +} + +// Addr returns the net.Addr of the client. +func (s *Session) Addr() net.Addr { + return s.conn.RemoteAddr() +} + +// Latency returns the latency of the connection. +func (s *Session) Latency() time.Duration { + return s.conn.Latency() +} + +// ClientData returns the login.ClientData of the underlying *minecraft.Conn. +func (s *Session) ClientData() login.ClientData { + return s.conn.ClientData() +} + +// handlePackets continuously handles incoming packets from the connection. It processes them accordingly. +// Once the connection is closed, handlePackets will return. +func (s *Session) handlePackets() { + defer func() { + // First close the Controllable. This might lead to a world change + // (player might be dead while disconnecting, in which case it will + // respawn first). + s.ent.ExecWorld(func(tx *world.Tx, e world.Entity) { + _ = e.(Controllable).Close() + }) + // Because the player might no longer be in the same world after + // closing, we create a new transaction + s.ent.ExecWorld(func(tx *world.Tx, e world.Entity) { + s.Close(tx, e.(Controllable)) + }) + }() + for { + pk, err := s.conn.ReadPacket() + if err != nil { + return + } + s.ent.ExecWorld(func(tx *world.Tx, e world.Entity) { + err = s.handlePacket(pk, tx, e.(Controllable)) + }) + if err != nil { + s.conf.Log.Debug("process packet: " + err.Error()) + return + } + } +} + +// background performs background tasks of the Session. This includes chunk sending and automatic command updating. +// background returns when the Session's connection is closed using CloseConnection. +func (s *Session) background() { + var ( + r map[string]map[int]cmd.Runnable + enums map[string]cmd.Enum + enumValues map[string][]string + softEnums = make(map[string]struct{}) + ok bool + i int + ) + + s.ent.ExecWorld(func(tx *world.Tx, e world.Entity) { + co := e.(Controllable) + r = s.sendAvailableCommands(co, softEnums) + enums, enumValues = s.enums(co) + }) + + t := time.NewTicker(time.Second / 20) + defer t.Stop() + for { + select { + case <-t.C: + s.ent.ExecWorld(func(tx *world.Tx, e world.Entity) { + c := e.(Controllable) + + if i++; i%20 == 0 { + // Enum resending happens relatively often and frequent updates are more important than with full + // command changes. Those are generally only related to permission changes, which doesn't happen often. + r = s.resendEnums(enums, enumValues, softEnums, r, c) + } + if i%100 == 0 { + // Try to resend commands only every 5 seconds. + if r, ok = s.resendCommands(r, c, softEnums); ok { + enums, enumValues = s.enums(c) + } + } + s.sendChunks(tx, c) + }) + case <-s.closeBackground: + return + } + } +} + +// sendChunks sends the next up to 4 chunks to the connection. What chunks are loaded depends on the connection of +// the chunk loader and the chunks that were previously loaded. +func (s *Session) sendChunks(tx *world.Tx, c Controllable) { + var worldSwitched bool + if w := tx.World(); s.chunkLoader.World() != w && w != nil { + worldSwitched = true + s.handleWorldSwitch(w, tx, c) + } + pos := c.Position() + s.chunkLoader.Move(tx, pos) + chunkPos := world.ChunkPos{int32(pos[0]) << 4, int32(pos[2]) << 4} + if s.lastChunkPos != chunkPos || worldSwitched { + s.lastChunkPos = chunkPos + s.writePacket(&packet.NetworkChunkPublisherUpdate{ + Position: protocol.BlockPos{int32(pos[0]), int32(pos[1]), int32(pos[2])}, + Radius: uint32(s.chunkRadius) << 4, + }) + } + + s.blobMu.Lock() + const maxChunkTransactions = 8 + toLoad := maxChunkTransactions - len(s.openChunkTransactions) + s.blobMu.Unlock() + if toLoad > 4 { + toLoad = 4 + } + s.chunkLoader.Load(tx, toLoad) +} + +// handleWorldSwitch handles the player of the Session switching worlds. +func (s *Session) handleWorldSwitch(w *world.World, tx *world.Tx, c Controllable) { + if s.conn.ClientCacheEnabled() { + s.blobMu.Lock() + s.blobs = map[uint64][]byte{} + s.openChunkTransactions = nil + s.blobMu.Unlock() + } + + dim, _ := world.DimensionID(w.Dimension()) + same := w.Dimension() == s.chunkLoader.World().Dimension() + if !same { + s.changeDimension(int32(dim), false, c) + } + s.ViewEntityTeleport(c, c.Position()) + s.chunkLoader.ChangeWorld(tx, w) +} + +// changeDimension changes the dimension of the client. If silent is set to true, the portal noise will be stopped +// immediately. +func (s *Session) changeDimension(dim int32, silent bool, c Controllable) { + s.changingDimension.Store(true) + h := s.handlers[packet.IDServerBoundLoadingScreen].(*ServerBoundLoadingScreenHandler) + id := h.currentID.Add(1) + h.expectedID.Store(id) + + s.writePacket(&packet.ChangeDimension{ + Dimension: dim, + Position: vec64To32(c.Position().Add(entityOffset(c))), + LoadingScreenID: protocol.Option(id), + }) + s.writePacket(&packet.StopSound{StopAll: silent}) + s.writePacket(&packet.PlayStatus{Status: packet.PlayStatusPlayerSpawn}) + + // As of v1.19.50, the dimension ack that is meant to be sent by the client is now sent by the server. The client + // still sends the ack, but after the server has sent it. Thanks to Mojang for another groundbreaking change. + s.writePacket(&packet.PlayerAction{ + EntityRuntimeID: selfEntityRuntimeID, + ActionType: protocol.PlayerActionDimensionChangeDone, + }) +} + +// ChangingDimension returns whether the session is currently changing dimension or not. +func (s *Session) ChangingDimension() bool { + return s.changingDimension.Load() +} + +// ChunkRadius returns the chunk radius of the session. +func (s *Session) ChunkRadius() int32 { + return s.chunkRadius +} + +// handlePacket handles an incoming packet, processing it accordingly. If the packet had invalid data or was +// otherwise not valid in its context, an error is returned. +func (s *Session) handlePacket(pk packet.Packet, tx *world.Tx, c Controllable) (err error) { + handler, ok := s.handlers[pk.ID()] + if !ok { + s.conf.Log.Debug("unhandled packet", "packet", fmt.Sprintf("%T", pk), "data", fmt.Sprintf("%+v", pk)[1:]) + return nil + } + if handler == nil { + // A nil handler means it was explicitly unhandled. + return nil + } + if err := handler.Handle(pk, s, tx, c); err != nil { + return fmt.Errorf("%T: %w", pk, err) + } + return nil +} + +// registerHandlers registers all packet handlers found in the packetHandler package. +func (s *Session) registerHandlers() { + s.handlers = map[uint32]packetHandler{ + packet.IDActorEvent: nil, + packet.IDAdventureSettings: nil, // Deprecated, the client still sends this though. + packet.IDAnimate: nil, + packet.IDAnvilDamage: nil, + packet.IDBlockActorData: &BlockActorDataHandler{}, + packet.IDBlockPickRequest: &BlockPickRequestHandler{}, + packet.IDBookEdit: &BookEditHandler{}, + packet.IDBossEvent: nil, + packet.IDClientCacheBlobStatus: &ClientCacheBlobStatusHandler{}, + packet.IDCommandRequest: &CommandRequestHandler{}, + packet.IDContainerClose: &ContainerCloseHandler{}, + packet.IDEmote: &EmoteHandler{}, + packet.IDEmoteList: nil, + packet.IDFilterText: nil, + packet.IDInteract: &InteractHandler{}, + packet.IDInventoryTransaction: &InventoryTransactionHandler{}, + packet.IDItemStackRequest: &ItemStackRequestHandler{changes: map[byte]map[byte]changeInfo{}, responseChanges: map[int32]map[*inventory.Inventory]map[byte]responseChange{}}, + packet.IDLecternUpdate: &LecternUpdateHandler{}, + packet.IDMobEquipment: &MobEquipmentHandler{}, + packet.IDModalFormResponse: &ModalFormResponseHandler{forms: make(map[uint32]form.Form)}, + packet.IDMovePlayer: nil, + packet.IDNPCRequest: &NPCRequestHandler{}, + packet.IDPlayerAction: &PlayerActionHandler{}, + packet.IDPlayerAuthInput: &PlayerAuthInputHandler{}, + packet.IDPlayerSkin: &PlayerSkinHandler{}, + packet.IDRequestAbility: &RequestAbilityHandler{}, + packet.IDRequestChunkRadius: &RequestChunkRadiusHandler{}, + packet.IDRespawn: &RespawnHandler{}, + packet.IDSetPlayerInventoryOptions: nil, + packet.IDSubChunkRequest: &SubChunkRequestHandler{}, + packet.IDText: &TextHandler{}, + packet.IDServerBoundLoadingScreen: &ServerBoundLoadingScreenHandler{}, + packet.IDServerBoundDiagnostics: &ServerBoundDiagnosticsHandler{}, + } +} + +// writePacket writes a packet to the session's connection if it is not Nop. +func (s *Session) writePacket(pk packet.Packet) { + if s == Nop { + return + } + select { + case s.packets <- pk: + case <-s.closeBackground: + } +} + +// actorIdentifier represents the structure of an actor identifier sent over the network. +type actorIdentifier struct { + // ID is a unique namespaced identifier for the entity. + ID string `nbt:"id"` +} + +// sendAvailableEntities sends all registered entities to the player. +func (s *Session) sendAvailableEntities(w *world.World) { + var identifiers []actorIdentifier + for _, t := range w.EntityRegistry().Types() { + identifiers = append(identifiers, actorIdentifier{ID: t.EncodeEntity()}) + } + serialisedEntityData, err := nbt.Marshal(map[string]any{"idlist": identifiers}) + if err != nil { + panic("should never happen") + } + s.writePacket(&packet.AvailableActorIdentifiers{SerialisedEntityIdentifiers: serialisedEntityData}) +} diff --git a/server/session/session_list.go b/server/session/session_list.go new file mode 100644 index 0000000..dbd50e0 --- /dev/null +++ b/server/session/session_list.go @@ -0,0 +1,149 @@ +package session + +import ( + "slices" + "sync" + + "github.com/df-mc/dragonfly/server/internal/sliceutil" + "github.com/df-mc/dragonfly/server/player/skin" + "github.com/df-mc/dragonfly/server/world" + "github.com/google/uuid" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +var sessions = new(sessionList) + +type sessionList struct { + mu sync.Mutex + s []*Session +} + +func (l *sessionList) Add(s *Session) { + l.mu.Lock() + defer l.mu.Unlock() + + for _, other := range l.s { + // Show all sessions to the new session and the new session to all + // existing sessions. + l.sendSessionTo(s, other) + l.sendSessionTo(other, s) + } + // Show the new session to itself. + l.sendSessionTo(s, s) + l.s = append(l.s, s) +} + +func (l *sessionList) Remove(s *Session, entity world.Entity) { + l.mu.Lock() + removedFrom := slices.Clone(l.s) + for _, other := range l.s { + l.unsendSessionFrom(s, other) + } + l.s = sliceutil.DeleteVal(l.s, s) + l.mu.Unlock() + + if entity == nil { + return + } + for _, other := range removedFrom { + if other.viewLayer != nil { + other.viewLayer.Remove(entity) + } + } +} + +func (l *sessionList) Lookup(id uuid.UUID) (*Session, bool) { + l.mu.Lock() + defer l.mu.Unlock() + + if index := slices.IndexFunc(l.s, func(session *Session) bool { + return session.ent.UUID() == id + }); index != -1 { + return l.s[index], true + } + return nil, false +} + +func (l *sessionList) sendSessionTo(s, to *Session) { + runtimeID := uint64(selfEntityRuntimeID) + + to.entityMutex.Lock() + if s != to { + to.currentEntityRuntimeID += 1 + runtimeID = to.currentEntityRuntimeID + } + to.entityRuntimeIDs[s.ent] = runtimeID + to.entities[runtimeID] = s.ent + to.entityMutex.Unlock() + + to.writePacket(&packet.PlayerList{ + ActionType: packet.PlayerListActionAdd, + Entries: []protocol.PlayerListEntry{{ + UUID: s.ent.UUID(), + EntityUniqueID: int64(runtimeID), + Username: s.conn.IdentityData().DisplayName, + XUID: s.conn.IdentityData().XUID, + Skin: skinToProtocol(s.joinSkin), + }}, + }) +} + +func (l *sessionList) unsendSessionFrom(s, from *Session) { + from.entityMutex.Lock() + delete(from.entities, from.entityRuntimeIDs[s.ent]) + delete(from.entityRuntimeIDs, s.ent) + from.entityMutex.Unlock() + + from.writePacket(&packet.PlayerList{ + ActionType: packet.PlayerListActionRemove, + Entries: []protocol.PlayerListEntry{{UUID: s.ent.UUID()}}, + }) +} + +// skinToProtocol converts a skin to its protocol representation. +func skinToProtocol(s skin.Skin) protocol.Skin { + var animations []protocol.SkinAnimation + for _, animation := range s.Animations { + protocolAnim := protocol.SkinAnimation{ + ImageWidth: uint32(animation.Bounds().Max.X), + ImageHeight: uint32(animation.Bounds().Max.Y), + ImageData: animation.Pix, + FrameCount: float32(animation.FrameCount), + } + switch animation.Type() { + case skin.AnimationHead: + protocolAnim.AnimationType = protocol.SkinAnimationHead + case skin.AnimationBody32x32: + protocolAnim.AnimationType = protocol.SkinAnimationBody32x32 + case skin.AnimationBody128x128: + protocolAnim.AnimationType = protocol.SkinAnimationBody128x128 + } + protocolAnim.ExpressionType = uint32(animation.AnimationExpression) + animations = append(animations, protocolAnim) + } + + fullID := s.FullID + if fullID == "" { + fullID = uuid.New().String() + } + return protocol.Skin{ + PlayFabID: s.PlayFabID, + SkinID: uuid.New().String(), + SkinResourcePatch: s.ModelConfig.Encode(), + SkinImageWidth: uint32(s.Bounds().Max.X), + SkinImageHeight: uint32(s.Bounds().Max.Y), + SkinData: s.Pix, + CapeImageWidth: uint32(s.Cape.Bounds().Max.X), + CapeImageHeight: uint32(s.Cape.Bounds().Max.Y), + CapeData: s.Cape.Pix, + SkinGeometry: s.Model, + PersonaSkin: s.Persona, + CapeID: uuid.New().String(), + FullID: fullID, + Animations: animations, + Trusted: true, + OverrideAppearance: true, + GeometryDataEngineVersion: []byte(protocol.CurrentVersion), + } +} diff --git a/server/session/text.go b/server/session/text.go new file mode 100644 index 0000000..d8418df --- /dev/null +++ b/server/session/text.go @@ -0,0 +1,186 @@ +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" +) + +// SendMessage ... +func (s *Session) SendMessage(message string) { + s.writePacket(&packet.Text{ + TextType: packet.TextTypeRaw, + Message: message, + }) +} + +// SendTranslation sends a translation localised for a specific language.Tag. +func (s *Session) SendTranslation(t chat.Translation, l language.Tag, a []any) { + tr := t.F(a...) + s.writePacket(&packet.Text{ + TextType: packet.TextTypeTranslation, + NeedsTranslation: true, + Message: tr.Resolve(l), + Parameters: tr.Params(l), + }) +} + +// SendTip ... +func (s *Session) SendTip(message string) { + s.writePacket(&packet.Text{ + TextType: packet.TextTypeTip, + Message: message, + }) +} + +// SendAnnouncement ... +func (s *Session) SendAnnouncement(message string) { + s.writePacket(&packet.Text{ + TextType: packet.TextTypeAnnouncement, + Message: message, + }) +} + +// SendPopup ... +func (s *Session) SendPopup(message string) { + s.writePacket(&packet.Text{ + TextType: packet.TextTypePopup, + Message: message, + }) +} + +// SendJukeboxPopup ... +func (s *Session) SendJukeboxPopup(message string) { + s.writePacket(&packet.Text{ + TextType: packet.TextTypeJukeboxPopup, + Message: message, + }) +} + +// SendToast ... +func (s *Session) SendToast(title, message string) { + s.writePacket(&packet.ToastRequest{ + Title: title, + Message: message, + }) +} + +// SendScoreboard ... +func (s *Session) SendScoreboard(sb *scoreboard.Scoreboard) { + if s == Nop { + return + } + currentName, currentLines := *s.currentScoreboard.Load(), *s.currentLines.Load() + + if currentName != sb.Name() { + s.RemoveScoreboard() + pk := &packet.SetDisplayObjective{ + DisplaySlot: "sidebar", + ObjectiveName: sb.Name(), + DisplayName: sb.Name(), + CriteriaName: "dummy", + } + if sb.Descending() { + pk.SortOrder = packet.ScoreboardSortOrderDescending + } else { + pk.SortOrder = packet.ScoreboardSortOrderAscending + } + s.writePacket(pk) + name, lines := sb.Name(), append([]string(nil), sb.Lines()...) + s.currentScoreboard.Store(&name) + s.currentLines.Store(&lines) + } else { + // Remove all current lines from the scoreboard. We can't replace them without removing them. + pk := &packet.SetScore{ActionType: packet.ScoreboardActionRemove} + for i := range currentLines { + pk.Entries = append(pk.Entries, protocol.ScoreboardEntry{ + EntryID: int64(i), + ObjectiveName: currentName, + Score: int32(i), + }) + } + if len(pk.Entries) > 0 { + s.writePacket(pk) + } + } + pk := &packet.SetScore{ActionType: packet.ScoreboardActionModify} + for k, line := range sb.Lines() { + if len(line) == 0 { + line = "§" + colours[k] + } + pk.Entries = append(pk.Entries, protocol.ScoreboardEntry{ + EntryID: int64(k), + ObjectiveName: sb.Name(), + Score: int32(k), + IdentityType: protocol.ScoreboardIdentityFakePlayer, + DisplayName: line, + }) + } + if len(pk.Entries) > 0 { + s.writePacket(pk) + } +} + +// colours holds a list of colour codes to be filled out for empty lines in a scoreboard. +var colours = [15]string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"} + +// RemoveScoreboard ... +func (s *Session) RemoveScoreboard() { + s.writePacket(&packet.RemoveObjective{ObjectiveName: *s.currentScoreboard.Load()}) + var name string + var lines []string + s.currentScoreboard.Store(&name) + s.currentLines.Store(&lines) +} + +// SendBossBar sends a boss bar to the player with the text passed and the health percentage of the bar. +// SendBossBar removes any boss bar that might be active before sending the new one. +func (s *Session) SendBossBar(text string, colour uint8, healthPercentage float64) { + s.RemoveBossBar() + s.writePacket(&packet.BossEvent{ + BossEntityUniqueID: selfEntityRuntimeID, + EventType: packet.BossEventShow, + BossBarTitle: text, + HealthPercentage: float32(healthPercentage), + Colour: colour, + }) +} + +// RemoveBossBar removes any boss bar currently active on the player's screen. +func (s *Session) RemoveBossBar() { + s.writePacket(&packet.BossEvent{ + BossEntityUniqueID: selfEntityRuntimeID, + EventType: packet.BossEventHide, + }) +} + +const tickLength = time.Second / 20 + +// SetTitleDurations ... +func (s *Session) SetTitleDurations(fadeInDuration, remainDuration, fadeOutDuration time.Duration) { + s.writePacket(&packet.SetTitle{ + ActionType: packet.TitleActionSetDurations, + FadeInDuration: int32(fadeInDuration / tickLength), + RemainDuration: int32(remainDuration / tickLength), + FadeOutDuration: int32(fadeOutDuration / tickLength), + }) +} + +// SendTitle ... +func (s *Session) SendTitle(text string) { + s.writePacket(&packet.SetTitle{ActionType: packet.TitleActionSetTitle, Text: text}) +} + +// SendSubtitle ... +func (s *Session) SendSubtitle(text string) { + s.writePacket(&packet.SetTitle{ActionType: packet.TitleActionSetSubtitle, Text: text}) +} + +// SendActionBarMessage ... +func (s *Session) SendActionBarMessage(text string) { + s.writePacket(&packet.SetTitle{ActionType: packet.TitleActionSetActionBar, Text: text}) +} diff --git a/server/session/view_layer.go b/server/session/view_layer.go new file mode 100644 index 0000000..632b580 --- /dev/null +++ b/server/session/view_layer.go @@ -0,0 +1,73 @@ +package session + +import "github.com/df-mc/dragonfly/server/world" + +// ViewLayer returns the session's ViewLayer. The layer may be used to override how entities are viewed +// by this session, such as with a different name tag or visibility state. +func (s *Session) ViewLayer() *world.ViewLayer { + return s.viewLayer +} + +// ViewNameTag overwrites the public name tag of the entity and immediately refreshes it for this session. +func (s *Session) ViewNameTag(entity world.Entity, nameTag string) { + if s.viewLayer == nil { + return + } + s.viewLayer.ViewNameTag(entity, nameTag) +} + +// ViewPublicNameTag removes the name tag override from the entity and immediately refreshes it for this session. +func (s *Session) ViewPublicNameTag(entity world.Entity) { + if s.viewLayer == nil { + return + } + s.viewLayer.ViewPublicNameTag(entity) +} + +// ViewScoreTag overwrites the public score tag of the entity and immediately refreshes it for this session. +func (s *Session) ViewScoreTag(entity world.Entity, scoreTag string) { + if s.viewLayer == nil { + return + } + s.viewLayer.ViewScoreTag(entity, scoreTag) +} + +// ViewPublicScoreTag removes the score tag override from the entity and immediately refreshes it for this session. +func (s *Session) ViewPublicScoreTag(entity world.Entity) { + if s.viewLayer == nil { + return + } + s.viewLayer.ViewPublicScoreTag(entity) +} + +// ViewVisibility overwrites the public visibility of the entity and immediately refreshes it for this session. +func (s *Session) ViewVisibility(entity world.Entity, level world.VisibilityLevel) { + if s.viewLayer == nil { + return + } + s.viewLayer.ViewVisibility(entity, level) +} + +// RemoveViewLayer removes all overrides for the entity and immediately refreshes it for this session. +func (s *Session) RemoveViewLayer(entity world.Entity) { + if s.viewLayer == nil { + return + } + s.viewLayer.Remove(entity) +} + +// ViewLayerEntityChanged refreshes the entity metadata for this session if the entity is currently visible. +func (s *Session) ViewLayerEntityChanged(e world.Entity) { + if s.entityHidden(e) || !s.viewingEntity(e.H()) { + return + } + s.ViewEntityState(e) +} + +// viewingEntity checks if this session currently has a runtime ID assigned to the entity handle. +func (s *Session) viewingEntity(handle *world.EntityHandle) bool { + s.entityMutex.RLock() + _, ok := s.entityRuntimeIDs[handle] + s.entityMutex.RUnlock() + return ok +} diff --git a/server/session/world.go b/server/session/world.go new file mode 100644 index 0000000..d624576 --- /dev/null +++ b/server/session/world.go @@ -0,0 +1,1440 @@ +package session + +import ( + "bytes" + "fmt" + "image/color" + "math/rand/v2" + "strings" + "time" + + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/entity" + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/inventory" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/particle" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl32" + "github.com/go-gl/mathgl/mgl64" + "github.com/google/uuid" + "github.com/sandertv/gophertunnel/minecraft/nbt" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// NetworkEncodeableEntity is a world.EntityType where the save ID and network +// ID are not the same. +type NetworkEncodeableEntity interface { + // NetworkEncodeEntity returns the network type ID of the entity. This is + // NOT the save ID. + NetworkEncodeEntity() string +} + +// OffsetEntity is a world.EntityType that has an additional offset when sent +// over network. This is mostly the case for older entities such as players and +// TNT. +type OffsetEntity interface { + NetworkOffset() float64 +} + +// entityHidden checks if a world.Entity is being explicitly hidden from the Session. +func (s *Session) entityHidden(e world.Entity) bool { + s.entityMutex.RLock() + _, ok := s.hiddenEntities[e.H().UUID()] + s.entityMutex.RUnlock() + return ok +} + +// ViewEntity ... +func (s *Session) ViewEntity(e world.Entity) { + if e.H() == s.ent { + s.ViewEntityState(e) + return + } + if s.entityHidden(e) { + return + } + var runtimeID uint64 + + _, controllable := e.(Controllable) + + s.entityMutex.Lock() + if id, ok := s.entityRuntimeIDs[e.H()]; ok && controllable { + runtimeID = id + } else { + s.currentEntityRuntimeID += 1 + runtimeID = s.currentEntityRuntimeID + s.entityRuntimeIDs[e.H()] = runtimeID + s.entities[runtimeID] = e.H() + } + s.entityMutex.Unlock() + + yaw, pitch := e.Rotation().Elem() + metadata := s.entityMetadata(e) + + id := e.H().Type().EncodeEntity() + switch v := e.(type) { + case Controllable: + _, actualPlayer := sessions.Lookup(v.UUID()) + if !actualPlayer { + s.writePacket(&packet.PlayerList{ActionType: packet.PlayerListActionAdd, Entries: []protocol.PlayerListEntry{{ + UUID: v.UUID(), + EntityUniqueID: int64(runtimeID), + Username: v.Name(), + Skin: skinToProtocol(v.Skin()), + }}}) + } + + s.writePacket(&packet.AddPlayer{ + EntityMetadata: metadata, + EntityRuntimeID: runtimeID, + GameType: gameTypeFromMode(v.GameMode()), + HeadYaw: float32(yaw), + Pitch: float32(pitch), + Position: vec64To32(e.Position()), + UUID: v.UUID(), + Username: v.Name(), + Yaw: float32(yaw), + AbilityData: protocol.AbilityData{ + EntityUniqueID: int64(runtimeID), + Layers: []protocol.AbilityLayer{{ + Type: protocol.AbilityLayerTypeBase, + Abilities: protocol.AbilityCount - 1, + }}, + }, + }) + if !actualPlayer { + s.writePacket(&packet.PlayerList{ActionType: packet.PlayerListActionRemove, Entries: []protocol.PlayerListEntry{{ + UUID: v.UUID(), + }}}) + } else { + s.ViewSkin(e) + } + return + case *entity.Ent: + switch e.H().Type() { + case entity.ItemType: + s.writePacket(&packet.AddItemActor{ + EntityUniqueID: int64(runtimeID), + EntityRuntimeID: runtimeID, + Item: instanceFromItem(s.br, v.Behaviour().(*entity.ItemBehaviour).Item()), + Position: vec64To32(v.Position()), + Velocity: vec64To32(v.Velocity()), + EntityMetadata: metadata, + }) + return + case entity.TextType: + metadata[protocol.EntityDataKeyVariant] = int32(s.br.BlockRuntimeID(block.Air{})) + case entity.FallingBlockType: + metadata[protocol.EntityDataKeyVariant] = int32(s.br.BlockRuntimeID(v.Behaviour().(*entity.FallingBlockBehaviour).Block())) + } + } + if v, ok := e.H().Type().(NetworkEncodeableEntity); ok { + id = v.NetworkEncodeEntity() + } + + var vel mgl64.Vec3 + if v, ok := e.(interface{ Velocity() mgl64.Vec3 }); ok { + vel = v.Velocity() + } + + s.writePacket(&packet.AddActor{ + EntityUniqueID: int64(runtimeID), + EntityRuntimeID: runtimeID, + EntityType: id, + EntityMetadata: metadata, + Position: vec64To32(e.Position()), + Velocity: vec64To32(vel), + Pitch: float32(pitch), + Yaw: float32(yaw), + HeadYaw: float32(yaw), + }) +} + +// ViewEntityGameMode ... +func (s *Session) ViewEntityGameMode(e world.Entity) { + if s.entityHidden(e) { + return + } + c, ok := e.(Controllable) + if !ok { + return + } + s.writePacket(&packet.UpdatePlayerGameType{ + GameType: gameTypeFromMode(c.GameMode()), + PlayerUniqueID: int64(s.entityRuntimeID(c)), + }) +} + +// HideEntity ... +func (s *Session) HideEntity(e world.Entity) { + if s.entityRuntimeID(e) == selfEntityRuntimeID { + return + } + + s.entityMutex.Lock() + id, ok := s.entityRuntimeIDs[e.H()] + if _, controllable := e.(Controllable); !controllable { + delete(s.entityRuntimeIDs, e.H()) + delete(s.entities, id) + } + s.entityMutex.Unlock() + if !ok { + // The entity was already removed some other way. We don't need to send a packet. + return + } + s.writePacket(&packet.RemoveActor{EntityUniqueID: int64(id)}) +} + +// ViewEntityMovement ... +func (s *Session) ViewEntityMovement(e world.Entity, pos mgl64.Vec3, rot cube.Rotation, onGround bool) { + id := s.entityRuntimeID(e) + if (id == selfEntityRuntimeID && s.moving) || s.entityHidden(e) { + return + } + + flags := byte(0) + if onGround { + flags |= packet.MoveFlagOnGround + } + s.writePacket(&packet.MoveActorAbsolute{ + EntityRuntimeID: id, + Position: vec64To32(pos.Add(entityOffset(e))), + Rotation: vec64To32(mgl64.Vec3{rot.Pitch(), rot.Yaw(), rot.Yaw()}), + Flags: flags, + }) +} + +// ViewEntityVelocity ... +func (s *Session) ViewEntityVelocity(e world.Entity, velocity mgl64.Vec3) { + if s.entityHidden(e) { + return + } + s.writePacket(&packet.SetActorMotion{ + EntityRuntimeID: s.entityRuntimeID(e), + Velocity: vec64To32(velocity), + }) +} + +// entityOffset returns the offset that entities have client-side. +func entityOffset(e world.Entity) mgl64.Vec3 { + if offset, ok := e.H().Type().(OffsetEntity); ok { + return mgl64.Vec3{0, offset.NetworkOffset()} + } + return mgl64.Vec3{} +} + +// ViewTime ... +func (s *Session) ViewTime(time int) { + s.writePacket(&packet.SetTime{Time: int32(time)}) +} + +// ViewTimeCycle ... +func (s *Session) ViewTimeCycle(doDayLightCycle bool) { + s.sendGameRules([]protocol.GameRule{{Name: "dodaylightcycle", Value: doDayLightCycle}}) +} + +// ViewEntityTeleport ... +func (s *Session) ViewEntityTeleport(e world.Entity, position mgl64.Vec3) { + id := s.entityRuntimeID(e) + if s.entityHidden(e) { + return + } + + yaw, pitch := e.Rotation().Elem() + if id == selfEntityRuntimeID { + s.teleportPos.Store(&position) + } + + s.writePacket(&packet.SetActorMotion{EntityRuntimeID: id}) + if _, ok := e.(Controllable); ok { + s.writePacket(&packet.MovePlayer{ + EntityRuntimeID: id, + Position: vec64To32(position.Add(entityOffset(e))), + Pitch: float32(pitch), + Yaw: float32(yaw), + HeadYaw: float32(yaw), + Mode: packet.MoveModeTeleport, + }) + return + } + s.writePacket(&packet.MoveActorAbsolute{ + EntityRuntimeID: id, + Position: vec64To32(position.Add(entityOffset(e))), + Rotation: vec64To32(mgl64.Vec3{pitch, yaw, yaw}), + Flags: packet.MoveFlagTeleport, + }) +} + +// ViewEntityItems ... +func (s *Session) ViewEntityItems(e world.Entity) { + runtimeID := s.entityRuntimeID(e) + if runtimeID == selfEntityRuntimeID || s.entityHidden(e) { + // Don't view the items of the entity if the entity is the Controllable entity of the session. + return + } + c, ok := e.(item.Carrier) + if !ok { + return + } + + mainHand, offHand := c.HeldItems() + + // Show the main hand item. + s.writePacket(&packet.MobEquipment{ + EntityRuntimeID: runtimeID, + NewItem: instanceFromItem(s.br, mainHand), + }) + // Show the off-hand item. + s.writePacket(&packet.MobEquipment{ + EntityRuntimeID: runtimeID, + NewItem: instanceFromItem(s.br, offHand), + WindowID: protocol.WindowIDOffHand, + }) +} + +// ViewEntityArmour ... +func (s *Session) ViewEntityArmour(e world.Entity) { + runtimeID := s.entityRuntimeID(e) + if runtimeID == selfEntityRuntimeID || s.entityHidden(e) { + // Don't view the items of the entity if the entity is the Controllable entity of the session. + return + } + armoured, ok := e.(interface { + Armour() *inventory.Armour + }) + if !ok { + return + } + + inv := armoured.Armour() + + // Show the entity's armour + s.writePacket(&packet.MobArmourEquipment{ + EntityRuntimeID: runtimeID, + Helmet: instanceFromItem(s.br, inv.Helmet()), + Chestplate: instanceFromItem(s.br, inv.Chestplate()), + Leggings: instanceFromItem(s.br, inv.Leggings()), + Boots: instanceFromItem(s.br, inv.Boots()), + }) +} + +// ViewItemCooldown ... +func (s *Session) ViewItemCooldown(item world.Item, duration time.Duration) { + name, _ := item.EncodeItem() + s.writePacket(&packet.ClientStartItemCooldown{ + Category: strings.Split(name, ":")[1], + Duration: int32(duration.Milliseconds() / 50), + }) +} + +// ViewSleepingPlayers ... +func (s *Session) ViewSleepingPlayers(sleeping, max int) { + buf := bytes.NewBuffer(nil) + _ = nbt.NewEncoderWithEncoding(buf, nbt.NetworkLittleEndian).Encode(map[string]any{ + "ableToSleep": int32(max), + "overworldPlayerCount": int32(max), + "sleepingPlayerCount": int32(sleeping), + }) + + eventData := buf.Bytes() + + s.writePacket(&packet.LevelEventGeneric{ + EventID: packet.LevelEventSleepingPlayers, + SerialisedEventData: eventData[2 : len(eventData)-1], + }) +} + +// ViewParticle ... +func (s *Session) ViewParticle(pos mgl64.Vec3, p world.Particle) { + switch pa := p.(type) { + case particle.DragonEggTeleport: + xSign, ySign, zSign := 0, 0, 0 + if pa.Diff.X() < 0 { + xSign = 1 << 24 + } + if pa.Diff.Y() < 0 { + ySign = 1 << 25 + } + if pa.Diff.Z() < 0 { + zSign = 1 << 26 + } + + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventParticlesDragonEgg, + Position: vec64To32(pos), + EventData: int32((((((abs(pa.Diff.X()) << 16) | (abs(pa.Diff.Y()) << 8)) | abs(pa.Diff.Z())) | xSign) | ySign) | zSign), + }) + case particle.Note: + s.writePacket(&packet.BlockEvent{ + EventType: pa.Instrument.Int32(), + EventData: int32(pa.Pitch), + Position: protocol.BlockPos{int32(pos.X()), int32(pos.Y()), int32(pos.Z())}, + }) + case particle.HugeExplosion: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventParticlesExplosion, + Position: vec64To32(pos), + }) + case particle.BoneMeal: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventParticleCropGrowth, + Position: vec64To32(pos), + EventData: int32(boolByte(pa.Area)), + }) + case particle.BlockForceField: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventParticleDenyBlock, + Position: vec64To32(pos), + }) + case particle.BlockBreak: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventParticlesDestroyBlock, + Position: vec64To32(pos), + EventData: int32(s.br.BlockRuntimeID(pa.Block)), + }) + case particle.PunchBlock: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventParticlesCrackBlock, + Position: vec64To32(pos), + EventData: int32(s.br.BlockRuntimeID(pa.Block)) | (int32(pa.Face) << 24), + }) + case particle.EndermanTeleport: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventParticlesTeleport, + Position: vec64To32(pos), + }) + case particle.Flame: + if pa.Colour != (color.RGBA{}) { + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventParticleLegacyEvent | 57, + Position: vec64To32(pos), + EventData: nbtconv.Int32FromRGBA(pa.Colour), + }) + return + } + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventParticleLegacyEvent | 8, + Position: vec64To32(pos), + }) + case particle.Evaporate: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventParticlesEvaporateWater, + Position: vec64To32(pos), + }) + case particle.SnowballPoof: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventParticleLegacyEvent | 15, + Position: vec64To32(pos), + }) + case particle.EggSmash: + rid, meta, _ := world.ItemRuntimeID(item.Egg{}) + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventParticleLegacyEvent | 14, + EventData: (rid << 16) | int32(meta), + Position: vec64To32(pos), + }) + case particle.Splash: + if (pa.Colour == color.RGBA{}) { + pa.Colour, _ = effect.ResultingColour(nil) + } + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventParticlesPotionSplash, + EventData: (int32(pa.Colour.A) << 24) | (int32(pa.Colour.R) << 16) | (int32(pa.Colour.G) << 8) | int32(pa.Colour.B), + Position: vec64To32(pos), + }) + case particle.Effect: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventParticleLegacyEvent | 34, + EventData: (int32(pa.Colour.A) << 24) | (int32(pa.Colour.R) << 16) | (int32(pa.Colour.G) << 8) | int32(pa.Colour.B), + Position: vec64To32(pos), + }) + case particle.EntityFlame: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventParticleLegacyEvent | 19, + Position: vec64To32(pos), + }) + case particle.Dust: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventParticleLegacyEvent | 33, + Position: vec64To32(pos), + EventData: nbtconv.Int32FromRGBA(pa.Colour), + }) + case particle.WaterDrip: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventParticleLegacyEvent | 28, + Position: vec64To32(pos), + }) + case particle.LavaDrip: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventParticleLegacyEvent | 29, + Position: vec64To32(pos), + }) + case particle.Lava: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventParticleLegacyEvent | 10, + Position: vec64To32(pos), + }) + case particle.DustPlume: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventParticleLegacyEvent | 88, + Position: vec64To32(pos), + }) + } +} + +// tierToSoundEvent converts an item.ArmourTier to a sound event associated with equipping it. +func tierToSoundEvent(tier item.ArmourTier) string { + switch tier.(type) { + case item.ArmourTierLeather: + return packet.SoundEventEquipLeather + case item.ArmourTierCopper: + return packet.SoundEventEquipCopper + case item.ArmourTierGold: + return packet.SoundEventEquipGold + case item.ArmourTierChain: + return packet.SoundEventEquipChain + case item.ArmourTierIron: + return packet.SoundEventEquipIron + case item.ArmourTierDiamond: + return packet.SoundEventEquipDiamond + case item.ArmourTierNetherite: + return packet.SoundEventEquipNetherite + } + return packet.SoundEventEquipGeneric +} + +// playSound plays a world.Sound at a position, disabling relative volume if set to true. +func (s *Session) playSound(pos mgl64.Vec3, t world.Sound, disableRelative bool) { + pk := &packet.LevelSoundEvent{ + Position: vec64To32(pos), + EntityType: ":", + ExtraData: -1, + DisableRelativeVolume: disableRelative, + } + switch so := t.(type) { + case sound.EquipItem: + switch i := so.Item.(type) { + case item.Helmet: + pk.SoundType = tierToSoundEvent(i.Tier) + case item.Chestplate: + pk.SoundType = tierToSoundEvent(i.Tier) + case item.Leggings: + pk.SoundType = tierToSoundEvent(i.Tier) + case item.Boots: + pk.SoundType = tierToSoundEvent(i.Tier) + case item.Elytra: + pk.SoundType = packet.SoundEventEquipElytra + default: + pk.SoundType = packet.SoundEventEquipGeneric + } + case sound.Note: + pk.SoundType = packet.SoundEventNote + pk.ExtraData = (so.Instrument.Int32() << 8) | int32(so.Pitch) + case sound.DoorCrash: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventSoundZombieDoorCrash, + Position: vec64To32(pos), + }) + return + case sound.Explosion: + pk.SoundType = packet.SoundEventExplode + case sound.Thunder: + pk.SoundType, pk.EntityType = packet.SoundEventThunder, "minecraft:lightning_bolt" + case sound.Click: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventSoundClick, + Position: vec64To32(pos), + }) + return + case sound.SignWaxed: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventWaxOn, + Position: vec64To32(pos), + }) + case sound.WaxedSignFailedInteraction: + pk.SoundType = packet.SoundEventWaxedSignInteractFail + case sound.WaxRemoved: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventWaxOff, + Position: vec64To32(pos), + }) + case sound.CopperScraped: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventScrape, + Position: vec64To32(pos), + }) + case sound.Pop: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventSoundInfinityArrowPickup, + Position: vec64To32(pos), + }) + return + case sound.Teleport: + pk.SoundType = packet.SoundEventTeleport + case sound.ItemAdd: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventSoundAddItem, + Position: vec64To32(pos), + }) + return + case sound.ItemFrameRemove: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventSoundItemFrameRemoveItem, + Position: vec64To32(pos), + }) + return + case sound.ItemFrameRotate: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventSoundItemFrameRotateItem, + Position: vec64To32(pos), + }) + return + case sound.GhastWarning: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventSoundGhastWarning, + Position: vec64To32(pos), + }) + return + case sound.GhastShoot: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventSoundGhastFireball, + Position: vec64To32(pos), + }) + return + case sound.TNT: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventSoundFuse, + Position: vec64To32(pos), + }) + return + case sound.FireworkLaunch: + pk.SoundType = packet.SoundEventLaunch + case sound.FireworkHugeBlast: + pk.SoundType = packet.SoundEventLargeBlast + case sound.FireworkBlast: + pk.SoundType = packet.SoundEventBlast + case sound.FireworkTwinkle: + 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: + pk.SoundType = packet.SoundEventSmokerUse + case sound.PotionBrewed: + pk.SoundType = packet.SoundEventPotionBrewed + case sound.UseSpyglass: + pk.SoundType = packet.SoundEventUseSpyglass + case sound.StopUsingSpyglass: + pk.SoundType = packet.SoundEventStopUsingSpyglass + case sound.GoatHorn: + switch so.Horn { + case sound.Ponder(): + pk.SoundType = packet.SoundEventGoatCall0 + case sound.Sing(): + pk.SoundType = packet.SoundEventGoatCall1 + case sound.Seek(): + pk.SoundType = packet.SoundEventGoatCall2 + case sound.Feel(): + pk.SoundType = packet.SoundEventGoatCall3 + case sound.Admire(): + pk.SoundType = packet.SoundEventGoatCall4 + case sound.Call(): + pk.SoundType = packet.SoundEventGoatCall5 + case sound.Yearn(): + pk.SoundType = packet.SoundEventGoatCall6 + case sound.Dream(): + pk.SoundType = packet.SoundEventGoatCall7 + } + case sound.FireExtinguish: + pk.SoundType = packet.SoundEventExtinguishFire + case sound.Ignite: + pk.SoundType = packet.SoundEventIgnite + case sound.Burning: + pk.SoundType = packet.SoundEventPlayerHurtOnFire + case sound.Drowning: + pk.SoundType = packet.SoundEventPlayerHurtDrown + case sound.Fall: + pk.EntityType = "minecraft:player" + if so.Distance > 4 { + pk.SoundType = packet.SoundEventFallBig + break + } + pk.SoundType = packet.SoundEventFallSmall + case sound.Burp: + pk.SoundType = packet.SoundEventBurp + case sound.DoorOpen: + pk.SoundType, pk.ExtraData = packet.SoundEventDoorOpen, int32(s.br.BlockRuntimeID(so.Block)) + case sound.DoorClose: + pk.SoundType, pk.ExtraData = packet.SoundEventDoorClose, int32(s.br.BlockRuntimeID(so.Block)) + case sound.TrapdoorOpen: + pk.SoundType, pk.ExtraData = packet.SoundEventTrapdoorOpen, int32(s.br.BlockRuntimeID(so.Block)) + case sound.TrapdoorClose: + pk.SoundType, pk.ExtraData = packet.SoundEventTrapdoorClose, int32(s.br.BlockRuntimeID(so.Block)) + case sound.FenceGateOpen: + pk.SoundType, pk.ExtraData = packet.SoundEventFenceGateOpen, int32(s.br.BlockRuntimeID(so.Block)) + case sound.FenceGateClose: + pk.SoundType, pk.ExtraData = packet.SoundEventFenceGateClose, int32(s.br.BlockRuntimeID(so.Block)) + case sound.Deny: + pk.SoundType = packet.SoundEventDeny + case sound.BlockPlace: + pk.SoundType, pk.ExtraData = packet.SoundEventPlace, int32(s.br.BlockRuntimeID(so.Block)) + case sound.AnvilLand: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventSoundAnvilLand, + Position: vec64To32(pos), + }) + return + case sound.AnvilUse: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventSoundAnvilUsed, + Position: vec64To32(pos), + }) + return + case sound.AnvilBreak: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventSoundAnvilBroken, + Position: vec64To32(pos), + }) + return + case sound.ChestClose: + pk.SoundType = packet.SoundEventChestClosed + case sound.ChestOpen: + pk.SoundType = packet.SoundEventChestOpen + case sound.EnderChestClose: + pk.SoundType = packet.SoundEventEnderChestClosed + case sound.EnderChestOpen: + pk.SoundType = packet.SoundEventEnderChestOpen + case sound.BarrelClose: + pk.SoundType = packet.SoundEventBarrelClose + case sound.BarrelOpen: + pk.SoundType = packet.SoundEventBarrelOpen + case sound.BlockBreaking: + pk.SoundType, pk.ExtraData = packet.SoundEventHit, int32(s.br.BlockRuntimeID(so.Block)) + case sound.ItemBreak: + pk.SoundType = packet.SoundEventBreak + case sound.ItemUseOn: + pk.SoundType, pk.ExtraData = packet.SoundEventItemUseOn, int32(s.br.BlockRuntimeID(so.Block)) + case sound.Fizz: + pk.SoundType = packet.SoundEventFizz + case sound.GlassBreak: + pk.SoundType = packet.SoundEventGlass + case sound.Attack: + pk.SoundType, pk.EntityType = packet.SoundEventAttackStrong, "minecraft:player" + if !so.Damage { + pk.SoundType = packet.SoundEventAttackNoDamage + } + case sound.BucketFill: + if _, water := so.Liquid.(block.Water); water { + pk.SoundType = packet.SoundEventBucketFillWater + break + } + pk.SoundType = packet.SoundEventBucketFillLava + case sound.BucketEmpty: + if _, water := so.Liquid.(block.Water); water { + pk.SoundType = packet.SoundEventBucketEmptyWater + break + } + pk.SoundType = packet.SoundEventBucketEmptyLava + case sound.BowShoot: + pk.SoundType = packet.SoundEventBow + case sound.CrossbowLoad: + switch so.Stage { + case sound.CrossbowLoadingStart: + pk.SoundType = packet.SoundEventCrossbowLoadingStart + if so.QuickCharge { + pk.SoundType = packet.SoundEventCrossbowQuickChargeStart + } + case sound.CrossbowLoadingMiddle: + pk.SoundType = packet.SoundEventCrossbowLoadingMiddle + if so.QuickCharge { + pk.SoundType = packet.SoundEventCrossbowQuickChargeMiddle + } + case sound.CrossbowLoadingEnd: + pk.SoundType = packet.SoundEventCrossbowLoadingEnd + if so.QuickCharge { + pk.SoundType = packet.SoundEventCrossbowQuickChargeEnd + } + default: + panic("invalid crossbow loading stage") + } + case sound.CrossbowShoot: + pk.SoundType = packet.SoundEventCrossbowShoot + case sound.ArrowHit: + pk.SoundType = packet.SoundEventBowHit + case sound.ItemThrow: + pk.SoundType, pk.EntityType = packet.SoundEventThrow, "minecraft:player" + case sound.LevelUp: + pk.SoundType, pk.ExtraData = packet.SoundEventLevelUp, 0x10000000 + case sound.Experience: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventSoundExperienceOrbPickup, + Position: vec64To32(pos), + }) + return + case sound.MusicDiscPlay: + switch so.DiscType { + case sound.Disc13(): + pk.SoundType = packet.SoundEventRecord13 + case sound.DiscCat(): + pk.SoundType = packet.SoundEventRecordCat + case sound.DiscBlocks(): + pk.SoundType = packet.SoundEventRecordBlocks + case sound.DiscChirp(): + pk.SoundType = packet.SoundEventRecordChirp + case sound.DiscFar(): + pk.SoundType = packet.SoundEventRecordFar + case sound.DiscMall(): + pk.SoundType = packet.SoundEventRecordMall + case sound.DiscMellohi(): + pk.SoundType = packet.SoundEventRecordMellohi + case sound.DiscStal(): + pk.SoundType = packet.SoundEventRecordStal + case sound.DiscStrad(): + pk.SoundType = packet.SoundEventRecordStrad + case sound.DiscWard(): + pk.SoundType = packet.SoundEventRecordWard + case sound.Disc11(): + pk.SoundType = packet.SoundEventRecord11 + case sound.DiscWait(): + pk.SoundType = packet.SoundEventRecordWait + case sound.DiscOtherside(): + pk.SoundType = packet.SoundEventRecordOtherside + case sound.DiscPigstep(): + pk.SoundType = packet.SoundEventRecordPigstep + case sound.Disc5(): + pk.SoundType = packet.SoundEventRecord5 + case sound.DiscRelic(): + pk.SoundType = packet.SoundEventRecordRelic + case sound.DiscCreator(): + pk.SoundType = packet.SoundEventRecordCreator + case sound.DiscCreatorMusicBox(): + pk.SoundType = packet.SoundEventRecordCreatorMusicBox + case sound.DiscPrecipice(): + pk.SoundType = packet.SoundEventRecordPrecipice + case sound.DiscTears(): + pk.SoundType = packet.SoundEventRecordTears + case sound.DiscLavaChicken(): + pk.SoundType = packet.SoundEventRecordLavaChicken + default: + panic(fmt.Errorf("disc (%v) does not have sound", so.DiscType.String())) + } + case sound.MusicDiscEnd: + pk.SoundType = packet.SoundEventRecordNull + case sound.FireCharge: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventSoundBlazeFireball, + Position: vec64To32(pos), + }) + return + case sound.ComposterEmpty: + pk.SoundType = packet.SoundEventComposterEmpty + case sound.ComposterFill: + pk.SoundType = packet.SoundEventComposterFill + case sound.ComposterFillLayer: + pk.SoundType = packet.SoundEventComposterFillLayer + case sound.ComposterReady: + pk.SoundType = packet.SoundEventComposterReady + case sound.PowerOn: + pk.SoundType = packet.SoundEventPowerOn + case sound.PowerOff: + pk.SoundType = packet.SoundEventPowerOff + case sound.LecternBookPlace: + pk.SoundType = packet.SoundEventLecternBookPlace + case sound.Totem: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventSoundTotemUsed, + Position: vec64To32(pos), + }) + case sound.DecoratedPotInserted: + s.writePacket(&packet.PlaySound{ + SoundName: "block.decorated_pot.insert", + Position: vec64To32(pos), + Volume: 1, + Pitch: 0.7 + 0.5*float32(so.Progress), + }) + return + case sound.DecoratedPotInsertFailed: + pk.SoundType = packet.SoundEventDecoratedPotInsertFail + case sound.LightningExplode: + s.writePacket(&packet.PlaySound{ + SoundName: "ambient.weather.lightning.impact", + Position: vec64To32(pos), + Volume: 1, + Pitch: 0.7, + }) + case sound.LightningThunder: + s.writePacket(&packet.PlaySound{ + SoundName: "ambient.weather.thunder", + Position: vec64To32(pos), + Volume: 1, + Pitch: 1.0, + }) + } + s.writePacket(pk) +} + +// PlaySound plays a world.Sound to the client. The volume is not dependent on the distance to the source if it is a +// sound of the LevelSoundEvent packet. +func (s *Session) PlaySound(t world.Sound, pos mgl64.Vec3) { + if s == Nop { + return + } + s.playSound(pos, t, true) +} + +// ViewSound ... +func (s *Session) ViewSound(pos mgl64.Vec3, soundType world.Sound) { + s.playSound(pos, soundType, false) +} + +// OpenSign ... +func (s *Session) OpenSign(pos cube.Pos, frontSide bool) { + blockPos := protocol.BlockPos{int32(pos[0]), int32(pos[1]), int32(pos[2])} + s.writePacket(&packet.OpenSign{ + Position: blockPos, + FrontSide: frontSide, + }) +} + +// ViewFurnaceUpdate updates a furnace for the associated session based on previous times. +func (s *Session) ViewFurnaceUpdate(prevCookTime, cookTime, prevRemainingFuelTime, remainingFuelTime, prevMaxFuelTime, maxFuelTime time.Duration) { + if prevCookTime != cookTime { + s.writePacket(&packet.ContainerSetData{ + WindowID: byte(s.openedWindowID.Load()), + Key: packet.ContainerDataFurnaceTickCount, + Value: int32(cookTime.Milliseconds() / 50), + }) + } + + if prevRemainingFuelTime != remainingFuelTime { + s.writePacket(&packet.ContainerSetData{ + WindowID: byte(s.openedWindowID.Load()), + Key: packet.ContainerDataFurnaceLitTime, + Value: int32(remainingFuelTime.Milliseconds() / 50), + }) + } + + if prevMaxFuelTime != maxFuelTime { + s.writePacket(&packet.ContainerSetData{ + WindowID: byte(s.openedWindowID.Load()), + Key: packet.ContainerDataFurnaceLitDuration, + Value: int32(maxFuelTime.Milliseconds() / 50), + }) + } +} + +// ViewBrewingUpdate updates a brewing stand for the associated session based on previous times. +func (s *Session) ViewBrewingUpdate(prevBrewTime, brewTime time.Duration, prevFuelAmount, fuelAmount, prevFuelTotal, fuelTotal int32) { + if prevBrewTime != brewTime { + s.writePacket(&packet.ContainerSetData{ + WindowID: byte(s.openedWindowID.Load()), + Key: packet.ContainerDataBrewingStandBrewTime, + Value: int32(brewTime.Milliseconds() / 50), + }) + } + + if prevFuelAmount != fuelAmount { + s.writePacket(&packet.ContainerSetData{ + WindowID: byte(s.openedWindowID.Load()), + Key: packet.ContainerDataBrewingStandFuelAmount, + Value: fuelAmount, + }) + } + + if prevFuelTotal != fuelTotal { + s.writePacket(&packet.ContainerSetData{ + WindowID: byte(s.openedWindowID.Load()), + Key: packet.ContainerDataBrewingStandFuelTotal, + Value: fuelTotal, + }) + } +} + +// ViewBlockUpdate ... +func (s *Session) ViewBlockUpdate(pos cube.Pos, b world.Block, layer int) { + blockPos := protocol.BlockPos{int32(pos[0]), int32(pos[1]), int32(pos[2])} + s.writePacket(&packet.UpdateBlock{ + Position: blockPos, + NewBlockRuntimeID: s.br.BlockRuntimeID(b), + Flags: packet.BlockUpdateNetwork, + Layer: uint32(layer), + }) + if v, ok := b.(world.NBTer); ok { + if nbtData := v.EncodeNBT(); nbtData != nil { + nbtData["x"], nbtData["y"], nbtData["z"] = int32(pos.X()), int32(pos.Y()), int32(pos.Z()) + s.writePacket(&packet.BlockActorData{ + Position: blockPos, + NBTData: nbtData, + }) + } + } +} + +// ViewEntityAction ... +func (s *Session) ViewEntityAction(e world.Entity, a world.EntityAction) { + switch act := a.(type) { + case entity.SwingArmAction: + if _, ok := e.(Controllable); ok { + if s.entityRuntimeID(e) == selfEntityRuntimeID && s.swingingArm.Load() { + return + } + s.writePacket(&packet.Animate{ + ActionType: packet.AnimateActionSwingArm, + EntityRuntimeID: s.entityRuntimeID(e), + }) + return + } + s.writePacket(&packet.ActorEvent{ + EntityRuntimeID: s.entityRuntimeID(e), + EventType: packet.ActorEventStartAttacking, + }) + case entity.HurtAction: + s.writePacket(&packet.ActorEvent{ + EntityRuntimeID: s.entityRuntimeID(e), + EventType: packet.ActorEventHurt, + }) + case entity.CriticalHitAction: + if act.Count <= 0 { + act.Count = 55 + } + s.writePacket(&packet.Animate{ + ActionType: packet.AnimateActionCriticalHit, + EntityRuntimeID: s.entityRuntimeID(e), + Data: float32(act.Count), + }) + case entity.EnchantedHitAction: + if act.Count <= 0 { + act.Count = 15 + } + s.writePacket(&packet.Animate{ + ActionType: packet.AnimateActionMagicCriticalHit, + EntityRuntimeID: s.entityRuntimeID(e), + Data: float32(act.Count), + }) + case entity.DeathAction: + s.writePacket(&packet.ActorEvent{ + EntityRuntimeID: s.entityRuntimeID(e), + EventType: packet.ActorEventDeath, + }) + case entity.PickedUpAction: + s.writePacket(&packet.TakeItemActor{ + ItemEntityRuntimeID: s.entityRuntimeID(e), + TakerEntityRuntimeID: s.entityRuntimeID(act.Collector), + }) + case entity.ArrowShakeAction: + s.writePacket(&packet.ActorEvent{ + EntityRuntimeID: s.entityRuntimeID(e), + EventType: packet.ActorEventShake, + EventData: int32(act.Duration.Milliseconds() / 50), + }) + case entity.FireworkExplosionAction: + s.writePacket(&packet.ActorEvent{ + EntityRuntimeID: s.entityRuntimeID(e), + EventType: packet.ActorEventFireworksExplode, + }) + case entity.EatAction: + if user, ok := e.(item.User); ok { + held, _ := user.HeldItems() + it := held.Item() + if held.Empty() { + // This can happen sometimes if the user switches between items very quickly, so just ignore the action. + return + } + if _, ok := it.(item.Consumable); !ok { + // Not consumable, refer to the comment above. + return + } + rid, meta, _ := world.ItemRuntimeID(it) + s.writePacket(&packet.ActorEvent{ + EntityRuntimeID: s.entityRuntimeID(e), + EventType: packet.ActorEventFeed, + // It's a little weird how the runtime ID is still shifted 16 bits to the left here, given the + // runtime ID already includes the meta, but it seems to work. + EventData: (rid << 16) | int32(meta), + }) + } + case entity.TotemUseAction: + s.writePacket(&packet.ActorEvent{ + EntityRuntimeID: s.entityRuntimeID(e), + EventType: packet.ActorEventTalismanActivate, + }) + } +} + +// ViewEntityState ... +func (s *Session) ViewEntityState(e world.Entity) { + s.writePacket(&packet.SetActorData{ + EntityRuntimeID: s.entityRuntimeID(e), + EntityMetadata: s.entityMetadata(e), + }) +} + +// entityMetadata returns the metadata of an entity as viewed by the session, including any overrides +// applied through its ViewLayer. +func (s *Session) entityMetadata(e world.Entity) protocol.EntityMetadata { + metadata := s.parseEntityMetadata(e) + if s.viewLayer == nil { + return metadata + } + if nt, ok := s.viewLayer.NameTag(e); ok { + metadata[protocol.EntityDataKeyName] = nt + if nt != "" { + metadata[protocol.EntityDataKeyAlwaysShowNameTag] = uint8(1) + if !metadata.Flag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagAlwaysShowName) { + metadata.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagAlwaysShowName) + } + if !metadata.Flag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagShowName) { + metadata.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagShowName) + } + } else { + metadata[protocol.EntityDataKeyAlwaysShowNameTag] = uint8(0) + if metadata.Flag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagAlwaysShowName) { + metadata.UnsetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagAlwaysShowName) + } + if metadata.Flag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagShowName) { + metadata.UnsetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagShowName) + } + } + } + if st, ok := s.viewLayer.ScoreTag(e); ok { + metadata[protocol.EntityDataKeyScore] = st + } + if visibility := s.viewLayer.Visibility(e); visibility.EnforceVisibility() { + invisibleFlag := metadata.Flag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagInvisible) + shouldForceVisible := visibility == world.EnforceVisible() && invisibleFlag + shouldForceInvisible := visibility == world.EnforceInvisible() && !invisibleFlag + if shouldForceVisible { + metadata.UnsetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagInvisible) + } else if shouldForceInvisible { + metadata.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagInvisible) + } + } + return metadata +} + +// ViewEntityAnimation ... +func (s *Session) ViewEntityAnimation(e world.Entity, a world.EntityAnimation) { + s.writePacket(&packet.AnimateEntity{ + Animation: a.Name(), + NextState: a.NextState(), + StopCondition: a.StopCondition(), + Controller: a.Controller(), + EntityRuntimeIDs: []uint64{ + s.entityRuntimeID(e), + }, + }) +} + +// OpenBlockContainer ... +func (s *Session) OpenBlockContainer(pos cube.Pos, tx *world.Tx) { + if s.containerOpened.Load() && *s.openedPos.Load() == pos { + return + } + s.closeCurrentContainer(tx, false) + + b := tx.Block(pos) + if container, ok := b.(block.Container); ok { + s.openNormalContainer(container, pos, tx) + return + } + // We hit a special kind of window like beacons, which are not actually opened server-side. + nextID := s.nextWindowID() + // The client does not send a ContainerClose packet for lecterns. + _, lectern := b.(block.Lectern) + if !lectern { + s.containerOpened.Store(true) + inv := inventory.New(1, nil) + s.openedWindow.Store(inv) + s.openedPos.Store(&pos) + } + + var containerType byte + switch b := b.(type) { + case block.CraftingTable: + containerType = protocol.ContainerTypeWorkbench + case block.EnchantingTable: + containerType = protocol.ContainerTypeEnchantment + case block.Anvil: + containerType = protocol.ContainerTypeAnvil + case block.Beacon: + containerType = protocol.ContainerTypeBeacon + case block.Lectern: + containerType = protocol.ContainerTypeLectern + case block.Loom: + containerType = protocol.ContainerTypeLoom + case block.Grindstone: + containerType = protocol.ContainerTypeGrindstone + case block.Stonecutter: + containerType = protocol.ContainerTypeStonecutter + case block.SmithingTable: + containerType = protocol.ContainerTypeSmithingTable + case block.EnderChest: + b.AddViewer(tx, pos) + + s.openedWindow.Store(s.enderChest) + defer s.sendInv(s.enderChest, uint32(nextID)) + } + + s.openedContainerID.Store(uint32(containerType)) + s.writePacket(&packet.ContainerOpen{ + WindowID: nextID, + ContainerType: containerType, + ContainerPosition: protocol.BlockPos{int32(pos[0]), int32(pos[1]), int32(pos[2])}, + ContainerEntityUniqueID: -1, + }) +} + +// openNormalContainer opens a normal container that can hold items in it server-side. +func (s *Session) openNormalContainer(b block.Container, pos cube.Pos, tx *world.Tx) { + b.AddViewer(s, tx, pos) // Paired chests might update the block here. + b = tx.Block(pos).(block.Container) + + nextID := s.nextWindowID() + s.containerOpened.Store(true) + inv := b.Inventory(tx, pos) + s.openedWindow.Store(inv) + s.openedPos.Store(&pos) + + var containerType byte + switch b.(type) { + case block.Furnace: + containerType = protocol.ContainerTypeFurnace + case block.BrewingStand: + containerType = protocol.ContainerTypeBrewingStand + case block.BlastFurnace: + containerType = protocol.ContainerTypeBlastFurnace + case block.Smoker: + containerType = protocol.ContainerTypeSmoker + case block.Hopper: + containerType = protocol.ContainerTypeHopper + } + + s.openedContainerID.Store(uint32(containerType)) + s.writePacket(&packet.ContainerOpen{ + WindowID: nextID, + ContainerType: containerType, + ContainerPosition: protocol.BlockPos{int32(pos[0]), int32(pos[1]), int32(pos[2])}, + ContainerEntityUniqueID: -1, + }) + s.sendInv(b.Inventory(tx, pos), uint32(nextID)) +} + +// ViewSlotChange ... +func (s *Session) ViewSlotChange(slot int, newItem item.Stack) { + if !s.containerOpened.Load() { + return + } + if s.inTransaction.Load() { + // Don't send slot changes to the player itself. + return + } + s.writePacket(&packet.InventorySlot{ + WindowID: s.openedWindowID.Load(), + Slot: uint32(slot), + NewItem: instanceFromItem(s.br, newItem), + }) +} + +// ViewBlockAction ... +func (s *Session) ViewBlockAction(pos cube.Pos, a world.BlockAction) { + blockPos := protocol.BlockPos{int32(pos[0]), int32(pos[1]), int32(pos[2])} + switch t := a.(type) { + case block.OpenAction: + s.writePacket(&packet.BlockEvent{ + Position: blockPos, + EventType: packet.BlockEventChangeChestState, + EventData: 1, + }) + case block.CloseAction: + s.writePacket(&packet.BlockEvent{ + Position: blockPos, + EventType: packet.BlockEventChangeChestState, + }) + case block.StartCrackAction: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventStartBlockCracking, + Position: vec64To32(pos.Vec3()), + EventData: int32(65535 / (t.BreakTime.Seconds() * 20)), + }) + case block.StopCrackAction: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventStopBlockCracking, + Position: vec64To32(pos.Vec3()), + EventData: 0, + }) + case block.ContinueCrackAction: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventUpdateBlockCracking, + Position: vec64To32(pos.Vec3()), + EventData: int32(65535 / (t.BreakTime.Seconds() * 20)), + }) + case block.DecoratedPotWobbleAction: + nbt := t.DecoratedPot.EncodeNBT() + nbt["x"], nbt["y"], nbt["z"] = blockPos.X(), blockPos.Y(), blockPos.Z() + nbt["animation"] = boolByte(t.Success) + 1 + s.writePacket(&packet.BlockActorData{ + Position: blockPos, + NBTData: nbt, + }) + } +} + +// ViewEmote ... +func (s *Session) ViewEmote(player world.Entity, emote uuid.UUID) { + flags := byte(packet.EmoteFlagServerSide) + if s.emoteChatMuted { + flags |= packet.EmoteFlagMuteChat + } + s.writePacket(&packet.Emote{ + EntityRuntimeID: s.entityRuntimeID(player), + EmoteID: emote.String(), + Flags: flags, + }) +} + +// ViewSkin ... +func (s *Session) ViewSkin(e world.Entity) { + if v, ok := e.(Controllable); ok { + s.writePacket(&packet.PlayerSkin{ + UUID: v.UUID(), + Skin: skinToProtocol(v.Skin()), + }) + } +} + +// ViewWorldSpawn ... +func (s *Session) ViewWorldSpawn(pos cube.Pos) { + blockPos := protocol.BlockPos{int32(pos[0]), int32(pos[1]), int32(pos[2])} + s.writePacket(&packet.SetSpawnPosition{ + SpawnType: packet.SpawnTypeWorld, + Position: blockPos, + Dimension: packet.DimensionOverworld, + SpawnPosition: blockPos, + }) +} + +// ViewWeather ... +func (s *Session) ViewWeather(raining, thunder bool) { + pk := &packet.LevelEvent{ + EventType: packet.LevelEventStopRaining, + } + if raining { + pk.EventType, pk.EventData = packet.LevelEventStartRaining, int32(rand.IntN(50000)+10000) + } + s.writePacket(pk) + + pk = &packet.LevelEvent{ + EventType: packet.LevelEventStopThunderstorm, + } + if thunder { + pk.EventType, pk.EventData = packet.LevelEventStartThunderstorm, int32(rand.IntN(50000)+10000) + } + s.writePacket(pk) +} + +// ViewEntityWake ... +func (s *Session) ViewEntityWake(e world.Entity) { + s.writePacket(&packet.Animate{ + EntityRuntimeID: s.entityRuntimeID(e), + ActionType: packet.AnimateActionStopSleep, + }) +} + +// nextWindowID produces the next window ID for a new window. It is an int of 1-99. +func (s *Session) nextWindowID() byte { + if s.openedWindowID.CompareAndSwap(99, 1) { + return 1 + } + return byte(s.openedWindowID.Add(1)) +} + +// closeWindow closes the container window currently opened. If no window is open, closeWindow will do +// nothing. +func (s *Session) closeWindow(clientRequested bool) bool { + if !s.containerOpened.CompareAndSwap(true, false) { + return false + } + containerType := byte(s.openedContainerID.Load()) + windowID := byte(s.openedWindowID.Load()) + + s.openedContainerID.Store(0) + s.openedWindow.Store(inventory.New(1, nil)) + if !clientRequested { + s.writePacket(&packet.ContainerClose{ + WindowID: windowID, + ContainerType: containerType, + ServerSide: true, + }) + } + return true +} + +// entityRuntimeID returns the runtime ID of the entity passed. +// noinspection GoCommentLeadingSpace +func (s *Session) entityRuntimeID(e world.Entity) uint64 { + return s.handleRuntimeID(e.H()) +} + +func (s *Session) handleRuntimeID(e *world.EntityHandle) uint64 { + s.entityMutex.RLock() + defer s.entityMutex.RUnlock() + + if id, ok := s.entityRuntimeIDs[e]; ok { + return id + } + s.conf.Log.Debug("entity runtime ID not found", "UUID", e.UUID().String()) + return 0 +} + +// entityFromRuntimeID attempts to return an entity by its runtime ID. False is returned if no entity with the +// ID could be found. +func (s *Session) entityFromRuntimeID(id uint64) (*world.EntityHandle, bool) { + s.entityMutex.RLock() + e, ok := s.entities[id] + s.entityMutex.RUnlock() + return e, ok +} + +// vec32To64 converts a mgl32.Vec3 to a mgl64.Vec3. +func vec32To64(vec3 mgl32.Vec3) mgl64.Vec3 { + return mgl64.Vec3{float64(vec3[0]), float64(vec3[1]), float64(vec3[2])} +} + +// vec64To32 converts a mgl64.Vec3 to a mgl32.Vec3. +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())} +} + +// boolByte returns 1 if the bool passed is true, or 0 if it is false. +func boolByte(b bool) uint8 { + if b { + return 1 + } + return 0 +} + +// abs ... +func abs(a int) int { + if a < 0 { + return -a + } + return a +} diff --git a/server/status.go b/server/status.go new file mode 100644 index 0000000..df81d7d --- /dev/null +++ b/server/status.go @@ -0,0 +1,22 @@ +package server + +import ( + "github.com/sandertv/gophertunnel/minecraft" +) + +// statusProvider handles the way the server shows up in the server list. The +// online players and maximum players are not changeable from outside the +// server, but the server name may be changed at any time. +type statusProvider struct { + name string +} + +// ServerStatus returns the player count, max players and the server's name as +// a minecraft.ServerStatus. +func (s statusProvider) ServerStatus(playerCount, maxPlayers int) minecraft.ServerStatus { + return minecraft.ServerStatus{ + ServerName: s.name, + PlayerCount: playerCount, + MaxPlayers: maxPlayers, + } +} diff --git a/server/world/biome.go b/server/world/biome.go new file mode 100644 index 0000000..a292e6a --- /dev/null +++ b/server/world/biome.go @@ -0,0 +1,67 @@ +package world + +import "image/color" + +// Biome is a region in a world with distinct geographical features, flora, temperatures, humidity ratings, +// and sky, water, grass and foliage colours. +type Biome interface { + // Temperature returns the temperature of the biome. + Temperature() float64 + // Rainfall returns the rainfall of the biome. + Rainfall() float64 + // Depth returns the depth of the biome. + Depth() float64 + // Scale returns the scale of the biome. + Scale() float64 + // WaterColour returns the water colour of the biome. + WaterColour() color.RGBA + // Tags returns the tags for the biome. + Tags() []string + // String returns the biome name as a string. + String() string + // EncodeBiome encodes the biome into an int value that is used to identify the biome over the network. + EncodeBiome() int +} + +// biomes holds a map of id => Biome to be used for looking up the biome by an ID. It is registered +// to when calling RegisterBiome. +var biomes = map[int]Biome{} + +var biomeByName = map[string]Biome{} + +// RegisterBiome registers a biome to the map so that it can be saved and loaded with the world. +func RegisterBiome(b Biome) { + id := b.EncodeBiome() + if _, ok := biomes[id]; ok { + panic("cannot register the same biome (" + b.String() + ") twice") + } + biomes[id] = b + biomeByName[b.String()] = b +} + +// BiomeByID looks up a biome by the ID and returns it if found. +func BiomeByID(id int) (Biome, bool) { + e, ok := biomes[id] + return e, ok +} + +// BiomeByName looks up a biome by the name and returns it if found. +func BiomeByName(name string) (Biome, bool) { + e, ok := biomeByName[name] + return e, ok +} + +// Biomes returns a slice of all registered biomes. +func Biomes() []Biome { + bs := make([]Biome, 0, len(biomes)) + for _, b := range biomes { + bs = append(bs, b) + } + return bs +} + +// ocean returns an ocean biome. +func ocean() Biome { + o, _ := BiomeByID(0) + return o +} diff --git a/server/world/biome/badlands.go b/server/world/biome/badlands.go new file mode 100644 index 0000000..edbff7d --- /dev/null +++ b/server/world/biome/badlands.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// Badlands ... +type Badlands struct{} + +// Temperature ... +func (Badlands) Temperature() float64 { + return 2 +} + +// Rainfall ... +func (Badlands) Rainfall() float64 { + return 0 +} + +// Depth ... +func (Badlands) Depth() float64 { + return 0.1 +} + +// Scale ... +func (Badlands) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (Badlands) WaterColour() color.RGBA { + return color.RGBA{R: 0x4e, G: 0x7f, B: 0x81, A: 0xa5} +} + +// Tags ... +func (Badlands) Tags() []string { + return []string{"animal", "mesa", "monster", "overworld", "spawns_mesa_mobs", "spawns_warm_variant_farm_animals", "surface_mineshaft"} +} + +// String ... +func (Badlands) String() string { + return "mesa" +} + +// EncodeBiome ... +func (Badlands) EncodeBiome() int { + return 37 +} diff --git a/server/world/biome/badlands_plateau.go b/server/world/biome/badlands_plateau.go new file mode 100644 index 0000000..f412c03 --- /dev/null +++ b/server/world/biome/badlands_plateau.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// BadlandsPlateau ... +type BadlandsPlateau struct{} + +// Temperature ... +func (BadlandsPlateau) Temperature() float64 { + return 2 +} + +// Rainfall ... +func (BadlandsPlateau) Rainfall() float64 { + return 0 +} + +// Depth ... +func (BadlandsPlateau) Depth() float64 { + return 1.5 +} + +// Scale ... +func (BadlandsPlateau) Scale() float64 { + return 0.025 +} + +// WaterColour ... +func (BadlandsPlateau) WaterColour() color.RGBA { + return color.RGBA{R: 0x55, G: 0x80, B: 0x9e, A: 0xa5} +} + +// Tags ... +func (BadlandsPlateau) Tags() []string { + return []string{"animal", "mesa", "monster", "overworld", "plateau", "rare", "spawns_mesa_mobs", "spawns_warm_variant_farm_animals", "surface_mineshaft"} +} + +// String ... +func (BadlandsPlateau) String() string { + return "mesa_plateau" +} + +// EncodeBiome ... +func (BadlandsPlateau) EncodeBiome() int { + return 39 +} diff --git a/server/world/biome/bamboo_jungle.go b/server/world/biome/bamboo_jungle.go new file mode 100644 index 0000000..6f9944d --- /dev/null +++ b/server/world/biome/bamboo_jungle.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// BambooJungle ... +type BambooJungle struct{} + +// Temperature ... +func (BambooJungle) Temperature() float64 { + return 0.95 +} + +// Rainfall ... +func (BambooJungle) Rainfall() float64 { + return 0.9 +} + +// Depth ... +func (BambooJungle) Depth() float64 { + return 0.1 +} + +// Scale ... +func (BambooJungle) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (BambooJungle) WaterColour() color.RGBA { + return color.RGBA{R: 0x14, G: 0xa2, B: 0xc5, A: 0xa5} +} + +// Tags ... +func (BambooJungle) Tags() []string { + return []string{"animal", "bamboo", "jungle", "monster", "overworld", "spawns_jungle_mobs", "spawns_warm_variant_farm_animals"} +} + +// String ... +func (BambooJungle) String() string { + return "bamboo_jungle" +} + +// EncodeBiome ... +func (BambooJungle) EncodeBiome() int { + return 48 +} diff --git a/server/world/biome/bamboo_jungle_hills.go b/server/world/biome/bamboo_jungle_hills.go new file mode 100644 index 0000000..ed60407 --- /dev/null +++ b/server/world/biome/bamboo_jungle_hills.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// BambooJungleHills ... +type BambooJungleHills struct{} + +// Temperature ... +func (BambooJungleHills) Temperature() float64 { + return 0.95 +} + +// Rainfall ... +func (BambooJungleHills) Rainfall() float64 { + return 0.9 +} + +// Depth ... +func (BambooJungleHills) Depth() float64 { + return 0.45 +} + +// Scale ... +func (BambooJungleHills) Scale() float64 { + return 0.3 +} + +// WaterColour ... +func (BambooJungleHills) WaterColour() color.RGBA { + return color.RGBA{R: 0x1b, G: 0x9e, B: 0xd8, A: 0xa5} +} + +// Tags ... +func (BambooJungleHills) Tags() []string { + return []string{"animal", "bamboo", "hills", "jungle", "monster", "overworld", "spawns_jungle_mobs", "spawns_warm_variant_farm_animals"} +} + +// String ... +func (BambooJungleHills) String() string { + return "bamboo_jungle_hills" +} + +// EncodeBiome ... +func (BambooJungleHills) EncodeBiome() int { + return 49 +} diff --git a/server/world/biome/basalt_deltas.go b/server/world/biome/basalt_deltas.go new file mode 100644 index 0000000..b28016b --- /dev/null +++ b/server/world/biome/basalt_deltas.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// BasaltDeltas ... +type BasaltDeltas struct{} + +// Temperature ... +func (BasaltDeltas) Temperature() float64 { + return 2 +} + +// Rainfall ... +func (BasaltDeltas) Rainfall() float64 { + return 0 +} + +// Depth ... +func (BasaltDeltas) Depth() float64 { + return 0.1 +} + +// Scale ... +func (BasaltDeltas) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (BasaltDeltas) WaterColour() color.RGBA { + return color.RGBA{R: 0x3f, G: 0x76, B: 0xe4, A: 0xa5} +} + +// Tags ... +func (BasaltDeltas) Tags() []string { + return []string{"nether", "basalt_deltas", "spawn_many_magma_cubes", "spawn_ghast", "spawns_warm_variant_farm_animals"} +} + +// String ... +func (BasaltDeltas) String() string { + return "basalt_deltas" +} + +// EncodeBiome ... +func (BasaltDeltas) EncodeBiome() int { + return 181 +} diff --git a/server/world/biome/beach.go b/server/world/biome/beach.go new file mode 100644 index 0000000..be022a1 --- /dev/null +++ b/server/world/biome/beach.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// Beach ... +type Beach struct{} + +// Temperature ... +func (Beach) Temperature() float64 { + return 0.8 +} + +// Rainfall ... +func (Beach) Rainfall() float64 { + return 0.4 +} + +// Depth ... +func (Beach) Depth() float64 { + return 0 +} + +// Scale ... +func (Beach) Scale() float64 { + return 0.025 +} + +// WaterColour ... +func (Beach) WaterColour() color.RGBA { + return color.RGBA{R: 0x15, G: 0x7c, B: 0xab, A: 0xa5} +} + +// Tags ... +func (Beach) Tags() []string { + return []string{"beach", "monster", "overworld", "warm"} +} + +// String ... +func (Beach) String() string { + return "beach" +} + +// EncodeBiome ... +func (Beach) EncodeBiome() int { + return 16 +} diff --git a/server/world/biome/birch_forest.go b/server/world/biome/birch_forest.go new file mode 100644 index 0000000..16a792e --- /dev/null +++ b/server/world/biome/birch_forest.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// BirchForest ... +type BirchForest struct{} + +// Temperature ... +func (BirchForest) Temperature() float64 { + return 0.6 +} + +// Rainfall ... +func (BirchForest) Rainfall() float64 { + return 0.6 +} + +// Depth ... +func (BirchForest) Depth() float64 { + return 0.1 +} + +// Scale ... +func (BirchForest) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (BirchForest) WaterColour() color.RGBA { + return color.RGBA{R: 0x06, G: 0x77, B: 0xce, A: 0xa5} +} + +// Tags ... +func (BirchForest) Tags() []string { + return []string{"animal", "birch", "forest", "monster", "overworld", "bee_habitat"} +} + +// String ... +func (BirchForest) String() string { + return "birch_forest" +} + +// EncodeBiome ... +func (BirchForest) EncodeBiome() int { + return 27 +} diff --git a/server/world/biome/birch_forest_hills.go b/server/world/biome/birch_forest_hills.go new file mode 100644 index 0000000..b231d23 --- /dev/null +++ b/server/world/biome/birch_forest_hills.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// BirchForestHills ... +type BirchForestHills struct{} + +// Temperature ... +func (BirchForestHills) Temperature() float64 { + return 0.6 +} + +// Rainfall ... +func (BirchForestHills) Rainfall() float64 { + return 0.6 +} + +// Depth ... +func (BirchForestHills) Depth() float64 { + return 0.45 +} + +// Scale ... +func (BirchForestHills) Scale() float64 { + return 0.3 +} + +// WaterColour ... +func (BirchForestHills) WaterColour() color.RGBA { + return color.RGBA{R: 0x0a, G: 0x74, B: 0xc4, A: 0xa5} +} + +// Tags ... +func (BirchForestHills) Tags() []string { + return []string{"animal", "birch", "forest", "hills", "monster", "overworld", "bee_habitat"} +} + +// String ... +func (BirchForestHills) String() string { + return "birch_forest_hills" +} + +// EncodeBiome ... +func (BirchForestHills) EncodeBiome() int { + return 28 +} diff --git a/server/world/biome/cherry_grove.go b/server/world/biome/cherry_grove.go new file mode 100644 index 0000000..d9a1a20 --- /dev/null +++ b/server/world/biome/cherry_grove.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// CherryGrove ... +type CherryGrove struct{} + +// Temperature ... +func (CherryGrove) Temperature() float64 { + return 0.3 +} + +// Rainfall ... +func (CherryGrove) Rainfall() float64 { + return 0.8 +} + +// Depth ... +func (CherryGrove) Depth() float64 { + return 0.1 +} + +// Scale ... +func (CherryGrove) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (CherryGrove) WaterColour() color.RGBA { + return color.RGBA{R: 0x60, G: 0xb7, B: 0xff, A: 0xa6} +} + +// Tags ... +func (CherryGrove) Tags() []string { + return []string{"mountains", "monster", "overworld", "cherry_grove", "bee_habitat"} +} + +// String ... +func (CherryGrove) String() string { + return "cherry_grove" +} + +// EncodeBiome ... +func (CherryGrove) EncodeBiome() int { + return 192 +} diff --git a/server/world/biome/cold_ocean.go b/server/world/biome/cold_ocean.go new file mode 100644 index 0000000..e52b682 --- /dev/null +++ b/server/world/biome/cold_ocean.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// ColdOcean ... +type ColdOcean struct{} + +// Temperature ... +func (ColdOcean) Temperature() float64 { + return 0.5 +} + +// Rainfall ... +func (ColdOcean) Rainfall() float64 { + return 0.5 +} + +// Depth ... +func (ColdOcean) Depth() float64 { + return -1 +} + +// Scale ... +func (ColdOcean) Scale() float64 { + return 0.1 +} + +// WaterColour ... +func (ColdOcean) WaterColour() color.RGBA { + return color.RGBA{R: 0x20, G: 0x80, B: 0xc9, A: 0xa5} +} + +// Tags ... +func (ColdOcean) Tags() []string { + return []string{"cold", "monster", "ocean", "overworld", "spawns_cold_variant_farm_animals", "fast_fishing", "high_seas"} +} + +// String ... +func (ColdOcean) String() string { + return "cold_ocean" +} + +// EncodeBiome ... +func (ColdOcean) EncodeBiome() int { + return 44 +} diff --git a/server/world/biome/crimson_forest.go b/server/world/biome/crimson_forest.go new file mode 100644 index 0000000..5f7da8a --- /dev/null +++ b/server/world/biome/crimson_forest.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// CrimsonForest ... +type CrimsonForest struct{} + +// Temperature ... +func (CrimsonForest) Temperature() float64 { + return 2 +} + +// Rainfall ... +func (CrimsonForest) Rainfall() float64 { + return 0 +} + +// Depth ... +func (CrimsonForest) Depth() float64 { + return 0.1 +} + +// Scale ... +func (CrimsonForest) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (CrimsonForest) WaterColour() color.RGBA { + return color.RGBA{R: 0x90, G: 0x59, B: 0x57, A: 0xa5} +} + +// Tags ... +func (CrimsonForest) Tags() []string { + return []string{"nether", "netherwart_forest", "crimson_forest", "spawn_few_zombified_piglins", "spawn_piglin", "spawns_warm_variant_farm_animals"} +} + +// String ... +func (CrimsonForest) String() string { + return "crimson_forest" +} + +// EncodeBiome ... +func (CrimsonForest) EncodeBiome() int { + return 179 +} diff --git a/server/world/biome/dark_forest.go b/server/world/biome/dark_forest.go new file mode 100644 index 0000000..65a30c3 --- /dev/null +++ b/server/world/biome/dark_forest.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// DarkForest ... +type DarkForest struct{} + +// Temperature ... +func (DarkForest) Temperature() float64 { + return 0.7 +} + +// Rainfall ... +func (DarkForest) Rainfall() float64 { + return 0.8 +} + +// Depth ... +func (DarkForest) Depth() float64 { + return 0.1 +} + +// Scale ... +func (DarkForest) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (DarkForest) WaterColour() color.RGBA { + return color.RGBA{R: 0x3b, G: 0x6c, B: 0xd1, A: 0xa5} +} + +// Tags ... +func (DarkForest) Tags() []string { + return []string{"animal", "forest", "monster", "no_legacy_worldgen", "overworld", "roofed"} +} + +// String ... +func (DarkForest) String() string { + return "roofed_forest" +} + +// EncodeBiome ... +func (DarkForest) EncodeBiome() int { + return 29 +} diff --git a/server/world/biome/dark_forest_hills.go b/server/world/biome/dark_forest_hills.go new file mode 100644 index 0000000..8a6efc0 --- /dev/null +++ b/server/world/biome/dark_forest_hills.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// DarkForestHills ... +type DarkForestHills struct{} + +// Temperature ... +func (DarkForestHills) Temperature() float64 { + return 0.7 +} + +// Rainfall ... +func (DarkForestHills) Rainfall() float64 { + return 0.8 +} + +// Depth ... +func (DarkForestHills) Depth() float64 { + return 0.2 +} + +// Scale ... +func (DarkForestHills) Scale() float64 { + return 0.4 +} + +// WaterColour ... +func (DarkForestHills) WaterColour() color.RGBA { + return color.RGBA{R: 0x3b, G: 0x6c, B: 0xd1, A: 0xa5} +} + +// Tags ... +func (DarkForestHills) Tags() []string { + return []string{"animal", "forest", "monster", "mutated", "roofed", "overworld_generation"} +} + +// String ... +func (DarkForestHills) String() string { + return "roofed_forest_mutated" +} + +// EncodeBiome ... +func (DarkForestHills) EncodeBiome() int { + return 157 +} diff --git a/server/world/biome/deep_cold_ocean.go b/server/world/biome/deep_cold_ocean.go new file mode 100644 index 0000000..8a4893d --- /dev/null +++ b/server/world/biome/deep_cold_ocean.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// DeepColdOcean ... +type DeepColdOcean struct{} + +// Temperature ... +func (DeepColdOcean) Temperature() float64 { + return 0.5 +} + +// Rainfall ... +func (DeepColdOcean) Rainfall() float64 { + return 0.5 +} + +// Depth ... +func (DeepColdOcean) Depth() float64 { + return -1.8 +} + +// Scale ... +func (DeepColdOcean) Scale() float64 { + return 0.1 +} + +// WaterColour ... +func (DeepColdOcean) WaterColour() color.RGBA { + return color.RGBA{R: 0x20, G: 0x80, B: 0xc9, A: 0xa5} +} + +// Tags ... +func (DeepColdOcean) Tags() []string { + return []string{"cold", "deep", "monster", "ocean", "overworld", "spawns_cold_variant_farm_animals", "fast_fishing", "high_seas"} +} + +// String ... +func (DeepColdOcean) String() string { + return "deep_cold_ocean" +} + +// EncodeBiome ... +func (DeepColdOcean) EncodeBiome() int { + return 45 +} diff --git a/server/world/biome/deep_dark.go b/server/world/biome/deep_dark.go new file mode 100644 index 0000000..40728ba --- /dev/null +++ b/server/world/biome/deep_dark.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// DeepDark ... +type DeepDark struct{} + +// Temperature ... +func (DeepDark) Temperature() float64 { + return 0.8 +} + +// Rainfall ... +func (DeepDark) Rainfall() float64 { + return 0.4 +} + +// Depth ... +func (DeepDark) Depth() float64 { + return 0.1 +} + +// Scale ... +func (DeepDark) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (DeepDark) WaterColour() color.RGBA { + return color.RGBA{R: 0x60, G: 0xb7, B: 0xff, A: 0xa6} +} + +// Tags ... +func (DeepDark) Tags() []string { + return []string{"caves", "deep_dark", "overworld", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs"} +} + +// String ... +func (DeepDark) String() string { + return "deep_dark" +} + +// EncodeBiome ... +func (DeepDark) EncodeBiome() int { + return 190 +} diff --git a/server/world/biome/deep_frozen_ocean.go b/server/world/biome/deep_frozen_ocean.go new file mode 100644 index 0000000..424154a --- /dev/null +++ b/server/world/biome/deep_frozen_ocean.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// DeepFrozenOcean ... +type DeepFrozenOcean struct{} + +// Temperature ... +func (DeepFrozenOcean) Temperature() float64 { + return 0.5 +} + +// Rainfall ... +func (DeepFrozenOcean) Rainfall() float64 { + return 0.5 +} + +// Depth ... +func (DeepFrozenOcean) Depth() float64 { + return -1.8 +} + +// Scale ... +func (DeepFrozenOcean) Scale() float64 { + return 0.1 +} + +// WaterColour ... +func (DeepFrozenOcean) WaterColour() color.RGBA { + return color.RGBA{R: 0x25, G: 0x70, B: 0xb5, A: 0xa5} +} + +// Tags ... +func (DeepFrozenOcean) Tags() []string { + return []string{"deep", "frozen", "monster", "ocean", "overworld", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs", "spawns_polar_bears_on_alternate_blocks", "fast_fishing", "high_seas"} +} + +// String ... +func (DeepFrozenOcean) String() string { + return "deep_frozen_ocean" +} + +// EncodeBiome ... +func (DeepFrozenOcean) EncodeBiome() int { + return 47 +} diff --git a/server/world/biome/deep_lukewarm_ocean.go b/server/world/biome/deep_lukewarm_ocean.go new file mode 100644 index 0000000..3f18e8d --- /dev/null +++ b/server/world/biome/deep_lukewarm_ocean.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// DeepLukewarmOcean ... +type DeepLukewarmOcean struct{} + +// Temperature ... +func (DeepLukewarmOcean) Temperature() float64 { + return 0.5 +} + +// Rainfall ... +func (DeepLukewarmOcean) Rainfall() float64 { + return 0.5 +} + +// Depth ... +func (DeepLukewarmOcean) Depth() float64 { + return -1.8 +} + +// Scale ... +func (DeepLukewarmOcean) Scale() float64 { + return 0.1 +} + +// WaterColour ... +func (DeepLukewarmOcean) WaterColour() color.RGBA { + return color.RGBA{R: 0x0d, G: 0x96, B: 0xdb, A: 0xa5} +} + +// Tags ... +func (DeepLukewarmOcean) Tags() []string { + return []string{"deep", "lukewarm", "monster", "ocean", "overworld", "spawns_warm_variant_farm_animals", "fast_fishing", "high_seas"} +} + +// String ... +func (DeepLukewarmOcean) String() string { + return "deep_lukewarm_ocean" +} + +// EncodeBiome ... +func (DeepLukewarmOcean) EncodeBiome() int { + return 43 +} diff --git a/server/world/biome/deep_ocean.go b/server/world/biome/deep_ocean.go new file mode 100644 index 0000000..a5ee2ee --- /dev/null +++ b/server/world/biome/deep_ocean.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// DeepOcean ... +type DeepOcean struct{} + +// Temperature ... +func (DeepOcean) Temperature() float64 { + return 0.5 +} + +// Rainfall ... +func (DeepOcean) Rainfall() float64 { + return 0.5 +} + +// Depth ... +func (DeepOcean) Depth() float64 { + return -1.8 +} + +// Scale ... +func (DeepOcean) Scale() float64 { + return 0.1 +} + +// WaterColour ... +func (DeepOcean) WaterColour() color.RGBA { + return color.RGBA{R: 0x17, G: 0x87, B: 0xd4, A: 0xa5} +} + +// Tags ... +func (DeepOcean) Tags() []string { + return []string{"deep", "monster", "ocean", "overworld", "spawns_warm_variant_farm_animals", "fast_fishing", "high_seas"} +} + +// String ... +func (DeepOcean) String() string { + return "deep_ocean" +} + +// EncodeBiome ... +func (DeepOcean) EncodeBiome() int { + return 24 +} diff --git a/server/world/biome/deep_warm_ocean.go b/server/world/biome/deep_warm_ocean.go new file mode 100644 index 0000000..e0efa55 --- /dev/null +++ b/server/world/biome/deep_warm_ocean.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// DeepWarmOcean ... +type DeepWarmOcean struct{} + +// Temperature ... +func (DeepWarmOcean) Temperature() float64 { + return 0.5 +} + +// Rainfall ... +func (DeepWarmOcean) Rainfall() float64 { + return 0.5 +} + +// Depth ... +func (DeepWarmOcean) Depth() float64 { + return -1.8 +} + +// Scale ... +func (DeepWarmOcean) Scale() float64 { + return 0.1 +} + +// WaterColour ... +func (DeepWarmOcean) WaterColour() color.RGBA { + return color.RGBA{R: 0x02, G: 0xb0, B: 0xe5, A: 0xa5} +} + +// Tags ... +func (DeepWarmOcean) Tags() []string { + return []string{"deep", "monster", "ocean", "overworld", "warm", "spawns_warm_variant_farm_animals", "fast_fishing", "high_seas"} +} + +// String ... +func (DeepWarmOcean) String() string { + return "deep_warm_ocean" +} + +// EncodeBiome ... +func (DeepWarmOcean) EncodeBiome() int { + return 41 +} diff --git a/server/world/biome/desert.go b/server/world/biome/desert.go new file mode 100644 index 0000000..3b69145 --- /dev/null +++ b/server/world/biome/desert.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// Desert ... +type Desert struct{} + +// Temperature ... +func (Desert) Temperature() float64 { + return 2 +} + +// Rainfall ... +func (Desert) Rainfall() float64 { + return 0 +} + +// Depth ... +func (Desert) Depth() float64 { + return 0.125 +} + +// Scale ... +func (Desert) Scale() float64 { + return 0.05 +} + +// WaterColour ... +func (Desert) WaterColour() color.RGBA { + return color.RGBA{R: 0x32, G: 0xa5, B: 0x98, A: 0xa5} +} + +// Tags ... +func (Desert) Tags() []string { + return []string{"desert", "monster", "overworld", "spawns_gold_rabbits", "spawns_warm_variant_farm_animals", "spawns_warm_variant_frogs"} +} + +// String ... +func (Desert) String() string { + return "desert" +} + +// EncodeBiome ... +func (Desert) EncodeBiome() int { + return 2 +} diff --git a/server/world/biome/desert_hills.go b/server/world/biome/desert_hills.go new file mode 100644 index 0000000..2565efb --- /dev/null +++ b/server/world/biome/desert_hills.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// DesertHills ... +type DesertHills struct{} + +// Temperature ... +func (DesertHills) Temperature() float64 { + return 2 +} + +// Rainfall ... +func (DesertHills) Rainfall() float64 { + return 0 +} + +// Depth ... +func (DesertHills) Depth() float64 { + return 0.45 +} + +// Scale ... +func (DesertHills) Scale() float64 { + return 0.3 +} + +// WaterColour ... +func (DesertHills) WaterColour() color.RGBA { + return color.RGBA{R: 0x1a, G: 0x7a, B: 0xa1, A: 0xa5} +} + +// Tags ... +func (DesertHills) Tags() []string { + return []string{"desert", "hills", "monster", "overworld", "spawns_gold_rabbits", "spawns_warm_variant_farm_animals"} +} + +// String ... +func (DesertHills) String() string { + return "desert_hills" +} + +// EncodeBiome ... +func (DesertHills) EncodeBiome() int { + return 17 +} diff --git a/server/world/biome/desert_lakes.go b/server/world/biome/desert_lakes.go new file mode 100644 index 0000000..7f4070e --- /dev/null +++ b/server/world/biome/desert_lakes.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// DesertLakes ... +type DesertLakes struct{} + +// Temperature ... +func (DesertLakes) Temperature() float64 { + return 2 +} + +// Rainfall ... +func (DesertLakes) Rainfall() float64 { + return 0 +} + +// Depth ... +func (DesertLakes) Depth() float64 { + return 0.225 +} + +// Scale ... +func (DesertLakes) Scale() float64 { + return 0.25 +} + +// WaterColour ... +func (DesertLakes) WaterColour() color.RGBA { + return color.RGBA{R: 0x32, G: 0xa5, B: 0x98, A: 0xa5} +} + +// Tags ... +func (DesertLakes) Tags() []string { + return []string{"desert", "monster", "mutated", "overworld_generation", "spawns_gold_rabbits", "spawns_warm_variant_farm_animals"} +} + +// String ... +func (DesertLakes) String() string { + return "desert_mutated" +} + +// EncodeBiome ... +func (DesertLakes) EncodeBiome() int { + return 130 +} diff --git a/server/world/biome/dripstone_caves.go b/server/world/biome/dripstone_caves.go new file mode 100644 index 0000000..690b6f2 --- /dev/null +++ b/server/world/biome/dripstone_caves.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// DripstoneCaves ... +type DripstoneCaves struct{} + +// Temperature ... +func (DripstoneCaves) Temperature() float64 { + return 0.2 +} + +// Rainfall ... +func (DripstoneCaves) Rainfall() float64 { + return 0 +} + +// Depth ... +func (DripstoneCaves) Depth() float64 { + return 0.1 +} + +// Scale ... +func (DripstoneCaves) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (DripstoneCaves) WaterColour() color.RGBA { + return color.RGBA{R: 0x60, G: 0xb7, B: 0xff, A: 0xa6} +} + +// Tags ... +func (DripstoneCaves) Tags() []string { + return []string{"caves", "overworld", "dripstone_caves", "monster"} +} + +// String ... +func (DripstoneCaves) String() string { + return "dripstone_caves" +} + +// EncodeBiome ... +func (DripstoneCaves) EncodeBiome() int { + return 188 +} diff --git a/server/world/biome/end.go b/server/world/biome/end.go new file mode 100644 index 0000000..f73fa54 --- /dev/null +++ b/server/world/biome/end.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// End ... +type End struct{} + +// Temperature ... +func (End) Temperature() float64 { + return 0.5 +} + +// Rainfall ... +func (End) Rainfall() float64 { + return 0.5 +} + +// Depth ... +func (End) Depth() float64 { + return 0.1 +} + +// Scale ... +func (End) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (End) WaterColour() color.RGBA { + return color.RGBA{R: 0x62, G: 0x52, B: 0x9e, A: 0xa5} +} + +// Tags ... +func (End) Tags() []string { + return []string{"the_end", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs"} +} + +// String ... +func (End) String() string { + return "the_end" +} + +// EncodeBiome ... +func (End) EncodeBiome() int { + return 9 +} diff --git a/server/world/biome/eroded_badlands.go b/server/world/biome/eroded_badlands.go new file mode 100644 index 0000000..063e456 --- /dev/null +++ b/server/world/biome/eroded_badlands.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// ErodedBadlands ... +type ErodedBadlands struct{} + +// Temperature ... +func (ErodedBadlands) Temperature() float64 { + return 2 +} + +// Rainfall ... +func (ErodedBadlands) Rainfall() float64 { + return 0 +} + +// Depth ... +func (ErodedBadlands) Depth() float64 { + return 0.1 +} + +// Scale ... +func (ErodedBadlands) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (ErodedBadlands) WaterColour() color.RGBA { + return color.RGBA{R: 0x14, G: 0xa2, B: 0xc5, A: 0xa5} +} + +// Tags ... +func (ErodedBadlands) Tags() []string { + return []string{"animal", "mesa", "monster", "mutated", "overworld", "spawns_mesa_mobs", "spawns_warm_variant_farm_animals", "surface_mineshaft"} +} + +// String ... +func (ErodedBadlands) String() string { + return "mesa_bryce" +} + +// EncodeBiome ... +func (ErodedBadlands) EncodeBiome() int { + return 165 +} diff --git a/server/world/biome/flower_forest.go b/server/world/biome/flower_forest.go new file mode 100644 index 0000000..fd109d9 --- /dev/null +++ b/server/world/biome/flower_forest.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// FlowerForest ... +type FlowerForest struct{} + +// Temperature ... +func (FlowerForest) Temperature() float64 { + return 0.7 +} + +// Rainfall ... +func (FlowerForest) Rainfall() float64 { + return 0.8 +} + +// Depth ... +func (FlowerForest) Depth() float64 { + return 0.1 +} + +// Scale ... +func (FlowerForest) Scale() float64 { + return 0.4 +} + +// WaterColour ... +func (FlowerForest) WaterColour() color.RGBA { + return color.RGBA{R: 0x20, G: 0xa3, B: 0xcc, A: 0xa5} +} + +// Tags ... +func (FlowerForest) Tags() []string { + return []string{"animal", "flower_forest", "monster", "mutated", "overworld", "bee_habitat"} +} + +// String ... +func (FlowerForest) String() string { + return "flower_forest" +} + +// EncodeBiome ... +func (FlowerForest) EncodeBiome() int { + return 132 +} diff --git a/server/world/biome/forest.go b/server/world/biome/forest.go new file mode 100644 index 0000000..14ac869 --- /dev/null +++ b/server/world/biome/forest.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// Forest ... +type Forest struct{} + +// Temperature ... +func (Forest) Temperature() float64 { + return 0.7 +} + +// Rainfall ... +func (Forest) Rainfall() float64 { + return 0.8 +} + +// Depth ... +func (Forest) Depth() float64 { + return 0.1 +} + +// Scale ... +func (Forest) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (Forest) WaterColour() color.RGBA { + return color.RGBA{R: 0x1e, G: 0x97, B: 0xf2, A: 0xa5} +} + +// Tags ... +func (Forest) Tags() []string { + return []string{"animal", "forest", "monster", "overworld", "bee_habitat"} +} + +// String ... +func (Forest) String() string { + return "forest" +} + +// EncodeBiome ... +func (Forest) EncodeBiome() int { + return 4 +} diff --git a/server/world/biome/frozen_ocean.go b/server/world/biome/frozen_ocean.go new file mode 100644 index 0000000..e6ae7a2 --- /dev/null +++ b/server/world/biome/frozen_ocean.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// FrozenOcean ... +type FrozenOcean struct{} + +// Temperature ... +func (FrozenOcean) Temperature() float64 { + return 0 +} + +// Rainfall ... +func (FrozenOcean) Rainfall() float64 { + return 0.5 +} + +// Depth ... +func (FrozenOcean) Depth() float64 { + return -1 +} + +// Scale ... +func (FrozenOcean) Scale() float64 { + return 0.1 +} + +// WaterColour ... +func (FrozenOcean) WaterColour() color.RGBA { + return color.RGBA{R: 0x25, G: 0x70, B: 0xb5, A: 0xa5} +} + +// Tags ... +func (FrozenOcean) Tags() []string { + return []string{"frozen", "monster", "ocean", "overworld", "spawns_polar_bears_on_alternate_blocks", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs", "spawns_snow_foxes", "spawns_white_rabbits", "fast_fishing", "high_seas"} +} + +// String ... +func (FrozenOcean) String() string { + return "frozen_ocean" +} + +// EncodeBiome ... +func (FrozenOcean) EncodeBiome() int { + return 46 +} diff --git a/server/world/biome/frozen_peaks.go b/server/world/biome/frozen_peaks.go new file mode 100644 index 0000000..d5c84ec --- /dev/null +++ b/server/world/biome/frozen_peaks.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// FrozenPeaks ... +type FrozenPeaks struct{} + +// Temperature ... +func (FrozenPeaks) Temperature() float64 { + return -0.7 +} + +// Rainfall ... +func (FrozenPeaks) Rainfall() float64 { + return 0.9 +} + +// Depth ... +func (FrozenPeaks) Depth() float64 { + return 0.1 +} + +// Scale ... +func (FrozenPeaks) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (FrozenPeaks) WaterColour() color.RGBA { + return color.RGBA{R: 0x60, G: 0xb7, B: 0xff, A: 0xa6} +} + +// Tags ... +func (FrozenPeaks) Tags() []string { + return []string{"mountains", "monster", "overworld", "frozen", "frozen_peaks", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs", "spawns_snow_foxes", "spawns_white_rabbits"} +} + +// String ... +func (FrozenPeaks) String() string { + return "frozen_peaks" +} + +// EncodeBiome ... +func (FrozenPeaks) EncodeBiome() int { + return 183 +} diff --git a/server/world/biome/frozen_river.go b/server/world/biome/frozen_river.go new file mode 100644 index 0000000..d4aaffb --- /dev/null +++ b/server/world/biome/frozen_river.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// FrozenRiver ... +type FrozenRiver struct{} + +// Temperature ... +func (FrozenRiver) Temperature() float64 { + return 0 +} + +// Rainfall ... +func (FrozenRiver) Rainfall() float64 { + return 0.5 +} + +// Depth ... +func (FrozenRiver) Depth() float64 { + return -0.5 +} + +// Scale ... +func (FrozenRiver) Scale() float64 { + return 0 +} + +// WaterColour ... +func (FrozenRiver) WaterColour() color.RGBA { + return color.RGBA{R: 0x18, G: 0x53, B: 0x90, A: 0xa5} +} + +// Tags ... +func (FrozenRiver) Tags() []string { + return []string{"frozen", "overworld", "river", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs", "spawns_river_mobs", "spawns_snow_foxes", "spawns_white_rabbits"} +} + +// String ... +func (FrozenRiver) String() string { + return "frozen_river" +} + +// EncodeBiome ... +func (FrozenRiver) EncodeBiome() int { + return 11 +} diff --git a/server/world/biome/giant_spruce_taiga_hills.go b/server/world/biome/giant_spruce_taiga_hills.go new file mode 100644 index 0000000..5bb5556 --- /dev/null +++ b/server/world/biome/giant_spruce_taiga_hills.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// GiantSpruceTaigaHills ... +type GiantSpruceTaigaHills struct{} + +// Temperature ... +func (GiantSpruceTaigaHills) Temperature() float64 { + return 0.3 +} + +// Rainfall ... +func (GiantSpruceTaigaHills) Rainfall() float64 { + return 0.8 +} + +// Depth ... +func (GiantSpruceTaigaHills) Depth() float64 { + return 0.55 +} + +// Scale ... +func (GiantSpruceTaigaHills) Scale() float64 { + return 0.5 +} + +// WaterColour ... +func (GiantSpruceTaigaHills) WaterColour() color.RGBA { + return color.RGBA{R: 0x28, G: 0x63, B: 0x78, A: 0xa5} +} + +// Tags ... +func (GiantSpruceTaigaHills) Tags() []string { + return []string{"animal", "forest", "hills", "mega", "monster", "mutated", "taiga", "overworld_generation", "spawns_cold_variant_farm_animals"} +} + +// String ... +func (GiantSpruceTaigaHills) String() string { + return "redwood_taiga_hills_mutated" +} + +// EncodeBiome ... +func (GiantSpruceTaigaHills) EncodeBiome() int { + return 161 +} diff --git a/server/world/biome/giant_tree_taiga_hills.go b/server/world/biome/giant_tree_taiga_hills.go new file mode 100644 index 0000000..9e4a574 --- /dev/null +++ b/server/world/biome/giant_tree_taiga_hills.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// GiantTreeTaigaHills ... +type GiantTreeTaigaHills struct{} + +// Temperature ... +func (GiantTreeTaigaHills) Temperature() float64 { + return 0.3 +} + +// Rainfall ... +func (GiantTreeTaigaHills) Rainfall() float64 { + return 0.8 +} + +// Depth ... +func (GiantTreeTaigaHills) Depth() float64 { + return 0.45 +} + +// Scale ... +func (GiantTreeTaigaHills) Scale() float64 { + return 0.3 +} + +// WaterColour ... +func (GiantTreeTaigaHills) WaterColour() color.RGBA { + return color.RGBA{R: 0x28, G: 0x63, B: 0x78, A: 0xa5} +} + +// Tags ... +func (GiantTreeTaigaHills) Tags() []string { + return []string{"animal", "forest", "hills", "mega", "monster", "overworld", "taiga", "spawns_cold_variant_farm_animals"} +} + +// String ... +func (GiantTreeTaigaHills) String() string { + return "mega_taiga_hills" +} + +// EncodeBiome ... +func (GiantTreeTaigaHills) EncodeBiome() int { + return 33 +} diff --git a/server/world/biome/gravelly_mountains_plus.go b/server/world/biome/gravelly_mountains_plus.go new file mode 100644 index 0000000..dc554b1 --- /dev/null +++ b/server/world/biome/gravelly_mountains_plus.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// GravellyMountainsPlus ... +type GravellyMountainsPlus struct{} + +// Temperature ... +func (GravellyMountainsPlus) Temperature() float64 { + return 0.2 +} + +// Rainfall ... +func (GravellyMountainsPlus) Rainfall() float64 { + return 0.3 +} + +// Depth ... +func (GravellyMountainsPlus) Depth() float64 { + return 1 +} + +// Scale ... +func (GravellyMountainsPlus) Scale() float64 { + return 0.5 +} + +// WaterColour ... +func (GravellyMountainsPlus) WaterColour() color.RGBA { + return color.RGBA{R: 0x0e, G: 0x63, B: 0xab, A: 0xa5} +} + +// Tags ... +func (GravellyMountainsPlus) Tags() []string { + return []string{"animal", "extreme_hills", "forest", "monster", "mutated", "overworld", "spawns_cold_variant_farm_animals"} +} + +// String ... +func (GravellyMountainsPlus) String() string { + return "extreme_hills_plus_trees_mutated" +} + +// EncodeBiome ... +func (GravellyMountainsPlus) EncodeBiome() int { + return 162 +} diff --git a/server/world/biome/grove.go b/server/world/biome/grove.go new file mode 100644 index 0000000..938939b --- /dev/null +++ b/server/world/biome/grove.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// Grove ... +type Grove struct{} + +// Temperature ... +func (Grove) Temperature() float64 { + return -0.2 +} + +// Rainfall ... +func (Grove) Rainfall() float64 { + return 0.8 +} + +// Depth ... +func (Grove) Depth() float64 { + return 0.1 +} + +// Scale ... +func (Grove) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (Grove) WaterColour() color.RGBA { + return color.RGBA{R: 0x60, G: 0xb7, B: 0xff, A: 0xa6} +} + +// Tags ... +func (Grove) Tags() []string { + return []string{"mountains", "cold", "monster", "overworld", "grove", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs", "spawns_snow_foxes", "spawns_white_rabbits"} +} + +// String ... +func (Grove) String() string { + return "grove" +} + +// EncodeBiome ... +func (Grove) EncodeBiome() int { + return 185 +} diff --git a/server/world/biome/ice_spikes.go b/server/world/biome/ice_spikes.go new file mode 100644 index 0000000..b2469b9 --- /dev/null +++ b/server/world/biome/ice_spikes.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// IceSpikes ... +type IceSpikes struct{} + +// Temperature ... +func (IceSpikes) Temperature() float64 { + return 0 +} + +// Rainfall ... +func (IceSpikes) Rainfall() float64 { + return 1 +} + +// Depth ... +func (IceSpikes) Depth() float64 { + return 0.425 +} + +// Scale ... +func (IceSpikes) Scale() float64 { + return 0.45 +} + +// WaterColour ... +func (IceSpikes) WaterColour() color.RGBA { + return color.RGBA{R: 0x60, G: 0xb7, B: 0xff, A: 0xa6} +} + +// Tags ... +func (IceSpikes) Tags() []string { + return []string{"frozen", "ice_plains", "monster", "mutated", "overworld", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs", "spawns_white_rabbits"} +} + +// String ... +func (IceSpikes) String() string { + return "ice_plains_spikes" +} + +// EncodeBiome ... +func (IceSpikes) EncodeBiome() int { + return 140 +} diff --git a/server/world/biome/jagged_peaks.go b/server/world/biome/jagged_peaks.go new file mode 100644 index 0000000..5c3e771 --- /dev/null +++ b/server/world/biome/jagged_peaks.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// JaggedPeaks ... +type JaggedPeaks struct{} + +// Temperature ... +func (JaggedPeaks) Temperature() float64 { + return -0.7 +} + +// Rainfall ... +func (JaggedPeaks) Rainfall() float64 { + return 0.9 +} + +// Depth ... +func (JaggedPeaks) Depth() float64 { + return 0.1 +} + +// Scale ... +func (JaggedPeaks) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (JaggedPeaks) WaterColour() color.RGBA { + return color.RGBA{R: 0x60, G: 0xb7, B: 0xff, A: 0xa6} +} + +// Tags ... +func (JaggedPeaks) Tags() []string { + return []string{"mountains", "monster", "overworld", "frozen", "jagged_peaks", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs", "spawns_snow_foxes", "spawns_white_rabbits"} +} + +// String ... +func (JaggedPeaks) String() string { + return "jagged_peaks" +} + +// EncodeBiome ... +func (JaggedPeaks) EncodeBiome() int { + return 182 +} diff --git a/server/world/biome/jungle.go b/server/world/biome/jungle.go new file mode 100644 index 0000000..4831b07 --- /dev/null +++ b/server/world/biome/jungle.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// Jungle ... +type Jungle struct{} + +// Temperature ... +func (Jungle) Temperature() float64 { + return 0.95 +} + +// Rainfall ... +func (Jungle) Rainfall() float64 { + return 0.9 +} + +// Depth ... +func (Jungle) Depth() float64 { + return 0.1 +} + +// Scale ... +func (Jungle) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (Jungle) WaterColour() color.RGBA { + return color.RGBA{R: 0x14, G: 0xa2, B: 0xc5, A: 0xa5} +} + +// Tags ... +func (Jungle) Tags() []string { + return []string{"animal", "has_structure_trail_ruins", "jungle", "monster", "overworld", "rare", "spawns_jungle_mobs", "spawns_warm_variant_farm_animals"} +} + +// String ... +func (Jungle) String() string { + return "jungle" +} + +// EncodeBiome ... +func (Jungle) EncodeBiome() int { + return 21 +} diff --git a/server/world/biome/jungle_edge.go b/server/world/biome/jungle_edge.go new file mode 100644 index 0000000..7aa4f01 --- /dev/null +++ b/server/world/biome/jungle_edge.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// JungleEdge ... +type JungleEdge struct{} + +// Temperature ... +func (JungleEdge) Temperature() float64 { + return 0.95 +} + +// Rainfall ... +func (JungleEdge) Rainfall() float64 { + return 0.8 +} + +// Depth ... +func (JungleEdge) Depth() float64 { + return 0.1 +} + +// Scale ... +func (JungleEdge) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (JungleEdge) WaterColour() color.RGBA { + return color.RGBA{R: 0x0d, G: 0x8a, B: 0xe3, A: 0xa5} +} + +// Tags ... +func (JungleEdge) Tags() []string { + return []string{"animal", "edge", "jungle", "monster", "overworld", "spawns_jungle_mobs", "spawns_warm_variant_farm_animals"} +} + +// String ... +func (JungleEdge) String() string { + return "jungle_edge" +} + +// EncodeBiome ... +func (JungleEdge) EncodeBiome() int { + return 23 +} diff --git a/server/world/biome/jungle_hills.go b/server/world/biome/jungle_hills.go new file mode 100644 index 0000000..1d9824f --- /dev/null +++ b/server/world/biome/jungle_hills.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// JungleHills ... +type JungleHills struct{} + +// Temperature ... +func (JungleHills) Temperature() float64 { + return 0.95 +} + +// Rainfall ... +func (JungleHills) Rainfall() float64 { + return 0.9 +} + +// Depth ... +func (JungleHills) Depth() float64 { + return 0.45 +} + +// Scale ... +func (JungleHills) Scale() float64 { + return 0.3 +} + +// WaterColour ... +func (JungleHills) WaterColour() color.RGBA { + return color.RGBA{R: 0x1b, G: 0x9e, B: 0xd8, A: 0xa5} +} + +// Tags ... +func (JungleHills) Tags() []string { + return []string{"animal", "hills", "jungle", "monster", "overworld", "spawns_jungle_mobs", "spawns_warm_variant_farm_animals"} +} + +// String ... +func (JungleHills) String() string { + return "jungle_hills" +} + +// EncodeBiome ... +func (JungleHills) EncodeBiome() int { + return 22 +} diff --git a/server/world/biome/legacy_frozen_ocean.go b/server/world/biome/legacy_frozen_ocean.go new file mode 100644 index 0000000..65fd5a2 --- /dev/null +++ b/server/world/biome/legacy_frozen_ocean.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// LegacyFrozenOcean ... +type LegacyFrozenOcean struct{} + +// Temperature ... +func (LegacyFrozenOcean) Temperature() float64 { + return 0 +} + +// Rainfall ... +func (LegacyFrozenOcean) Rainfall() float64 { + return 0.5 +} + +// Depth ... +func (LegacyFrozenOcean) Depth() float64 { + return -1 +} + +// Scale ... +func (LegacyFrozenOcean) Scale() float64 { + return 0.1 +} + +// WaterColour ... +func (LegacyFrozenOcean) WaterColour() color.RGBA { + return color.RGBA{R: 0x60, G: 0xb7, B: 0xff, A: 0xa6} +} + +// Tags ... +func (LegacyFrozenOcean) Tags() []string { + return []string{"legacy", "frozen", "ocean", "overworld", "spawns_cold_variant_farm_animals", "spawns_polar_bears_on_alternate_blocks", "fast_fishing", "high_seas"} +} + +// String ... +func (LegacyFrozenOcean) String() string { + return "legacy_frozen_ocean" +} + +// EncodeBiome ... +func (LegacyFrozenOcean) EncodeBiome() int { + return 10 +} diff --git a/server/world/biome/lukewarm_ocean.go b/server/world/biome/lukewarm_ocean.go new file mode 100644 index 0000000..382a0c1 --- /dev/null +++ b/server/world/biome/lukewarm_ocean.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// LukewarmOcean ... +type LukewarmOcean struct{} + +// Temperature ... +func (LukewarmOcean) Temperature() float64 { + return 0.5 +} + +// Rainfall ... +func (LukewarmOcean) Rainfall() float64 { + return 0.5 +} + +// Depth ... +func (LukewarmOcean) Depth() float64 { + return -1 +} + +// Scale ... +func (LukewarmOcean) Scale() float64 { + return 0.1 +} + +// WaterColour ... +func (LukewarmOcean) WaterColour() color.RGBA { + return color.RGBA{R: 0x0d, G: 0x96, B: 0xdb, A: 0xa5} +} + +// Tags ... +func (LukewarmOcean) Tags() []string { + return []string{"lukewarm", "monster", "ocean", "overworld", "spawns_warm_variant_farm_animals", "fast_fishing", "high_seas"} +} + +// String ... +func (LukewarmOcean) String() string { + return "lukewarm_ocean" +} + +// EncodeBiome ... +func (LukewarmOcean) EncodeBiome() int { + return 42 +} diff --git a/server/world/biome/lush_caves.go b/server/world/biome/lush_caves.go new file mode 100644 index 0000000..67de745 --- /dev/null +++ b/server/world/biome/lush_caves.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// LushCaves ... +type LushCaves struct{} + +// Temperature ... +func (LushCaves) Temperature() float64 { + return 0.9 +} + +// Rainfall ... +func (LushCaves) Rainfall() float64 { + return 0 +} + +// Depth ... +func (LushCaves) Depth() float64 { + return 0.1 +} + +// Scale ... +func (LushCaves) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (LushCaves) WaterColour() color.RGBA { + return color.RGBA{R: 0x60, G: 0xb7, B: 0xff, A: 0xa6} +} + +// Tags ... +func (LushCaves) Tags() []string { + return []string{"caves", "lush_caves", "overworld", "monster", "spawns_tropical_fish_at_any_height"} +} + +// String ... +func (LushCaves) String() string { + return "lush_caves" +} + +// EncodeBiome ... +func (LushCaves) EncodeBiome() int { + return 187 +} diff --git a/server/world/biome/mangrove_swamp.go b/server/world/biome/mangrove_swamp.go new file mode 100644 index 0000000..f852595 --- /dev/null +++ b/server/world/biome/mangrove_swamp.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// MangroveSwamp ... +type MangroveSwamp struct{} + +// Temperature ... +func (MangroveSwamp) Temperature() float64 { + return 0.8 +} + +// Rainfall ... +func (MangroveSwamp) Rainfall() float64 { + return 0.9 +} + +// Depth ... +func (MangroveSwamp) Depth() float64 { + return -0.2 +} + +// Scale ... +func (MangroveSwamp) Scale() float64 { + return 0.1 +} + +// WaterColour ... +func (MangroveSwamp) WaterColour() color.RGBA { + return color.RGBA{R: 0x60, G: 0xb7, B: 0xff, A: 0xa6} +} + +// Tags ... +func (MangroveSwamp) Tags() []string { + return []string{"mangrove_swamp", "overworld", "monster", "spawns_slimes_on_surface", "spawns_warm_variant_farm_animals", "spawns_warm_variant_frogs", "slime"} +} + +// String ... +func (MangroveSwamp) String() string { + return "mangrove_swamp" +} + +// EncodeBiome ... +func (MangroveSwamp) EncodeBiome() int { + return 191 +} diff --git a/server/world/biome/meadow.go b/server/world/biome/meadow.go new file mode 100644 index 0000000..f17c6a4 --- /dev/null +++ b/server/world/biome/meadow.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// Meadow ... +type Meadow struct{} + +// Temperature ... +func (Meadow) Temperature() float64 { + return 0.3 +} + +// Rainfall ... +func (Meadow) Rainfall() float64 { + return 0.8 +} + +// Depth ... +func (Meadow) Depth() float64 { + return 0.1 +} + +// Scale ... +func (Meadow) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (Meadow) WaterColour() color.RGBA { + return color.RGBA{R: 0x60, G: 0xb7, B: 0xff, A: 0xa6} +} + +// Tags ... +func (Meadow) Tags() []string { + return []string{"mountains", "monster", "overworld", "meadow", "bee_habitat"} +} + +// String ... +func (Meadow) String() string { + return "meadow" +} + +// EncodeBiome ... +func (Meadow) EncodeBiome() int { + return 186 +} diff --git a/server/world/biome/modified_badlands_plateau.go b/server/world/biome/modified_badlands_plateau.go new file mode 100644 index 0000000..a426af5 --- /dev/null +++ b/server/world/biome/modified_badlands_plateau.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// ModifiedBadlandsPlateau ... +type ModifiedBadlandsPlateau struct{} + +// Temperature ... +func (ModifiedBadlandsPlateau) Temperature() float64 { + return 2 +} + +// Rainfall ... +func (ModifiedBadlandsPlateau) Rainfall() float64 { + return 0 +} + +// Depth ... +func (ModifiedBadlandsPlateau) Depth() float64 { + return 0.45 +} + +// Scale ... +func (ModifiedBadlandsPlateau) Scale() float64 { + return 0.3 +} + +// WaterColour ... +func (ModifiedBadlandsPlateau) WaterColour() color.RGBA { + return color.RGBA{R: 0x55, G: 0x80, B: 0x9e, A: 0xa5} +} + +// Tags ... +func (ModifiedBadlandsPlateau) Tags() []string { + return []string{"animal", "mesa", "monster", "mutated", "overworld", "plateau", "stone", "spawns_mesa_mobs", "spawns_warm_variant_farm_animals", "surface_mineshaft"} +} + +// String ... +func (ModifiedBadlandsPlateau) String() string { + return "mesa_plateau_mutated" +} + +// EncodeBiome ... +func (ModifiedBadlandsPlateau) EncodeBiome() int { + return 167 +} diff --git a/server/world/biome/modified_jungle.go b/server/world/biome/modified_jungle.go new file mode 100644 index 0000000..10c1339 --- /dev/null +++ b/server/world/biome/modified_jungle.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// ModifiedJungle ... +type ModifiedJungle struct{} + +// Temperature ... +func (ModifiedJungle) Temperature() float64 { + return 0.95 +} + +// Rainfall ... +func (ModifiedJungle) Rainfall() float64 { + return 0.9 +} + +// Depth ... +func (ModifiedJungle) Depth() float64 { + return 0.2 +} + +// Scale ... +func (ModifiedJungle) Scale() float64 { + return 0.4 +} + +// WaterColour ... +func (ModifiedJungle) WaterColour() color.RGBA { + return color.RGBA{R: 0x1b, G: 0x9e, B: 0xd8, A: 0xa5} +} + +// Tags ... +func (ModifiedJungle) Tags() []string { + return []string{"animal", "jungle", "monster", "mutated", "overworld_generation", "spawns_warm_variant_farm_animals"} +} + +// String ... +func (ModifiedJungle) String() string { + return "jungle_mutated" +} + +// EncodeBiome ... +func (ModifiedJungle) EncodeBiome() int { + return 149 +} diff --git a/server/world/biome/modified_jungle_edge.go b/server/world/biome/modified_jungle_edge.go new file mode 100644 index 0000000..f5963dc --- /dev/null +++ b/server/world/biome/modified_jungle_edge.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// ModifiedJungleEdge ... +type ModifiedJungleEdge struct{} + +// Temperature ... +func (ModifiedJungleEdge) Temperature() float64 { + return 0.95 +} + +// Rainfall ... +func (ModifiedJungleEdge) Rainfall() float64 { + return 0.8 +} + +// Depth ... +func (ModifiedJungleEdge) Depth() float64 { + return 0.2 +} + +// Scale ... +func (ModifiedJungleEdge) Scale() float64 { + return 0.4 +} + +// WaterColour ... +func (ModifiedJungleEdge) WaterColour() color.RGBA { + return color.RGBA{R: 0x0d, G: 0x8a, B: 0xe3, A: 0xa5} +} + +// Tags ... +func (ModifiedJungleEdge) Tags() []string { + return []string{"animal", "edge", "jungle", "monster", "mutated", "overworld_generation", "spawns_jungle_mobs", "spawns_warm_variant_farm_animals"} +} + +// String ... +func (ModifiedJungleEdge) String() string { + return "jungle_edge_mutated" +} + +// EncodeBiome ... +func (ModifiedJungleEdge) EncodeBiome() int { + return 151 +} diff --git a/server/world/biome/modified_wooded_badlands_plateau.go b/server/world/biome/modified_wooded_badlands_plateau.go new file mode 100644 index 0000000..227d70b --- /dev/null +++ b/server/world/biome/modified_wooded_badlands_plateau.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// ModifiedWoodedBadlandsPlateau ... +type ModifiedWoodedBadlandsPlateau struct{} + +// Temperature ... +func (ModifiedWoodedBadlandsPlateau) Temperature() float64 { + return 2 +} + +// Rainfall ... +func (ModifiedWoodedBadlandsPlateau) Rainfall() float64 { + return 0 +} + +// Depth ... +func (ModifiedWoodedBadlandsPlateau) Depth() float64 { + return 0.45 +} + +// Scale ... +func (ModifiedWoodedBadlandsPlateau) Scale() float64 { + return 0.3 +} + +// WaterColour ... +func (ModifiedWoodedBadlandsPlateau) WaterColour() color.RGBA { + return color.RGBA{R: 0x55, G: 0x80, B: 0x9e, A: 0xa5} +} + +// Tags ... +func (ModifiedWoodedBadlandsPlateau) Tags() []string { + return []string{"animal", "mesa", "monster", "mutated", "overworld", "plateau", "spawns_mesa_mobs", "spawns_warm_variant_farm_animals", "surface_mineshaft"} +} + +// String ... +func (ModifiedWoodedBadlandsPlateau) String() string { + return "mesa_plateau_stone_mutated" +} + +// EncodeBiome ... +func (ModifiedWoodedBadlandsPlateau) EncodeBiome() int { + return 166 +} diff --git a/server/world/biome/mountain_edge.go b/server/world/biome/mountain_edge.go new file mode 100644 index 0000000..87dbd52 --- /dev/null +++ b/server/world/biome/mountain_edge.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// MountainEdge ... +type MountainEdge struct{} + +// Temperature ... +func (MountainEdge) Temperature() float64 { + return 0.2 +} + +// Rainfall ... +func (MountainEdge) Rainfall() float64 { + return 0.3 +} + +// Depth ... +func (MountainEdge) Depth() float64 { + return 0.8 +} + +// Scale ... +func (MountainEdge) Scale() float64 { + return 0.4 +} + +// WaterColour ... +func (MountainEdge) WaterColour() color.RGBA { + return color.RGBA{R: 0x04, G: 0x5c, B: 0xd5, A: 0xa5} +} + +// Tags ... +func (MountainEdge) Tags() []string { + return []string{"animal", "edge", "extreme_hills", "monster", "mountain", "overworld", "spawns_cold_variant_farm_animals"} +} + +// String ... +func (MountainEdge) String() string { + return "extreme_hills_edge" +} + +// EncodeBiome ... +func (MountainEdge) EncodeBiome() int { + return 20 +} diff --git a/server/world/biome/mushroom_field_shore.go b/server/world/biome/mushroom_field_shore.go new file mode 100644 index 0000000..1c79ca1 --- /dev/null +++ b/server/world/biome/mushroom_field_shore.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// MushroomFieldShore ... +type MushroomFieldShore struct{} + +// Temperature ... +func (MushroomFieldShore) Temperature() float64 { + return 0.9 +} + +// Rainfall ... +func (MushroomFieldShore) Rainfall() float64 { + return 1 +} + +// Depth ... +func (MushroomFieldShore) Depth() float64 { + return 0 +} + +// Scale ... +func (MushroomFieldShore) Scale() float64 { + return 0.025 +} + +// WaterColour ... +func (MushroomFieldShore) WaterColour() color.RGBA { + return color.RGBA{R: 0x81, G: 0x81, B: 0x93, A: 0xa5} +} + +// Tags ... +func (MushroomFieldShore) Tags() []string { + return []string{"mooshroom_island", "overworld", "shore", "spawns_without_patrols"} +} + +// String ... +func (MushroomFieldShore) String() string { + return "mushroom_island_shore" +} + +// EncodeBiome ... +func (MushroomFieldShore) EncodeBiome() int { + return 15 +} diff --git a/server/world/biome/mushroom_fields.go b/server/world/biome/mushroom_fields.go new file mode 100644 index 0000000..fa7b4ee --- /dev/null +++ b/server/world/biome/mushroom_fields.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// MushroomFields ... +type MushroomFields struct{} + +// Temperature ... +func (MushroomFields) Temperature() float64 { + return 0.9 +} + +// Rainfall ... +func (MushroomFields) Rainfall() float64 { + return 1 +} + +// Depth ... +func (MushroomFields) Depth() float64 { + return 0.2 +} + +// Scale ... +func (MushroomFields) Scale() float64 { + return 0.3 +} + +// WaterColour ... +func (MushroomFields) WaterColour() color.RGBA { + return color.RGBA{R: 0x8a, G: 0x89, B: 0x97, A: 0xa5} +} + +// Tags ... +func (MushroomFields) Tags() []string { + return []string{"mooshroom_island", "overworld", "spawns_without_patrols"} +} + +// String ... +func (MushroomFields) String() string { + return "mushroom_island" +} + +// EncodeBiome ... +func (MushroomFields) EncodeBiome() int { + return 14 +} diff --git a/server/world/biome/nether_wastes.go b/server/world/biome/nether_wastes.go new file mode 100644 index 0000000..2d870b0 --- /dev/null +++ b/server/world/biome/nether_wastes.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// NetherWastes ... +type NetherWastes struct{} + +// Temperature ... +func (NetherWastes) Temperature() float64 { + return 2 +} + +// Rainfall ... +func (NetherWastes) Rainfall() float64 { + return 0 +} + +// Depth ... +func (NetherWastes) Depth() float64 { + return 0.1 +} + +// Scale ... +func (NetherWastes) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (NetherWastes) WaterColour() color.RGBA { + return color.RGBA{R: 0x90, G: 0x59, B: 0x57, A: 0xa5} +} + +// Tags ... +func (NetherWastes) Tags() []string { + return []string{"nether", "nether_wastes", "spawn_endermen", "spawn_few_piglins", "spawn_ghast", "spawn_magma_cubes", "spawns_nether_mobs", "spawn_zombified_piglin", "spawns_warm_variant_farm_animals"} +} + +// String ... +func (NetherWastes) String() string { + return "hell" +} + +// EncodeBiome ... +func (NetherWastes) EncodeBiome() int { + return 8 +} diff --git a/server/world/biome/ocean.go b/server/world/biome/ocean.go new file mode 100644 index 0000000..f8aa9b2 --- /dev/null +++ b/server/world/biome/ocean.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// Ocean ... +type Ocean struct{} + +// Temperature ... +func (Ocean) Temperature() float64 { + return 0.5 +} + +// Rainfall ... +func (Ocean) Rainfall() float64 { + return 0.5 +} + +// Depth ... +func (Ocean) Depth() float64 { + return -1 +} + +// Scale ... +func (Ocean) Scale() float64 { + return 0.1 +} + +// WaterColour ... +func (Ocean) WaterColour() color.RGBA { + return color.RGBA{R: 0x17, G: 0x87, B: 0xd4, A: 0xa5} +} + +// Tags ... +func (Ocean) Tags() []string { + return []string{"monster", "ocean", "overworld", "fast_fishing", "high_seas"} +} + +// String ... +func (Ocean) String() string { + return "ocean" +} + +// EncodeBiome ... +func (Ocean) EncodeBiome() int { + return 0 +} diff --git a/server/world/biome/old_growth_birch_forest.go b/server/world/biome/old_growth_birch_forest.go new file mode 100644 index 0000000..40314e8 --- /dev/null +++ b/server/world/biome/old_growth_birch_forest.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// OldGrowthBirchForest ... +type OldGrowthBirchForest struct{} + +// Temperature ... +func (OldGrowthBirchForest) Temperature() float64 { + return 0.6 +} + +// Rainfall ... +func (OldGrowthBirchForest) Rainfall() float64 { + return 0.6 +} + +// Depth ... +func (OldGrowthBirchForest) Depth() float64 { + return 0.2 +} + +// Scale ... +func (OldGrowthBirchForest) Scale() float64 { + return 0.4 +} + +// WaterColour ... +func (OldGrowthBirchForest) WaterColour() color.RGBA { + return color.RGBA{R: 0x06, G: 0x77, B: 0xce, A: 0xa5} +} + +// Tags ... +func (OldGrowthBirchForest) Tags() []string { + return []string{"animal", "birch", "forest", "monster", "mutated", "bee_habitat", "overworld_generation", "has_structure_trail_ruins"} +} + +// String ... +func (OldGrowthBirchForest) String() string { + return "birch_forest_mutated" +} + +// EncodeBiome ... +func (OldGrowthBirchForest) EncodeBiome() int { + return 155 +} diff --git a/server/world/biome/old_growth_pine_taiga.go b/server/world/biome/old_growth_pine_taiga.go new file mode 100644 index 0000000..c967280 --- /dev/null +++ b/server/world/biome/old_growth_pine_taiga.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// OldGrowthPineTaiga ... +type OldGrowthPineTaiga struct{} + +// Temperature ... +func (OldGrowthPineTaiga) Temperature() float64 { + return 0.3 +} + +// Rainfall ... +func (OldGrowthPineTaiga) Rainfall() float64 { + return 0.8 +} + +// Depth ... +func (OldGrowthPineTaiga) Depth() float64 { + return 0.2 +} + +// Scale ... +func (OldGrowthPineTaiga) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (OldGrowthPineTaiga) WaterColour() color.RGBA { + return color.RGBA{R: 0x2d, G: 0x6d, B: 0x77, A: 0xa5} +} + +// Tags ... +func (OldGrowthPineTaiga) Tags() []string { + return []string{"animal", "forest", "mega", "monster", "overworld", "rare", "taiga", "has_structure_trail_ruins", "spawns_cold_variant_farm_animals"} +} + +// String ... +func (OldGrowthPineTaiga) String() string { + return "mega_taiga" +} + +// EncodeBiome ... +func (OldGrowthPineTaiga) EncodeBiome() int { + return 32 +} diff --git a/server/world/biome/old_growth_spruce_taiga.go b/server/world/biome/old_growth_spruce_taiga.go new file mode 100644 index 0000000..e3f452c --- /dev/null +++ b/server/world/biome/old_growth_spruce_taiga.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// OldGrowthSpruceTaiga ... +type OldGrowthSpruceTaiga struct{} + +// Temperature ... +func (OldGrowthSpruceTaiga) Temperature() float64 { + return 0.25 +} + +// Rainfall ... +func (OldGrowthSpruceTaiga) Rainfall() float64 { + return 0.8 +} + +// Depth ... +func (OldGrowthSpruceTaiga) Depth() float64 { + return 0.2 +} + +// Scale ... +func (OldGrowthSpruceTaiga) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (OldGrowthSpruceTaiga) WaterColour() color.RGBA { + return color.RGBA{R: 0x2d, G: 0x6d, B: 0x77, A: 0xa5} +} + +// Tags ... +func (OldGrowthSpruceTaiga) Tags() []string { + return []string{"animal", "forest", "mega", "monster", "mutated", "overworld", "taiga", "has_structure_trail_ruins", "spawns_cold_variant_farm_animals"} +} + +// String ... +func (OldGrowthSpruceTaiga) String() string { + return "redwood_taiga_mutated" +} + +// EncodeBiome ... +func (OldGrowthSpruceTaiga) EncodeBiome() int { + return 160 +} diff --git a/server/world/biome/pale_garden.go b/server/world/biome/pale_garden.go new file mode 100644 index 0000000..200ff50 --- /dev/null +++ b/server/world/biome/pale_garden.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// PaleGarden ... +type PaleGarden struct{} + +// Temperature ... +func (PaleGarden) Temperature() float64 { + return 0.7 +} + +// Rainfall ... +func (PaleGarden) Rainfall() float64 { + return 0.8 +} + +// Depth ... +func (PaleGarden) Depth() float64 { + return 0.1 +} + +// Scale ... +func (PaleGarden) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (PaleGarden) WaterColour() color.RGBA { + return color.RGBA{R: 0x60, G: 0xb7, B: 0xff, A: 0xa6} +} + +// Tags ... +func (PaleGarden) Tags() []string { + return []string{"monster", "overworld", "pale_garden"} +} + +// String ... +func (PaleGarden) String() string { + return "pale_garden" +} + +// EncodeBiome ... +func (PaleGarden) EncodeBiome() int { + return 193 +} diff --git a/server/world/biome/plains.go b/server/world/biome/plains.go new file mode 100644 index 0000000..2725c5e --- /dev/null +++ b/server/world/biome/plains.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// Plains ... +type Plains struct{} + +// Temperature ... +func (Plains) Temperature() float64 { + return 0.8 +} + +// Rainfall ... +func (Plains) Rainfall() float64 { + return 0.4 +} + +// Depth ... +func (Plains) Depth() float64 { + return 0.125 +} + +// Scale ... +func (Plains) Scale() float64 { + return 0.05 +} + +// WaterColour ... +func (Plains) WaterColour() color.RGBA { + return color.RGBA{R: 0x44, G: 0xaf, B: 0xf5, A: 0xa5} +} + +// Tags ... +func (Plains) Tags() []string { + return []string{"animal", "monster", "overworld", "plains", "bee_habitat"} +} + +// String ... +func (Plains) String() string { + return "plains" +} + +// EncodeBiome ... +func (Plains) EncodeBiome() int { + return 1 +} diff --git a/server/world/biome/register.go b/server/world/biome/register.go new file mode 100644 index 0000000..d5b5aec --- /dev/null +++ b/server/world/biome/register.go @@ -0,0 +1,106 @@ +package biome + +import ( + _ "unsafe" + + "github.com/df-mc/dragonfly/server/world" +) + +// init registers all biomes that can be used in a world.World. +func init() { + world.RegisterBiome(BadlandsPlateau{}) + world.RegisterBiome(Badlands{}) + world.RegisterBiome(BambooJungleHills{}) + world.RegisterBiome(BambooJungle{}) + world.RegisterBiome(BasaltDeltas{}) + world.RegisterBiome(Beach{}) + world.RegisterBiome(BirchForestHills{}) + world.RegisterBiome(BirchForest{}) + world.RegisterBiome(CherryGrove{}) + world.RegisterBiome(ColdOcean{}) + world.RegisterBiome(CrimsonForest{}) + world.RegisterBiome(DarkForestHills{}) + world.RegisterBiome(DarkForest{}) + world.RegisterBiome(DeepColdOcean{}) + world.RegisterBiome(DeepDark{}) + world.RegisterBiome(DeepFrozenOcean{}) + world.RegisterBiome(DeepLukewarmOcean{}) + world.RegisterBiome(DeepOcean{}) + world.RegisterBiome(DeepWarmOcean{}) + world.RegisterBiome(DesertHills{}) + world.RegisterBiome(DesertLakes{}) + world.RegisterBiome(Desert{}) + world.RegisterBiome(DripstoneCaves{}) + world.RegisterBiome(End{}) + world.RegisterBiome(ErodedBadlands{}) + world.RegisterBiome(FlowerForest{}) + world.RegisterBiome(Forest{}) + world.RegisterBiome(FrozenOcean{}) + world.RegisterBiome(FrozenPeaks{}) + world.RegisterBiome(FrozenRiver{}) + world.RegisterBiome(GiantSpruceTaigaHills{}) + world.RegisterBiome(GiantTreeTaigaHills{}) + world.RegisterBiome(GravellyMountainsPlus{}) + world.RegisterBiome(Grove{}) + world.RegisterBiome(IceSpikes{}) + world.RegisterBiome(JaggedPeaks{}) + world.RegisterBiome(JungleEdge{}) + world.RegisterBiome(JungleHills{}) + world.RegisterBiome(Jungle{}) + world.RegisterBiome(LegacyFrozenOcean{}) + world.RegisterBiome(LukewarmOcean{}) + world.RegisterBiome(LushCaves{}) + world.RegisterBiome(MangroveSwamp{}) + world.RegisterBiome(Meadow{}) + world.RegisterBiome(ModifiedBadlandsPlateau{}) + world.RegisterBiome(ModifiedJungleEdge{}) + world.RegisterBiome(ModifiedJungle{}) + world.RegisterBiome(ModifiedWoodedBadlandsPlateau{}) + world.RegisterBiome(MountainEdge{}) + world.RegisterBiome(MushroomFieldShore{}) + world.RegisterBiome(MushroomFields{}) + world.RegisterBiome(NetherWastes{}) + world.RegisterBiome(Ocean{}) + world.RegisterBiome(OldGrowthBirchForest{}) + world.RegisterBiome(OldGrowthPineTaiga{}) + world.RegisterBiome(OldGrowthSpruceTaiga{}) + world.RegisterBiome(PaleGarden{}) + world.RegisterBiome(Plains{}) + world.RegisterBiome(River{}) + world.RegisterBiome(SavannaPlateau{}) + world.RegisterBiome(Savanna{}) + world.RegisterBiome(ShatteredSavannaPlateau{}) + world.RegisterBiome(SnowyBeach{}) + world.RegisterBiome(SnowyMountains{}) + world.RegisterBiome(SnowyPlains{}) + world.RegisterBiome(SnowySlopes{}) + world.RegisterBiome(SnowyTaigaHills{}) + world.RegisterBiome(SnowyTaigaMountains{}) + world.RegisterBiome(SnowyTaiga{}) + world.RegisterBiome(SoulSandValley{}) + world.RegisterBiome(StonyPeaks{}) + world.RegisterBiome(StonyShore{}) + world.RegisterBiome(SulfurCaves{}) + world.RegisterBiome(SunflowerPlains{}) + world.RegisterBiome(SwampHills{}) + world.RegisterBiome(Swamp{}) + world.RegisterBiome(TaigaHills{}) + world.RegisterBiome(TaigaMountains{}) + world.RegisterBiome(Taiga{}) + world.RegisterBiome(TallBirchHills{}) + world.RegisterBiome(WarmOcean{}) + world.RegisterBiome(WarpedForest{}) + world.RegisterBiome(WindsweptForest{}) + world.RegisterBiome(WindsweptGravellyHills{}) + world.RegisterBiome(WindsweptHills{}) + world.RegisterBiome(WindsweptSavanna{}) + world.RegisterBiome(WoodedBadlandsPlateau{}) + world.RegisterBiome(WoodedHills{}) + + world_finaliseBiomeRegistry() +} + +// noinspection ALL +// +//go:linkname world_finaliseBiomeRegistry github.com/df-mc/dragonfly/server/world.finaliseBiomeRegistry +func world_finaliseBiomeRegistry() diff --git a/server/world/biome/river.go b/server/world/biome/river.go new file mode 100644 index 0000000..4e81dd4 --- /dev/null +++ b/server/world/biome/river.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// River ... +type River struct{} + +// Temperature ... +func (River) Temperature() float64 { + return 0.5 +} + +// Rainfall ... +func (River) Rainfall() float64 { + return 0.5 +} + +// Depth ... +func (River) Depth() float64 { + return -0.5 +} + +// Scale ... +func (River) Scale() float64 { + return 0 +} + +// WaterColour ... +func (River) WaterColour() color.RGBA { + return color.RGBA{R: 0x00, G: 0x84, B: 0xff, A: 0xa5} +} + +// Tags ... +func (River) Tags() []string { + return []string{"overworld", "spawns_more_frequent_drowned", "spawns_reduced_water_ambient_mobs", "spawns_river_mobs", "river"} +} + +// String ... +func (River) String() string { + return "river" +} + +// EncodeBiome ... +func (River) EncodeBiome() int { + return 7 +} diff --git a/server/world/biome/savanna.go b/server/world/biome/savanna.go new file mode 100644 index 0000000..beca598 --- /dev/null +++ b/server/world/biome/savanna.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// Savanna ... +type Savanna struct{} + +// Temperature ... +func (Savanna) Temperature() float64 { + return 1.2 +} + +// Rainfall ... +func (Savanna) Rainfall() float64 { + return 0 +} + +// Depth ... +func (Savanna) Depth() float64 { + return 0.125 +} + +// Scale ... +func (Savanna) Scale() float64 { + return 0.05 +} + +// WaterColour ... +func (Savanna) WaterColour() color.RGBA { + return color.RGBA{R: 0x2c, G: 0x8b, B: 0x9c, A: 0xa5} +} + +// Tags ... +func (Savanna) Tags() []string { + return []string{"animal", "monster", "overworld", "savanna", "spawns_savanna_mobs", "spawns_warm_variant_farm_animals"} +} + +// String ... +func (Savanna) String() string { + return "savanna" +} + +// EncodeBiome ... +func (Savanna) EncodeBiome() int { + return 35 +} diff --git a/server/world/biome/savanna_plateau.go b/server/world/biome/savanna_plateau.go new file mode 100644 index 0000000..6050489 --- /dev/null +++ b/server/world/biome/savanna_plateau.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// SavannaPlateau ... +type SavannaPlateau struct{} + +// Temperature ... +func (SavannaPlateau) Temperature() float64 { + return 1 +} + +// Rainfall ... +func (SavannaPlateau) Rainfall() float64 { + return 0 +} + +// Depth ... +func (SavannaPlateau) Depth() float64 { + return 1.5 +} + +// Scale ... +func (SavannaPlateau) Scale() float64 { + return 0.025 +} + +// WaterColour ... +func (SavannaPlateau) WaterColour() color.RGBA { + return color.RGBA{R: 0x25, G: 0x90, B: 0xa8, A: 0xa5} +} + +// Tags ... +func (SavannaPlateau) Tags() []string { + return []string{"animal", "monster", "overworld", "plateau", "savanna", "spawns_savanna_mobs", "spawns_warm_variant_farm_animals"} +} + +// String ... +func (SavannaPlateau) String() string { + return "savanna_plateau" +} + +// EncodeBiome ... +func (SavannaPlateau) EncodeBiome() int { + return 36 +} diff --git a/server/world/biome/shattered_savanna_plateau.go b/server/world/biome/shattered_savanna_plateau.go new file mode 100644 index 0000000..c152aa5 --- /dev/null +++ b/server/world/biome/shattered_savanna_plateau.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// ShatteredSavannaPlateau ... +type ShatteredSavannaPlateau struct{} + +// Temperature ... +func (ShatteredSavannaPlateau) Temperature() float64 { + return 1 +} + +// Rainfall ... +func (ShatteredSavannaPlateau) Rainfall() float64 { + return 0.5 +} + +// Depth ... +func (ShatteredSavannaPlateau) Depth() float64 { + return 1.05 +} + +// Scale ... +func (ShatteredSavannaPlateau) Scale() float64 { + return 1.212 +} + +// WaterColour ... +func (ShatteredSavannaPlateau) WaterColour() color.RGBA { + return color.RGBA{R: 0x60, G: 0xb7, B: 0xff, A: 0xa6} +} + +// Tags ... +func (ShatteredSavannaPlateau) Tags() []string { + return []string{"animal", "monster", "mutated", "overworld", "plateau", "savanna", "spawns_savanna_mobs", "spawns_warm_variant_farm_animals"} +} + +// String ... +func (ShatteredSavannaPlateau) String() string { + return "savanna_plateau_mutated" +} + +// EncodeBiome ... +func (ShatteredSavannaPlateau) EncodeBiome() int { + return 164 +} diff --git a/server/world/biome/snowy_beach.go b/server/world/biome/snowy_beach.go new file mode 100644 index 0000000..129bc17 --- /dev/null +++ b/server/world/biome/snowy_beach.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// SnowyBeach ... +type SnowyBeach struct{} + +// Temperature ... +func (SnowyBeach) Temperature() float64 { + return 0.05 +} + +// Rainfall ... +func (SnowyBeach) Rainfall() float64 { + return 0.3 +} + +// Depth ... +func (SnowyBeach) Depth() float64 { + return 0 +} + +// Scale ... +func (SnowyBeach) Scale() float64 { + return 0.025 +} + +// WaterColour ... +func (SnowyBeach) WaterColour() color.RGBA { + return color.RGBA{R: 0x14, G: 0x63, B: 0xa5, A: 0xa5} +} + +// Tags ... +func (SnowyBeach) Tags() []string { + return []string{"beach", "cold", "monster", "overworld", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs", "spawns_snow_foxes", "spawns_white_rabbits"} +} + +// String ... +func (SnowyBeach) String() string { + return "cold_beach" +} + +// EncodeBiome ... +func (SnowyBeach) EncodeBiome() int { + return 26 +} diff --git a/server/world/biome/snowy_mountains.go b/server/world/biome/snowy_mountains.go new file mode 100644 index 0000000..cef79fb --- /dev/null +++ b/server/world/biome/snowy_mountains.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// SnowyMountains ... +type SnowyMountains struct{} + +// Temperature ... +func (SnowyMountains) Temperature() float64 { + return 0 +} + +// Rainfall ... +func (SnowyMountains) Rainfall() float64 { + return 0.5 +} + +// Depth ... +func (SnowyMountains) Depth() float64 { + return 0.45 +} + +// Scale ... +func (SnowyMountains) Scale() float64 { + return 0.3 +} + +// WaterColour ... +func (SnowyMountains) WaterColour() color.RGBA { + return color.RGBA{R: 0x11, G: 0x56, B: 0xa7, A: 0xa5} +} + +// Tags ... +func (SnowyMountains) Tags() []string { + return []string{"frozen", "ice", "mountain", "overworld", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs", "spawns_snow_foxes", "spawns_white_rabbits"} +} + +// String ... +func (SnowyMountains) String() string { + return "ice_mountains" +} + +// EncodeBiome ... +func (SnowyMountains) EncodeBiome() int { + return 13 +} diff --git a/server/world/biome/snowy_plains.go b/server/world/biome/snowy_plains.go new file mode 100644 index 0000000..be29a1d --- /dev/null +++ b/server/world/biome/snowy_plains.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// SnowyPlains ... +type SnowyPlains struct{} + +// Temperature ... +func (SnowyPlains) Temperature() float64 { + return 0 +} + +// Rainfall ... +func (SnowyPlains) Rainfall() float64 { + return 0.5 +} + +// Depth ... +func (SnowyPlains) Depth() float64 { + return 0.125 +} + +// Scale ... +func (SnowyPlains) Scale() float64 { + return 0.05 +} + +// WaterColour ... +func (SnowyPlains) WaterColour() color.RGBA { + return color.RGBA{R: 0x14, G: 0x55, B: 0x9b, A: 0xa5} +} + +// Tags ... +func (SnowyPlains) Tags() []string { + return []string{"frozen", "ice", "ice_plains", "overworld", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs", "spawns_snow_foxes", "spawns_white_rabbits"} +} + +// String ... +func (SnowyPlains) String() string { + return "ice_plains" +} + +// EncodeBiome ... +func (SnowyPlains) EncodeBiome() int { + return 12 +} diff --git a/server/world/biome/snowy_slopes.go b/server/world/biome/snowy_slopes.go new file mode 100644 index 0000000..387fba2 --- /dev/null +++ b/server/world/biome/snowy_slopes.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// SnowySlopes ... +type SnowySlopes struct{} + +// Temperature ... +func (SnowySlopes) Temperature() float64 { + return -0.3 +} + +// Rainfall ... +func (SnowySlopes) Rainfall() float64 { + return 0.9 +} + +// Depth ... +func (SnowySlopes) Depth() float64 { + return 0.1 +} + +// Scale ... +func (SnowySlopes) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (SnowySlopes) WaterColour() color.RGBA { + return color.RGBA{R: 0x60, G: 0xb7, B: 0xff, A: 0xa6} +} + +// Tags ... +func (SnowySlopes) Tags() []string { + return []string{"mountains", "monster", "overworld", "frozen", "spawns_cold_variant_frogs", "spawns_snow_foxes", "spawns_white_rabbits", "snowy_slopes", "spawns_cold_variant_farm_animals"} +} + +// String ... +func (SnowySlopes) String() string { + return "snowy_slopes" +} + +// EncodeBiome ... +func (SnowySlopes) EncodeBiome() int { + return 184 +} diff --git a/server/world/biome/snowy_taiga.go b/server/world/biome/snowy_taiga.go new file mode 100644 index 0000000..d539d7c --- /dev/null +++ b/server/world/biome/snowy_taiga.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// SnowyTaiga ... +type SnowyTaiga struct{} + +// Temperature ... +func (SnowyTaiga) Temperature() float64 { + return -0.5 +} + +// Rainfall ... +func (SnowyTaiga) Rainfall() float64 { + return 0.4 +} + +// Depth ... +func (SnowyTaiga) Depth() float64 { + return 0.2 +} + +// Scale ... +func (SnowyTaiga) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (SnowyTaiga) WaterColour() color.RGBA { + return color.RGBA{R: 0x20, G: 0x5e, B: 0x83, A: 0xa5} +} + +// Tags ... +func (SnowyTaiga) Tags() []string { + return []string{"animal", "cold", "forest", "monster", "overworld", "taiga", "has_structure_trail_ruins", "spawns_cold_variant_farm_animals"} +} + +// String ... +func (SnowyTaiga) String() string { + return "cold_taiga" +} + +// EncodeBiome ... +func (SnowyTaiga) EncodeBiome() int { + return 30 +} diff --git a/server/world/biome/snowy_taiga_hills.go b/server/world/biome/snowy_taiga_hills.go new file mode 100644 index 0000000..51706a7 --- /dev/null +++ b/server/world/biome/snowy_taiga_hills.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// SnowyTaigaHills ... +type SnowyTaigaHills struct{} + +// Temperature ... +func (SnowyTaigaHills) Temperature() float64 { + return -0.5 +} + +// Rainfall ... +func (SnowyTaigaHills) Rainfall() float64 { + return 0.4 +} + +// Depth ... +func (SnowyTaigaHills) Depth() float64 { + return 0.45 +} + +// Scale ... +func (SnowyTaigaHills) Scale() float64 { + return 0.3 +} + +// WaterColour ... +func (SnowyTaigaHills) WaterColour() color.RGBA { + return color.RGBA{R: 0x24, G: 0x5b, B: 0x78, A: 0xa5} +} + +// Tags ... +func (SnowyTaigaHills) Tags() []string { + return []string{"animal", "cold", "forest", "hills", "monster", "overworld", "taiga", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs", "spawns_white_rabbits"} +} + +// String ... +func (SnowyTaigaHills) String() string { + return "cold_taiga_hills" +} + +// EncodeBiome ... +func (SnowyTaigaHills) EncodeBiome() int { + return 31 +} diff --git a/server/world/biome/snowy_taiga_mountains.go b/server/world/biome/snowy_taiga_mountains.go new file mode 100644 index 0000000..3dad48f --- /dev/null +++ b/server/world/biome/snowy_taiga_mountains.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// SnowyTaigaMountains ... +type SnowyTaigaMountains struct{} + +// Temperature ... +func (SnowyTaigaMountains) Temperature() float64 { + return -0.5 +} + +// Rainfall ... +func (SnowyTaigaMountains) Rainfall() float64 { + return 0.4 +} + +// Depth ... +func (SnowyTaigaMountains) Depth() float64 { + return 0.3 +} + +// Scale ... +func (SnowyTaigaMountains) Scale() float64 { + return 0.4 +} + +// WaterColour ... +func (SnowyTaigaMountains) WaterColour() color.RGBA { + return color.RGBA{R: 0x20, G: 0x5e, B: 0x83, A: 0xa5} +} + +// Tags ... +func (SnowyTaigaMountains) Tags() []string { + return []string{"animal", "cold", "forest", "monster", "mutated", "taiga", "overworld_generation", "spawns_cold_variant_farm_animals"} +} + +// String ... +func (SnowyTaigaMountains) String() string { + return "cold_taiga_mutated" +} + +// EncodeBiome ... +func (SnowyTaigaMountains) EncodeBiome() int { + return 158 +} diff --git a/server/world/biome/soul_sand_valley.go b/server/world/biome/soul_sand_valley.go new file mode 100644 index 0000000..102937f --- /dev/null +++ b/server/world/biome/soul_sand_valley.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// SoulSandValley ... +type SoulSandValley struct{} + +// Temperature ... +func (SoulSandValley) Temperature() float64 { + return 2 +} + +// Rainfall ... +func (SoulSandValley) Rainfall() float64 { + return 0 +} + +// Depth ... +func (SoulSandValley) Depth() float64 { + return 0.1 +} + +// Scale ... +func (SoulSandValley) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (SoulSandValley) WaterColour() color.RGBA { + return color.RGBA{R: 0x90, G: 0x59, B: 0x57, A: 0xa5} +} + +// Tags ... +func (SoulSandValley) Tags() []string { + return []string{"nether", "soulsand_valley", "spawn_ghast", "spawn_endermen", "spawns_warm_variant_farm_animals"} +} + +// String ... +func (SoulSandValley) String() string { + return "soulsand_valley" +} + +// EncodeBiome ... +func (SoulSandValley) EncodeBiome() int { + return 178 +} diff --git a/server/world/biome/stony_peaks.go b/server/world/biome/stony_peaks.go new file mode 100644 index 0000000..3b8ec33 --- /dev/null +++ b/server/world/biome/stony_peaks.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// StonyPeaks ... +type StonyPeaks struct{} + +// Temperature ... +func (StonyPeaks) Temperature() float64 { + return 1 +} + +// Rainfall ... +func (StonyPeaks) Rainfall() float64 { + return 0.3 +} + +// Depth ... +func (StonyPeaks) Depth() float64 { + return 0.1 +} + +// Scale ... +func (StonyPeaks) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (StonyPeaks) WaterColour() color.RGBA { + return color.RGBA{R: 0x60, G: 0xb7, B: 0xff, A: 0xa6} +} + +// Tags ... +func (StonyPeaks) Tags() []string { + return []string{"mountains", "monster", "overworld", "spawns_cold_variant_farm_animals"} +} + +// String ... +func (StonyPeaks) String() string { + return "stony_peaks" +} + +// EncodeBiome ... +func (StonyPeaks) EncodeBiome() int { + return 189 +} diff --git a/server/world/biome/stony_shore.go b/server/world/biome/stony_shore.go new file mode 100644 index 0000000..78de47f --- /dev/null +++ b/server/world/biome/stony_shore.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// StonyShore ... +type StonyShore struct{} + +// Temperature ... +func (StonyShore) Temperature() float64 { + return 0.2 +} + +// Rainfall ... +func (StonyShore) Rainfall() float64 { + return 0.3 +} + +// Depth ... +func (StonyShore) Depth() float64 { + return 0.1 +} + +// Scale ... +func (StonyShore) Scale() float64 { + return 0.8 +} + +// WaterColour ... +func (StonyShore) WaterColour() color.RGBA { + return color.RGBA{R: 0x0d, G: 0x67, B: 0xbb, A: 0xa5} +} + +// Tags ... +func (StonyShore) Tags() []string { + return []string{"beach", "monster", "overworld", "stone"} +} + +// String ... +func (StonyShore) String() string { + return "stone_beach" +} + +// EncodeBiome ... +func (StonyShore) EncodeBiome() int { + return 25 +} diff --git a/server/world/biome/sulfur_caves.go b/server/world/biome/sulfur_caves.go new file mode 100644 index 0000000..7c32f06 --- /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/biome/sunflower_plains.go b/server/world/biome/sunflower_plains.go new file mode 100644 index 0000000..1e2c1dc --- /dev/null +++ b/server/world/biome/sunflower_plains.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// SunflowerPlains ... +type SunflowerPlains struct{} + +// Temperature ... +func (SunflowerPlains) Temperature() float64 { + return 0.8 +} + +// Rainfall ... +func (SunflowerPlains) Rainfall() float64 { + return 0.4 +} + +// Depth ... +func (SunflowerPlains) Depth() float64 { + return 0.125 +} + +// Scale ... +func (SunflowerPlains) Scale() float64 { + return 0.05 +} + +// WaterColour ... +func (SunflowerPlains) WaterColour() color.RGBA { + return color.RGBA{R: 0x60, G: 0xb7, B: 0xff, A: 0xa6} +} + +// Tags ... +func (SunflowerPlains) Tags() []string { + return []string{"animal", "monster", "mutated", "overworld", "plains", "bee_habitat"} +} + +// String ... +func (SunflowerPlains) String() string { + return "sunflower_plains" +} + +// EncodeBiome ... +func (SunflowerPlains) EncodeBiome() int { + return 129 +} diff --git a/server/world/biome/swamp.go b/server/world/biome/swamp.go new file mode 100644 index 0000000..1e2f793 --- /dev/null +++ b/server/world/biome/swamp.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// Swamp ... +type Swamp struct{} + +// Temperature ... +func (Swamp) Temperature() float64 { + return 0.8 +} + +// Rainfall ... +func (Swamp) Rainfall() float64 { + return 0.9 +} + +// Depth ... +func (Swamp) Depth() float64 { + return -0.2 +} + +// Scale ... +func (Swamp) Scale() float64 { + return 0.1 +} + +// WaterColour ... +func (Swamp) WaterColour() color.RGBA { + return color.RGBA{R: 0x61, G: 0x7b, B: 0x64, A: 0xa5} +} + +// Tags ... +func (Swamp) Tags() []string { + return []string{"animal", "monster", "overworld", "swamp", "spawns_slimes_on_surface", "slime", "swamp_water_huge_mushroom"} +} + +// String ... +func (Swamp) String() string { + return "swampland" +} + +// EncodeBiome ... +func (Swamp) EncodeBiome() int { + return 6 +} diff --git a/server/world/biome/swamp_hills.go b/server/world/biome/swamp_hills.go new file mode 100644 index 0000000..13adefc --- /dev/null +++ b/server/world/biome/swamp_hills.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// SwampHills ... +type SwampHills struct{} + +// Temperature ... +func (SwampHills) Temperature() float64 { + return 0.8 +} + +// Rainfall ... +func (SwampHills) Rainfall() float64 { + return 0.5 +} + +// Depth ... +func (SwampHills) Depth() float64 { + return -0.1 +} + +// Scale ... +func (SwampHills) Scale() float64 { + return 0.3 +} + +// WaterColour ... +func (SwampHills) WaterColour() color.RGBA { + return color.RGBA{R: 0x61, G: 0x7b, B: 0x64, A: 0xa5} +} + +// Tags ... +func (SwampHills) Tags() []string { + return []string{"animal", "monster", "mutated", "swamp", "overworld_generation", "spawns_slimes_on_surface", "slime", "swamp_water_huge_mushroom"} +} + +// String ... +func (SwampHills) String() string { + return "swampland_mutated" +} + +// EncodeBiome ... +func (SwampHills) EncodeBiome() int { + return 134 +} diff --git a/server/world/biome/taiga.go b/server/world/biome/taiga.go new file mode 100644 index 0000000..06f41e0 --- /dev/null +++ b/server/world/biome/taiga.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// Taiga ... +type Taiga struct{} + +// Temperature ... +func (Taiga) Temperature() float64 { + return 0.25 +} + +// Rainfall ... +func (Taiga) Rainfall() float64 { + return 0.8 +} + +// Depth ... +func (Taiga) Depth() float64 { + return 0.1 +} + +// Scale ... +func (Taiga) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (Taiga) WaterColour() color.RGBA { + return color.RGBA{R: 0x28, G: 0x70, B: 0x82, A: 0xa5} +} + +// Tags ... +func (Taiga) Tags() []string { + return []string{"animal", "forest", "monster", "overworld", "taiga", "has_structure_trail_ruins", "spawns_cold_variant_farm_animals"} +} + +// String ... +func (Taiga) String() string { + return "taiga" +} + +// EncodeBiome ... +func (Taiga) EncodeBiome() int { + return 5 +} diff --git a/server/world/biome/taiga_hills.go b/server/world/biome/taiga_hills.go new file mode 100644 index 0000000..439b5f6 --- /dev/null +++ b/server/world/biome/taiga_hills.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// TaigaHills ... +type TaigaHills struct{} + +// Temperature ... +func (TaigaHills) Temperature() float64 { + return 0.25 +} + +// Rainfall ... +func (TaigaHills) Rainfall() float64 { + return 0.8 +} + +// Depth ... +func (TaigaHills) Depth() float64 { + return 0.45 +} + +// Scale ... +func (TaigaHills) Scale() float64 { + return 0.3 +} + +// WaterColour ... +func (TaigaHills) WaterColour() color.RGBA { + return color.RGBA{R: 0x23, G: 0x65, B: 0x83, A: 0xa5} +} + +// Tags ... +func (TaigaHills) Tags() []string { + return []string{"animal", "hills", "monster", "overworld", "forest", "taiga", "spawns_cold_variant_farm_animals"} +} + +// String ... +func (TaigaHills) String() string { + return "taiga_hills" +} + +// EncodeBiome ... +func (TaigaHills) EncodeBiome() int { + return 19 +} diff --git a/server/world/biome/taiga_mountains.go b/server/world/biome/taiga_mountains.go new file mode 100644 index 0000000..3466426 --- /dev/null +++ b/server/world/biome/taiga_mountains.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// TaigaMountains ... +type TaigaMountains struct{} + +// Temperature ... +func (TaigaMountains) Temperature() float64 { + return 0.25 +} + +// Rainfall ... +func (TaigaMountains) Rainfall() float64 { + return 0.8 +} + +// Depth ... +func (TaigaMountains) Depth() float64 { + return 0.2 +} + +// Scale ... +func (TaigaMountains) Scale() float64 { + return 0.4 +} + +// WaterColour ... +func (TaigaMountains) WaterColour() color.RGBA { + return color.RGBA{R: 0x1e, G: 0x6b, B: 0x82, A: 0xa5} +} + +// Tags ... +func (TaigaMountains) Tags() []string { + return []string{"animal", "forest", "monster", "mutated", "taiga", "overworld_generation", "spawns_cold_variant_farm_animals"} +} + +// String ... +func (TaigaMountains) String() string { + return "taiga_mutated" +} + +// EncodeBiome ... +func (TaigaMountains) EncodeBiome() int { + return 133 +} diff --git a/server/world/biome/tall_birch_hills.go b/server/world/biome/tall_birch_hills.go new file mode 100644 index 0000000..5f1c83d --- /dev/null +++ b/server/world/biome/tall_birch_hills.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// TallBirchHills ... +type TallBirchHills struct{} + +// Temperature ... +func (TallBirchHills) Temperature() float64 { + return 0.7 +} + +// Rainfall ... +func (TallBirchHills) Rainfall() float64 { + return 0.8 +} + +// Depth ... +func (TallBirchHills) Depth() float64 { + return 0.55 +} + +// Scale ... +func (TallBirchHills) Scale() float64 { + return 0.5 +} + +// WaterColour ... +func (TallBirchHills) WaterColour() color.RGBA { + return color.RGBA{R: 0x0a, G: 0x74, B: 0xc4, A: 0xa5} +} + +// Tags ... +func (TallBirchHills) Tags() []string { + return []string{"animal", "birch", "forest", "hills", "monster", "mutated", "overworld_generation"} +} + +// String ... +func (TallBirchHills) String() string { + return "birch_forest_hills_mutated" +} + +// EncodeBiome ... +func (TallBirchHills) EncodeBiome() int { + return 156 +} diff --git a/server/world/biome/warm_ocean.go b/server/world/biome/warm_ocean.go new file mode 100644 index 0000000..1725696 --- /dev/null +++ b/server/world/biome/warm_ocean.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// WarmOcean ... +type WarmOcean struct{} + +// Temperature ... +func (WarmOcean) Temperature() float64 { + return 0.5 +} + +// Rainfall ... +func (WarmOcean) Rainfall() float64 { + return 0.5 +} + +// Depth ... +func (WarmOcean) Depth() float64 { + return -1 +} + +// Scale ... +func (WarmOcean) Scale() float64 { + return 0.1 +} + +// WaterColour ... +func (WarmOcean) WaterColour() color.RGBA { + return color.RGBA{R: 0x02, G: 0xb0, B: 0xe5, A: 0xa5} +} + +// Tags ... +func (WarmOcean) Tags() []string { + return []string{"monster", "ocean", "overworld", "warm", "spawns_warm_variant_farm_animals", "spawns_warm_variant_frogs", "fast_fishing", "high_seas"} +} + +// String ... +func (WarmOcean) String() string { + return "warm_ocean" +} + +// EncodeBiome ... +func (WarmOcean) EncodeBiome() int { + return 40 +} diff --git a/server/world/biome/warped_forest.go b/server/world/biome/warped_forest.go new file mode 100644 index 0000000..eacfe23 --- /dev/null +++ b/server/world/biome/warped_forest.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// WarpedForest ... +type WarpedForest struct{} + +// Temperature ... +func (WarpedForest) Temperature() float64 { + return 2 +} + +// Rainfall ... +func (WarpedForest) Rainfall() float64 { + return 0 +} + +// Depth ... +func (WarpedForest) Depth() float64 { + return 0.1 +} + +// Scale ... +func (WarpedForest) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (WarpedForest) WaterColour() color.RGBA { + return color.RGBA{R: 0x90, G: 0x59, B: 0x57, A: 0xa5} +} + +// Tags ... +func (WarpedForest) Tags() []string { + return []string{"nether", "netherwart_forest", "warped_forest", "spawn_endermen", "spawns_warm_variant_farm_animals"} +} + +// String ... +func (WarpedForest) String() string { + return "warped_forest" +} + +// EncodeBiome ... +func (WarpedForest) EncodeBiome() int { + return 180 +} diff --git a/server/world/biome/windswept_forest.go b/server/world/biome/windswept_forest.go new file mode 100644 index 0000000..974f5ff --- /dev/null +++ b/server/world/biome/windswept_forest.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// WindsweptForest ... +type WindsweptForest struct{} + +// Temperature ... +func (WindsweptForest) Temperature() float64 { + return 0.2 +} + +// Rainfall ... +func (WindsweptForest) Rainfall() float64 { + return 0.3 +} + +// Depth ... +func (WindsweptForest) Depth() float64 { + return 1 +} + +// Scale ... +func (WindsweptForest) Scale() float64 { + return 0.5 +} + +// WaterColour ... +func (WindsweptForest) WaterColour() color.RGBA { + return color.RGBA{R: 0x0e, G: 0x63, B: 0xab, A: 0xa5} +} + +// Tags ... +func (WindsweptForest) Tags() []string { + return []string{"animal", "extreme_hills", "forest", "monster", "mountain", "overworld", "spawns_cold_variant_farm_animals"} +} + +// String ... +func (WindsweptForest) String() string { + return "extreme_hills_plus_trees" +} + +// EncodeBiome ... +func (WindsweptForest) EncodeBiome() int { + return 34 +} diff --git a/server/world/biome/windswept_gravelly_hills.go b/server/world/biome/windswept_gravelly_hills.go new file mode 100644 index 0000000..5a3d32b --- /dev/null +++ b/server/world/biome/windswept_gravelly_hills.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// WindsweptGravellyHills ... +type WindsweptGravellyHills struct{} + +// Temperature ... +func (WindsweptGravellyHills) Temperature() float64 { + return 0.2 +} + +// Rainfall ... +func (WindsweptGravellyHills) Rainfall() float64 { + return 0.3 +} + +// Depth ... +func (WindsweptGravellyHills) Depth() float64 { + return 1 +} + +// Scale ... +func (WindsweptGravellyHills) Scale() float64 { + return 0.5 +} + +// WaterColour ... +func (WindsweptGravellyHills) WaterColour() color.RGBA { + return color.RGBA{R: 0x0e, G: 0x63, B: 0xab, A: 0xa5} +} + +// Tags ... +func (WindsweptGravellyHills) Tags() []string { + return []string{"animal", "extreme_hills", "monster", "mutated", "overworld", "spawns_cold_variant_farm_animals"} +} + +// String ... +func (WindsweptGravellyHills) String() string { + return "extreme_hills_mutated" +} + +// EncodeBiome ... +func (WindsweptGravellyHills) EncodeBiome() int { + return 131 +} diff --git a/server/world/biome/windswept_hills.go b/server/world/biome/windswept_hills.go new file mode 100644 index 0000000..bdd49af --- /dev/null +++ b/server/world/biome/windswept_hills.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// WindsweptHills ... +type WindsweptHills struct{} + +// Temperature ... +func (WindsweptHills) Temperature() float64 { + return 0.2 +} + +// Rainfall ... +func (WindsweptHills) Rainfall() float64 { + return 0.3 +} + +// Depth ... +func (WindsweptHills) Depth() float64 { + return 1 +} + +// Scale ... +func (WindsweptHills) Scale() float64 { + return 0.5 +} + +// WaterColour ... +func (WindsweptHills) WaterColour() color.RGBA { + return color.RGBA{R: 0x00, G: 0x7b, B: 0xf7, A: 0xa5} +} + +// Tags ... +func (WindsweptHills) Tags() []string { + return []string{"animal", "extreme_hills", "monster", "overworld", "spawns_cold_variant_farm_animals"} +} + +// String ... +func (WindsweptHills) String() string { + return "extreme_hills" +} + +// EncodeBiome ... +func (WindsweptHills) EncodeBiome() int { + return 3 +} diff --git a/server/world/biome/windswept_savanna.go b/server/world/biome/windswept_savanna.go new file mode 100644 index 0000000..9252d82 --- /dev/null +++ b/server/world/biome/windswept_savanna.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// WindsweptSavanna ... +type WindsweptSavanna struct{} + +// Temperature ... +func (WindsweptSavanna) Temperature() float64 { + return 2 +} + +// Rainfall ... +func (WindsweptSavanna) Rainfall() float64 { + return 0 +} + +// Depth ... +func (WindsweptSavanna) Depth() float64 { + return 0.363 +} + +// Scale ... +func (WindsweptSavanna) Scale() float64 { + return 1.225 +} + +// WaterColour ... +func (WindsweptSavanna) WaterColour() color.RGBA { + return color.RGBA{R: 0x60, G: 0xb7, B: 0xff, A: 0xa6} +} + +// Tags ... +func (WindsweptSavanna) Tags() []string { + return []string{"animal", "monster", "mutated", "overworld", "savanna", "spawns_savanna_mobs", "spawns_warm_variant_farm_animals"} +} + +// String ... +func (WindsweptSavanna) String() string { + return "savanna_mutated" +} + +// EncodeBiome ... +func (WindsweptSavanna) EncodeBiome() int { + return 163 +} diff --git a/server/world/biome/wooded_badlands_plateau.go b/server/world/biome/wooded_badlands_plateau.go new file mode 100644 index 0000000..ad758c5 --- /dev/null +++ b/server/world/biome/wooded_badlands_plateau.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// WoodedBadlandsPlateau ... +type WoodedBadlandsPlateau struct{} + +// Temperature ... +func (WoodedBadlandsPlateau) Temperature() float64 { + return 2 +} + +// Rainfall ... +func (WoodedBadlandsPlateau) Rainfall() float64 { + return 0 +} + +// Depth ... +func (WoodedBadlandsPlateau) Depth() float64 { + return 1.5 +} + +// Scale ... +func (WoodedBadlandsPlateau) Scale() float64 { + return 0.025 +} + +// WaterColour ... +func (WoodedBadlandsPlateau) WaterColour() color.RGBA { + return color.RGBA{R: 0x55, G: 0x80, B: 0x9e, A: 0xa5} +} + +// Tags ... +func (WoodedBadlandsPlateau) Tags() []string { + return []string{"animal", "mesa", "monster", "overworld", "plateau", "rare", "stone", "spawns_mesa_mobs", "spawns_warm_variant_farm_animals", "surface_mineshaft"} +} + +// String ... +func (WoodedBadlandsPlateau) String() string { + return "mesa_plateau_stone" +} + +// EncodeBiome ... +func (WoodedBadlandsPlateau) EncodeBiome() int { + return 38 +} diff --git a/server/world/biome/wooded_hills.go b/server/world/biome/wooded_hills.go new file mode 100644 index 0000000..9560475 --- /dev/null +++ b/server/world/biome/wooded_hills.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// WoodedHills ... +type WoodedHills struct{} + +// Temperature ... +func (WoodedHills) Temperature() float64 { + return 0.7 +} + +// Rainfall ... +func (WoodedHills) Rainfall() float64 { + return 0.8 +} + +// Depth ... +func (WoodedHills) Depth() float64 { + return 0.45 +} + +// Scale ... +func (WoodedHills) Scale() float64 { + return 0.3 +} + +// WaterColour ... +func (WoodedHills) WaterColour() color.RGBA { + return color.RGBA{R: 0x05, G: 0x6b, B: 0xd1, A: 0xa5} +} + +// Tags ... +func (WoodedHills) Tags() []string { + return []string{"animal", "hills", "monster", "overworld", "forest", "bee_habitat"} +} + +// String ... +func (WoodedHills) String() string { + return "forest_hills" +} + +// EncodeBiome ... +func (WoodedHills) EncodeBiome() int { + return 18 +} diff --git a/server/world/biome_definition.go b/server/world/biome_definition.go new file mode 100644 index 0000000..5952521 --- /dev/null +++ b/server/world/biome_definition.go @@ -0,0 +1,82 @@ +package world + +import ( + "encoding/binary" + + "github.com/sandertv/gophertunnel/minecraft/protocol" +) + +var ( + // maxVanillaBiomeID is the highest ID used by vanilla biomes. + maxVanillaBiomeID int +) + +// finaliseBiomeRegistry is called after all vanilla biomes have been registered. +// It sets maxVanillaBiomeID to the highest ID found among them. +// noinspection GoUnusedFunction +// +//lint:ignore U1000 Function is used through compiler directives. +func finaliseBiomeRegistry() { + for _, b := range biomes { + id := b.EncodeBiome() + if id > maxVanillaBiomeID { + maxVanillaBiomeID = id + } + } +} + +// BiomeDefinitions returns the list of biome definitions along with the associated StringList. +func BiomeDefinitions() ([]protocol.BiomeDefinition, []string) { + var ( + internedStrings []string + internedStringIndex = make(map[string]int) + ) + + intern := func(s string) int { + if index, exists := internedStringIndex[s]; exists { + return index + } + index := len(internedStrings) + internedStrings = append(internedStrings, s) + internedStringIndex[s] = index + return index + } + + encodedBiomes := make([]protocol.BiomeDefinition, 0, len(biomes)) + for _, b := range biomes { + nameIndex := intern(b.String()) + + tags := b.Tags() + tagIndices := make([]uint16, len(tags)) + for i, tag := range tags { + tagIndices[i] = uint16(intern(tag)) + } + + var biomeID int16 = -1 + id := b.EncodeBiome() + if id > maxVanillaBiomeID { + biomeID = int16(id) + } + + def := protocol.BiomeDefinition{ + NameIndex: int16(nameIndex), + BiomeID: biomeID, + Temperature: float32(b.Temperature()), + Downfall: float32(b.Rainfall()), + Depth: float32(b.Depth()), + Scale: float32(b.Scale()), + MapWaterColour: int32(binary.BigEndian.Uint32([]byte{ + b.WaterColour().A, + b.WaterColour().R, + b.WaterColour().G, + b.WaterColour().B, + })), + Rain: b.Rainfall() > 0, + Tags: protocol.Option[[]uint16](tagIndices), + } + + encodedBiomes = append(encodedBiomes, def) + } + + return encodedBiomes, internedStrings +} diff --git a/server/world/block.go b/server/world/block.go new file mode 100644 index 0000000..165cce5 --- /dev/null +++ b/server/world/block.go @@ -0,0 +1,262 @@ +package world + +import ( + "image" + "math/rand/v2" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/block/customblock" +) + +// Block is a block that may be placed or found in a world. In addition, the block may also be added to an +// inventory: It is also an item. +// Every Block implementation must be able to be hashed as key in a map. +type Block interface { + // EncodeBlock encodes the block to a string ID such as 'minecraft:grass' and properties associated + // with the block. + EncodeBlock() (string, map[string]any) + // Hash returns two different identifiers for the block. The first is the base hash which is unique for + // each type of block at runtime. For vanilla blocks, this is an auto-incrementing constant and for custom + // blocks, you can call block.NextHash() to get a unique identifier. The second is the hash of the block's + // own state and does not need to worry about colliding with other types of blocks. This is later combined + // with the base hash to create a unique identifier for the full block. + Hash() (uint64, uint64) + // Model returns the BlockModel of the Block. + Model() BlockModel +} + +// CustomBlock represents a block that is non-vanilla and requires a resource pack and extra steps to show it to the +// client. +type CustomBlock interface { + Block + Properties() customblock.Properties +} + +type CustomBlockBuildable interface { + CustomBlock + // Name is the name displayed to clients using the block. + Name() string + // Geometry is the geometries for the block that define the shape of the block. If false is returned, no custom + // geometry will be applied. Permutation-specific geometry can be defined by returning a map of permutations to + // geometry. + Geometry() []byte + // Textures is a map of images indexed by their target, used to map textures on to the block. Permutation-specific + // textures can be defined by returning a map of permutations to textures. + Textures() map[string]image.Image +} + +// Liquid represents a block that can be moved through and which can flow in the world after placement. There +// are two liquids in vanilla, which are lava and water. +type Liquid interface { + Block + // LiquidDepth returns the current depth of the liquid. + LiquidDepth() int + // SpreadDecay returns the amount of depth that is subtracted from the liquid's depth when it spreads to + // a next block. + SpreadDecay() int + // WithDepth returns the liquid with the depth passed. + WithDepth(depth int, falling bool) Liquid + // LiquidFalling checks if the liquid is currently considered falling down. + LiquidFalling() bool + // BlastResistance is the blast resistance of the liquid, which influences the liquid's ability to withstand an + // explosive blast. + BlastResistance() float64 + // LiquidType returns an int unique for the liquid, used to check if two liquids are considered to be + // of the same type. + LiquidType() string + // Harden checks if the block should harden when looking at the surrounding blocks and sets the position + // to the hardened block when adequate. If the block was hardened, the method returns true. + Harden(pos cube.Pos, tx *Tx, flownIntoBy *cube.Pos) bool + // LiquidRemoveBlock is called when the liquid flows into and removes the block passed. + LiquidRemoveBlock(pos cube.Pos, tx *Tx, removed Block) +} + +// Conductor represents a block that can conduct a redstone signal. +type Conductor interface { + Block + // RedstoneSource returns true if the conductor is a signal source. + RedstoneSource() bool + + // WeakPower returns the weak power level emitted by this conductor toward a neighbouring receiver. + // The face argument is relative to the receiving block, not this conductor. + // Weak power can pass through a solid block to power redstone components on the other side, but + // cannot power solid blocks themselves or travel further. + // The accountForDust parameter indicates whether redstone dust should be considered when + // calculating power levels. + WeakPower(pos cube.Pos, face cube.Face, tx *Tx, accountForDust bool) int + + // StrongPower returns the strong power level emitted by this conductor toward a neighbouring + // receiver. The face argument uses the same convention as WeakPower. + // Strong power can be transmitted through solid blocks. When a solid block receives strong power + // through one of its faces, it can provide weak power to adjacent redstone components on all other + // faces. Strong power can also directly power any redstone component. + // The accountForDust parameter indicates whether redstone dust should be considered when + // calculating power levels. + StrongPower(pos cube.Pos, face cube.Face, tx *Tx, accountForDust bool) int +} + +// WeakBlockPowerer represents a conductor whose weak power may weakly power an adjacent conductive block. Weakly +// powered blocks may activate mechanisms and repeaters, but do not power adjacent redstone dust. For example, +// dust pointing into a stone block opens a door on the stone's far side, but a second stretch of dust there stays +// dark. +type WeakBlockPowerer interface { + Conductor + // WeaklyPowersBlocks returns true if this conductor's WeakPower can make an adjacent conductive block weakly powered. + WeaklyPowersBlocks() bool +} + +// RedstonePowerRelayer represents a block with custom behaviour for whether +// neighbouring redstone power may be relayed through it by Tx.RedstonePower. +type RedstonePowerRelayer interface { + Block + // RelaysRedstonePowerThrough reports whether this non-conductor block may + // relay neighbouring redstone power through itself to receivers on its other sides. + RelaysRedstonePowerThrough() bool +} + +// RedstoneUpdater represents a block that reacts to nearby redstone power changes. +type RedstoneUpdater interface { + Block + // RedstoneUpdate is called when a change in redstone signal is computed. + RedstoneUpdate(pos cube.Pos, tx *Tx) +} + +// RegisterBlock registers the Block passed in the DefaultBlockRegistry. +// +// This function exists for backwards compatibility and works well for the common "single server per process" setup, +// where all worlds share the global default registry. +// +// If you run multiple servers/registries in a single process, prefer creating a registry using NewBlockRegistry() and +// registering blocks on that instance (e.g. conf.Blocks.RegisterBlock(...)) before calling Finalize(). +func RegisterBlock(b Block) { + DefaultBlockRegistry.RegisterBlock(b) +} + +// BlockHash returns a unique identifier of the block including the block states using the DefaultBlockRegistry. +// This function is used internally to convert a block to a single integer which can be used in map lookups. The hash +// produced therefore does not need to match anything in the game, but it must be unique among all registered blocks. +// The tool in `/cmd/blockhash` may be used to automatically generate block hashes of blocks in a package. +// +// If you use a non-default registry (NewBlockRegistry), use your registry instance's BlockHash(...) instead so the +// hash is consistent with that registry. +func BlockHash(b Block) uint64 { + return DefaultBlockRegistry.BlockHash(b) +} + +// BlockRuntimeID attempts to return a runtime ID of a block previously registered using RegisterBlock() on the +// DefaultBlockRegistry. +// If the runtime ID cannot be found because the Block wasn't registered, BlockRuntimeID will panic. +// If you use a non-default registry (NewBlockRegistry), use your registry instance's BlockRuntimeID(...) instead. +func BlockRuntimeID(b Block) uint32 { + return DefaultBlockRegistry.BlockRuntimeID(b) +} + +// BlockByRuntimeID attempts to return a Block by its runtime ID using the DefaultBlockRegistry. If not found, the bool +// returned is false. If found, the block is non-nil and the bool true. +// If you use a non-default registry (NewBlockRegistry), use your registry instance's BlockByRuntimeID(...) instead. +func BlockByRuntimeID(rid uint32) (Block, bool) { + return DefaultBlockRegistry.BlockByRuntimeID(rid) +} + +// BlockByName attempts to return a Block by its name and properties using the DefaultBlockRegistry. If not found, the +// bool returned is false. +// If you use a non-default registry (NewBlockRegistry), use your registry instance's BlockByName(...) instead. +func BlockByName(name string, properties map[string]any) (Block, bool) { + return DefaultBlockRegistry.BlockByName(name, properties) +} + +// Blocks returns a slice of all blocks registered in the DefaultBlockRegistry. +// If you use a non-default registry (NewBlockRegistry), use your registry instance's Blocks() instead. +func Blocks() []Block { + return DefaultBlockRegistry.Blocks() +} + +// CustomBlocks returns a map of all custom blocks registered with their names as keys in the DefaultBlockRegistry. +// If you use a non-default registry (NewBlockRegistry), use your registry instance's CustomBlocks() instead. +func CustomBlocks() map[string]CustomBlock { + return DefaultBlockRegistry.CustomBlocks() +} + +// RandomTicker represents a block that executes an action when it is ticked randomly. Every 20th of a second, +// one random block in each sub chunk are picked to receive a random tick. +type RandomTicker interface { + // RandomTick handles a random tick of the block at the position passed. Additionally, a rand.RandSource + // instance is passed which may be used to generate values randomly without locking. + RandomTick(pos cube.Pos, tx *Tx, r *rand.Rand) +} + +// ScheduledTicker represents a block that executes an action when it has a block update scheduled, such as +// when a block adjacent to it is broken. +type ScheduledTicker interface { + // ScheduledTick handles a scheduled tick initiated by an event in one of the neighbouring blocks, such as + // when a block is placed or broken. Additionally, a rand.RandSource instance is passed which may be used to + // generate values randomly without locking. + ScheduledTick(pos cube.Pos, tx *Tx, r *rand.Rand) +} + +// TickerBlock is an implementation of NBTer with an additional Tick method that is called on every world +// tick for loaded blocks that implement this interface. +type TickerBlock interface { + NBTer + Tick(currentTick int64, pos cube.Pos, tx *Tx) +} + +// NeighbourUpdateTicker represents a block that is updated when a block adjacent to it is updated, either +// through placement or being broken. +type NeighbourUpdateTicker interface { + // NeighbourUpdateTick handles a neighbouring block being updated. The position of that block and the + // position of this block is passed. + NeighbourUpdateTick(pos, changedNeighbour cube.Pos, tx *Tx) +} + +// NBTer represents either an item or a block which may decode NBT data and encode to NBT data. Typically, +// this is done to store additional data. +type NBTer interface { + // DecodeNBT returns the (new) item, block or Entity, depending on which of those the NBTer was, with the NBT data + // decoded into it. + DecodeNBT(data map[string]any) any + // EncodeNBT encodes the Entity into a map which can then be encoded as NBT to be written. + EncodeNBT() map[string]any +} + +// LiquidDisplacer represents a block that is able to displace a liquid to a different world layer, without +// fully removing the liquid. +type LiquidDisplacer interface { + // CanDisplace specifies if the block is able to displace the liquid passed. + CanDisplace(b Liquid) bool + // SideClosed checks if a position on the side of the block placed in the world at a specific position is + // closed. When this returns true (for example, when the side is below the position and the block is a + // slab), liquid inside the displacer won't flow from pos into side. + SideClosed(pos, side cube.Pos, tx *Tx) bool +} + +// lightEmitter is identical to a block.LightEmitter. +type lightEmitter interface { + LightEmissionLevel() uint8 +} + +// lightDiffuser is identical to a block.LightDiffuser. +type lightDiffuser interface { + LightDiffusionLevel() uint8 +} + +// replaceableBlock represents a block that may be replaced by another block automatically. An example is +// grass, which may be replaced by clicking it with another block. +type replaceableBlock interface { + // ReplaceableBy returns a bool which indicates if the block is replaceable by another block. + ReplaceableBy(b Block) bool +} + +// replaceable checks if the block at the position passed is replaceable with the block passed. +func replaceable(w *World, c *Column, pos cube.Pos, with Block) bool { + if r, ok := w.blockInChunk(c, pos).(replaceableBlock); ok { + return r.ReplaceableBy(with) + } + return false +} + +// BlockAction represents an action that may be performed by a block. Typically, these actions are sent to +// viewers in a world so that they can see these actions. +type BlockAction interface { + BlockAction() +} diff --git a/server/world/block_model.go b/server/world/block_model.go new file mode 100644 index 0000000..8c7f17d --- /dev/null +++ b/server/world/block_model.go @@ -0,0 +1,28 @@ +package world + +import ( + "github.com/df-mc/dragonfly/server/block/cube" +) + +// BlockModel represents the model of a block. These models specify the ways a block can be collided with and +// whether specific faces are solid wrt. being able to, for example, place torches onto those sides. +type BlockModel interface { + // BBox returns the bounding boxes that a block with this model can be collided with. + BBox(pos cube.Pos, s BlockSource) []cube.BBox + // FaceSolid checks if a specific face of a block at the position in a world passed is solid. Blocks may + // be attached to these faces. + FaceSolid(pos cube.Pos, face cube.Face, s BlockSource) bool +} + +// unknownModel is the model used for unknown blocks. It is the equivalent of a fully solid model. +type unknownModel struct{} + +// BBox ... +func (u unknownModel) BBox(cube.Pos, BlockSource) []cube.BBox { + return []cube.BBox{cube.Box(0, 0, 0, 1, 1, 1)} +} + +// FaceSolid ... +func (u unknownModel) FaceSolid(cube.Pos, cube.Face, BlockSource) bool { + return true +} diff --git a/server/world/block_registry.go b/server/world/block_registry.go new file mode 100644 index 0000000..0ba3540 --- /dev/null +++ b/server/world/block_registry.go @@ -0,0 +1,541 @@ +package world + +import ( + "fmt" + "maps" + "math" + "math/bits" + "slices" + "sort" + "sync" + + "github.com/brentp/intintmap" + "github.com/df-mc/dragonfly/server/world/chunk" + "github.com/segmentio/fasthash/fnv1" +) + +// DefaultBlockRegistry is the default (vanilla) block registry used by Dragonfly when no custom registry is provided. +// +// The registry is populated during package init (block states + block implementations) and is finalized on first use +// by `server.Config.New()`, `world.Config.New()`, or `mcdb.Config.Open()`. Callers that need custom blocks should +// create a new registry using `NewBlockRegistry()` and pass it through config instead of mutating this value. +var DefaultBlockRegistry = &BasicBlockRegistry{ + blockProperties: make(map[string]map[string]any), + stateRuntimeIDs: make(map[stateHash]uint32), + customBlocks: make(map[string]CustomBlock), +} + +// BlockRegistry converts between runtime IDs and block states/implementations. +// +// A BlockRegistry has a build/finalize lifecycle: +// - During setup, blocks and block states may be registered using RegisterBlockState/RegisterBlock. +// - After calling Finalize, the registry becomes immutable and is ready for use in chunk encoding/decoding and +// network serialization. RegisterBlock/RegisterBlockState will panic after finalization. +// +// The interface is split because the chunk package cannot import world.Block. +type BlockRegistry interface { + chunk.BlockRegistry + + // BlockByRuntimeID looks up a Block by runtime ID. If the runtime ID is unknown/out of range, ok is false. + BlockByRuntimeID(rid uint32) (Block, bool) + // BlockByRuntimeIDOrAir looks up a Block by runtime ID. If not found, an air block is returned. + BlockByRuntimeIDOrAir(rid uint32) Block + // BlockRuntimeID looks up the runtime ID of a previously registered Block. + BlockRuntimeID(block Block) (rid uint32) + // RegisterBlock registers a Block implementation for a previously registered block state. + RegisterBlock(block Block) + // RegisterBlockState registers a block state that blocks may encode to. + RegisterBlockState(blockState BlockState) + // CustomBlocks returns custom blocks registered in this registry, keyed by identifier. + CustomBlocks() map[string]CustomBlock + // BlockByName looks up a Block by full identifier and properties. + BlockByName(name string, properties map[string]any) (Block, bool) + // Blocks returns all blocks registered in the registry, indexed by runtime ID. + Blocks() []Block + // Air returns the air block registered in the registry. + Air() Block + // Finalize finalizes the registry, building derived lookup tables required for runtime usage. Finalize is + // idempotent. + Finalize() + // BitSize returns the number of bits used by BlockHash (depends on the number of registered blocks). + BitSize() int + // BlockHash returns a unique identifier of the block including the block states. The hash is internal to Dragonfly + // and is used for fast map lookups; it does not need to match any in-game identifiers. + BlockHash(b Block) uint64 + // RuntimeIDToHash resolves a runtime ID to its network block hash. + RuntimeIDToHash(runtimeID uint32) (hash uint32, ok bool) +} + +const ( + blockFlagNBT uint16 = 1 << iota + blockFlagRandomTick + blockFlagLiquid + blockFlagLiquidDisplacing +) + +// blockInfo is a packed set of per-runtime-ID properties used in hot paths (lighting, ticking, fluid checks, etc.). +// +// Layout (uint16): +// - bits 0..3: boolean flags (blockFlag*). +// - bits 8..11: 4-bit light emission level (0-15). +// - bits 12..15: 4-bit light filtering level (0-15). +// +// Values are computed during BlockRegistry.Finalize() and stored in BasicBlockRegistry.blockInfos, indexed by runtime ID. +type blockInfo uint16 + +func (b *blockInfo) set(flag uint16) { + *b |= blockInfo(flag) +} + +func (b blockInfo) get(flag uint16) bool { + return uint16(b)&flag != 0 +} + +func (b *blockInfo) setLight(light uint8) { + // Overwrite the 4-bit light emission field. + *b &^= blockInfo(0xF) << 8 + *b |= blockInfo(light&0xF) << 8 +} + +func (b *blockInfo) setLightFilter(light uint8) { + // Overwrite the 4-bit light filtering field. + *b &^= blockInfo(0xF) << 12 + *b |= blockInfo(light&0xF) << 12 +} + +func (b blockInfo) getLight() uint8 { + return uint8((b >> 8) & 0xF) +} + +func (b blockInfo) getLightFilter() uint8 { + return uint8((b >> 12) & 0xF) +} + +// BasicBlockRegistry is the default BlockRegistry implementation used by Dragonfly. +type BasicBlockRegistry struct { + mu sync.Mutex + + finalized bool + bitSize int + hashes *intintmap.Map + networkhashToRids map[uint32]uint32 + ridsToNetworkhash []uint32 + + // stateRuntimeIDs holds a map for looking up the runtime ID of a block by the stateHash it produces. + stateRuntimeIDs map[stateHash]uint32 + + blockProperties map[string]map[string]any + // blocks holds a list of all registered Blocks indexed by their runtime ID. Blocks that were not explicitly + // registered are of the type unknownBlock. + blocks []Block + // customBlocks maps a custom block's identifier to the custom block. + customBlocks map[string]CustomBlock + + blockInfos []blockInfo + + airRID uint32 +} + +func (br *BasicBlockRegistry) BitSize() int { + if !br.finalized { + panic("BlockRegistry.BitSize called on non finalized BlockRegistry") + } + return br.bitSize +} + +func (br *BasicBlockRegistry) BlockCount() int { + if !br.finalized { + panic("BlockRegistry.BlockCount called on non finalized BlockRegistry") + } + return len(br.blockInfos) +} + +func (br *BasicBlockRegistry) RandomTickBlock(rid uint32) bool { + if !br.finalized { + panic("BlockRegistry.RandomTickBlock called on non finalized BlockRegistry") + } + return br.blockInfos[rid].get(blockFlagRandomTick) +} + +func (br *BasicBlockRegistry) FilteringBlock(rid uint32) uint8 { + if !br.finalized { + panic("BlockRegistry.FilteringBlock called on non finalized BlockRegistry") + } + return br.blockInfos[rid].getLightFilter() +} + +func (br *BasicBlockRegistry) LightBlock(rid uint32) uint8 { + if !br.finalized { + panic("BlockRegistry.LightBlock called on non finalized BlockRegistry") + } + return br.blockInfos[rid].getLight() +} + +func (br *BasicBlockRegistry) NBTBlock(rid uint32) bool { + if !br.finalized { + panic("BlockRegistry.NBTBlock called on non finalized BlockRegistry") + } + return br.blockInfos[rid].get(blockFlagNBT) +} + +func (br *BasicBlockRegistry) LiquidDisplacingBlock(rid uint32) bool { + if !br.finalized { + panic("BlockRegistry.LiquidDisplacingBlock called on non finalized BlockRegistry") + } + return br.blockInfos[rid].get(blockFlagLiquidDisplacing) +} + +func (br *BasicBlockRegistry) LiquidBlock(rid uint32) bool { + if !br.finalized { + panic("BlockRegistry.LiquidBlock called on non finalized BlockRegistry") + } + return br.blockInfos[rid].get(blockFlagLiquid) +} + +func (br *BasicBlockRegistry) Blocks() []Block { + if !br.finalized { + panic("BlockRegistry.Blocks called on non finalized BlockRegistry") + } + return slices.Clone(br.blocks) +} + +func (br *BasicBlockRegistry) HashToRuntimeID(hash uint32) (rid uint32, ok bool) { + if !br.finalized { + panic("BlockRegistry.HashToRuntimeID called on non finalized BlockRegistry") + } + rid, ok = br.networkhashToRids[hash] + return +} + +func (br *BasicBlockRegistry) RuntimeIDToHash(runtimeID uint32) (hash uint32, ok bool) { + if !br.finalized { + panic("BlockRegistry.RuntimeIDToHash called on non finalized BlockRegistry") + } + if runtimeID >= uint32(len(br.ridsToNetworkhash)) { + return 0, false + } + return br.ridsToNetworkhash[runtimeID], true +} + +// Clone returns an independent copy of the registry. If the source registry is finalized, the clone is also finalized. +// If the source is not finalized, the clone remains mutable. +func (br *BasicBlockRegistry) Clone() *BasicBlockRegistry { + br.mu.Lock() + defer br.mu.Unlock() + + br2 := &BasicBlockRegistry{ + blockProperties: make(map[string]map[string]any, len(br.blockProperties)), + stateRuntimeIDs: make(map[stateHash]uint32, len(br.stateRuntimeIDs)), + customBlocks: make(map[string]CustomBlock, len(br.customBlocks)), + finalized: br.finalized, + bitSize: br.bitSize, + airRID: br.airRID, + } + + for k, v := range br.blockProperties { + br2.blockProperties[k] = maps.Clone(v) + } + maps.Copy(br2.stateRuntimeIDs, br.stateRuntimeIDs) + maps.Copy(br2.customBlocks, br.customBlocks) + + br2.blocks = make([]Block, len(br.blocks)) + copy(br2.blocks, br.blocks) + br2.blockInfos = append([]blockInfo(nil), br.blockInfos...) + + if br.finalized { + br2.hashes = intintmap.New(len(br.blocks), 0.999) + for rid, b := range br2.blocks { + if _, hash := b.Hash(); hash == math.MaxUint64 { + continue + } + br2.hashes.Put(int64(br2.BlockHash(b)), int64(rid)) + } + br2.networkhashToRids = make(map[uint32]uint32, len(br.networkhashToRids)) + maps.Copy(br2.networkhashToRids, br.networkhashToRids) + br2.ridsToNetworkhash = append([]uint32(nil), br.ridsToNetworkhash...) + } + + return br2 +} + +// NewBlockRegistry returns a mutable registry seeded with all vanilla block states and block implementations. +// Callers may RegisterBlockState/RegisterBlock and must call Finalize() before using the registry for world/chunk +// serialization. The returned registry is independent from DefaultBlockRegistry. +func NewBlockRegistry() BlockRegistry { + // Clone() produces a fully populated registry. We then mark it mutable again so that additional block states and + // blocks can be registered before finalizing. + br := DefaultBlockRegistry.Clone() + br.finalized = false + br.bitSize = 0 + br.hashes = nil + br.networkhashToRids = nil + br.ridsToNetworkhash = nil + br.blockInfos = nil + return br +} + +// RegisterBlock registers the Block passed. The EncodeBlock method will be used to encode and decode the +// block passed. RegisterBlock panics if the block properties returned were not valid, existing properties. +func (br *BasicBlockRegistry) RegisterBlock(b Block) { + br.mu.Lock() + defer br.mu.Unlock() + + if br.finalized { + panic("BlockRegistry.RegisterBlock called on finalized BlockRegistry") + } + name, properties := b.EncodeBlock() + if _, ok := b.(CustomBlock); ok { + br.registerBlockStateLocked(BlockState{Name: name, Properties: properties}) + } + rid, ok := br.stateRuntimeIDs[stateHash{name: name, properties: hashProperties(properties)}] + if !ok { + // We assume all blocks must have all their states registered beforehand. Vanilla blocks will have + // this done through registering of all states present in the block_states.nbt file. + panic(fmt.Sprintf("block state returned is not registered (%v {%#v})", name, properties)) + } + if _, ok := br.blocks[rid].(unknownBlock); !ok { + panic(fmt.Sprintf("block with name and properties %v {%#v} already registered", name, properties)) + } + br.blocks[rid] = b + if c, ok := b.(CustomBlock); ok { + if _, ok := br.customBlocks[name]; !ok { + br.customBlocks[name] = c + } + } +} + +// RegisterBlockState registers a BlockState to the registry. The function panics if the properties the +// BlockState holds are invalid or if the BlockState was already registered. +func (br *BasicBlockRegistry) RegisterBlockState(s BlockState) { + br.mu.Lock() + defer br.mu.Unlock() + br.registerBlockStateLocked(s) +} + +// registerBlockStateLocked is the implementation of RegisterBlockState. br.mu must be held by the caller. +func (br *BasicBlockRegistry) registerBlockStateLocked(s BlockState) { + if br.finalized { + panic("BlockRegistry.RegisterBlockState called on finalized BlockRegistry") + } + h := stateHash{name: s.Name, properties: hashProperties(s.Properties)} + if _, ok := br.stateRuntimeIDs[h]; ok { + panic(fmt.Sprintf("cannot register the same state twice (%+v)", s)) + } + if _, ok := br.blockProperties[s.Name]; !ok { + br.blockProperties[s.Name] = s.Properties + } + rid := uint32(len(br.blocks)) + br.blocks = append(br.blocks, unknownBlock{BlockState: s}) + br.stateRuntimeIDs[h] = rid +} + +func (br *BasicBlockRegistry) Finalize() { + br.mu.Lock() + defer br.mu.Unlock() + + if br.finalized { + // Finalize is intentionally idempotent so code can defensively call it when a registry is provided through + // config. RegisterBlock/RegisterBlockState still panic after finalization. + return + } + + br.bitSize = bits.Len64(uint64(len(br.blocks))) + sort.SliceStable(br.blocks, func(i, j int) bool { + var nameOne string + if b1, ok := br.blocks[i].(unknownBlock); ok { + nameOne = b1.Name + } else { + nameOne, _ = br.blocks[i].EncodeBlock() + } + var nameTwo string + if b2, ok := br.blocks[j].(unknownBlock); ok { + nameTwo = b2.Name + } else { + nameTwo, _ = br.blocks[j].EncodeBlock() + } + return fnv1.HashString64(nameOne) < fnv1.HashString64(nameTwo) + }) + + br.blockInfos = make([]blockInfo, len(br.blocks)) + br.hashes = intintmap.New(len(br.blocks), 0.999) + br.networkhashToRids = make(map[uint32]uint32, len(br.blocks)) + br.ridsToNetworkhash = make([]uint32, len(br.blocks)) + br.stateRuntimeIDs = make(map[stateHash]uint32, len(br.blocks)) + networkHashScratch := make([]byte, 0, 0xff) + foundAir := false + + for idx, b := range br.blocks { + rid := uint32(idx) + name, properties := b.EncodeBlock() + h := stateHash{name: name, properties: hashProperties(properties)} + if name == "minecraft:air" { + br.airRID = rid + foundAir = true + } + if _, ok := br.stateRuntimeIDs[h]; ok { + panic(fmt.Sprintf("cannot register the same state twice (%s %+v)", name, properties)) + } + br.stateRuntimeIDs[h] = rid + + var info blockInfo + // Default to fully opaque. Blocks that implement lightDiffuser may override this (e.g., air -> 0, leaves -> 1-14). + info.setLightFilter(15) + if diffuser, ok := b.(lightDiffuser); ok { + info.setLightFilter(diffuser.LightDiffusionLevel()) + } + if emitter, ok := b.(lightEmitter); ok { + info.setLight(emitter.LightEmissionLevel()) + } + if _, ok := b.(NBTer); ok { + info.set(blockFlagNBT) + } + if _, ok := b.(RandomTicker); ok { + info.set(blockFlagRandomTick) + } + if _, ok := b.(Liquid); ok { + info.set(blockFlagLiquid) + } + if _, ok := b.(LiquidDisplacer); ok { + info.set(blockFlagLiquidDisplacing) + } + br.blockInfos[rid] = info + + if _, hash := b.Hash(); hash != math.MaxUint64 { + h := int64(br.BlockHash(b)) + if other, ok := br.hashes.Get(h); ok { + panic(fmt.Sprintf("block %#v with hash %v already registered by %#v", b, h, br.blocks[other])) + } + br.hashes.Put(h, int64(rid)) + } + var netHash uint32 + netHash, networkHashScratch = networkBlockHash(name, properties, networkHashScratch) + if other, ok := br.networkhashToRids[netHash]; ok { + otherName, otherProperties := br.blocks[other].EncodeBlock() + panic(fmt.Sprintf("network block hash collision for (%s %+v) and (%s %+v)", name, properties, otherName, otherProperties)) + } + br.networkhashToRids[netHash] = rid + br.ridsToNetworkhash[rid] = netHash + } + if !foundAir { + panic("BlockRegistry.Finalize: no minecraft:air block state registered") + } + br.finalized = true +} + +// AirRuntimeID returns the runtime ID of the air block. +func (br *BasicBlockRegistry) AirRuntimeID() uint32 { + if !br.finalized { + panic("BlockRegistry.AirRuntimeID called on non finalized BlockRegistry") + } + return br.airRID +} + +// RuntimeIDToState returns the name and state properties of a block by its runtime ID. +func (br *BasicBlockRegistry) RuntimeIDToState(runtimeID uint32) (name string, properties map[string]any, found bool) { + if !br.finalized { + panic("BlockRegistry.RuntimeIDToState called on non finalized BlockRegistry") + } + if runtimeID >= uint32(len(br.blocks)) { + return "", nil, false + } + name, properties = br.blocks[runtimeID].EncodeBlock() + return name, properties, true +} + +// StateToRuntimeID returns the runtime ID of a block by its name and state properties. +func (br *BasicBlockRegistry) StateToRuntimeID(name string, properties map[string]any) (runtimeID uint32, found bool) { + if !br.finalized { + panic("BlockRegistry.StateToRuntimeID called on non finalized BlockRegistry") + } + if rid, ok := br.stateRuntimeIDs[stateHash{name: name, properties: hashProperties(properties)}]; ok { + return rid, true + } + rid, ok := br.stateRuntimeIDs[stateHash{name: name, properties: hashProperties(br.blockProperties[name])}] + return rid, ok +} + +// BlockHash returns a unique identifier of the block including the block states. This function is used internally +// to convert a block to a single integer which can be used in map lookups. The hash produced therefore does not +// need to match anything in the game, but it must be unique among all registered blocks. +// The tool in `/cmd/blockhash` may be used to automatically generate block hashes of blocks in a package. +func (br *BasicBlockRegistry) BlockHash(b Block) uint64 { + base, hash := b.Hash() + return base | (hash << uint64(br.bitSize)) +} + +// BlockRuntimeID attempts to return a runtime ID of a block previously registered using RegisterBlock(). +// If the runtime ID cannot be found because the Block wasn't registered, BlockRuntimeID will panic. +func (br *BasicBlockRegistry) BlockRuntimeID(b Block) uint32 { + if !br.finalized { + panic("BlockRegistry.BlockRuntimeID called on non finalized BlockRegistry") + } + if b == nil { + return br.airRID + } + if _, hash := b.Hash(); hash != math.MaxUint64 { + // b is not an unknownBlock. + if rid, ok := br.hashes.Get(int64(br.BlockHash(b))); ok { + return uint32(rid) + } + panic(fmt.Sprintf("cannot find block by non-0 hash of block %#v", b)) + } + return br.slowBlockRuntimeID(b) +} + +func (br *BasicBlockRegistry) BlockByRuntimeIDOrAir(rid uint32) Block { + bl, _ := br.BlockByRuntimeID(rid) + return bl +} + +// slowBlockRuntimeID finds the runtime ID of a Block by hashing the properties produced by calling the +// Block.EncodeBlock method and looking it up in the stateRuntimeIDs map. +func (br *BasicBlockRegistry) slowBlockRuntimeID(b Block) uint32 { + name, properties := b.EncodeBlock() + rid, ok := br.stateRuntimeIDs[stateHash{name: name, properties: hashProperties(properties)}] + if !ok { + panic(fmt.Sprintf("cannot find block by (name + properties): %#v", b)) + } + return rid +} + +// BlockByRuntimeID attempts to return a Block by its runtime ID. If not found, the bool returned is +// false. If found, the block is non-nil and the bool true. +func (br *BasicBlockRegistry) BlockByRuntimeID(rid uint32) (Block, bool) { + if !br.finalized { + panic("BlockRegistry.BlockByRuntimeID called on non finalized BlockRegistry") + } + if rid >= uint32(len(br.blocks)) { + return br.Air(), false + } + return br.blocks[rid], true +} + +// BlockByName attempts to return a Block by its name and properties. If not found, the bool returned is +// false. +func (br *BasicBlockRegistry) BlockByName(name string, properties map[string]any) (Block, bool) { + if !br.finalized { + panic("BlockRegistry.BlockByName called on non finalized BlockRegistry") + } + rid, ok := br.stateRuntimeIDs[stateHash{name: name, properties: hashProperties(properties)}] + if !ok { + return nil, false + } + return br.blocks[rid], true +} + +// CustomBlocks returns a map of all custom blocks registered with their names as keys. +func (br *BasicBlockRegistry) CustomBlocks() map[string]CustomBlock { + return maps.Clone(br.customBlocks) +} + +// Air returns an air block. +func (br *BasicBlockRegistry) Air() Block { + if !br.finalized { + panic("BlockRegistry.Air called on non finalized BlockRegistry") + } + if br.airRID >= uint32(len(br.blocks)) { + // This should never happen for a valid registry (Finalize enforces air exists). + panic("BlockRegistry.Air: air runtime ID out of range") + } + return br.blocks[br.airRID] +} diff --git a/server/world/block_source.go b/server/world/block_source.go new file mode 100644 index 0000000..42d8fe5 --- /dev/null +++ b/server/world/block_source.go @@ -0,0 +1,14 @@ +package world + +import "github.com/df-mc/dragonfly/server/block/cube" + +// BlockSource represents a source for obtaining blocks. +type BlockSource interface { + // Block returns the block at the given position in the block source. + Block(cube.Pos) Block +} + +// worldSource is a wrapper around a World that implements BlockSource. +type worldSource struct{ w *World } + +func (w worldSource) Block(pos cube.Pos) Block { return w.w.block(pos) } diff --git a/server/world/block_state.go b/server/world/block_state.go new file mode 100644 index 0000000..a58c56a --- /dev/null +++ b/server/world/block_state.go @@ -0,0 +1,116 @@ +package world + +import ( + "bytes" + _ "embed" + "fmt" + "maps" + "math" + "slices" + "strings" + "unsafe" + + "github.com/sandertv/gophertunnel/minecraft/nbt" +) + +var ( + //go:embed block_states.nbt + blockStateData []byte +) + +func init() { + dec := nbt.NewDecoder(bytes.NewBuffer(blockStateData)) + + // Register all block states present in the block_states.nbt file. These are all possible options registered + // blocks may encode to. + for { + var s BlockState + if err := dec.Decode(&s); err != nil { + break + } + DefaultBlockRegistry.RegisterBlockState(s) + } +} + +// BlockState holds a combination of a name and properties, together with a version. +type BlockState struct { + Name string `nbt:"name"` + Properties map[string]any `nbt:"states"` + Version int32 `nbt:"version"` +} + +// unknownBlock represents a block that has not yet been implemented. It is used for registering block +// states that haven't yet been added. +type unknownBlock struct { + BlockState + data map[string]any +} + +// EncodeBlock ... +func (b unknownBlock) EncodeBlock() (string, map[string]any) { + return b.Name, b.Properties +} + +// Model ... +func (unknownBlock) Model() BlockModel { + return unknownModel{} +} + +// Hash ... +func (b unknownBlock) Hash() (uint64, uint64) { + return 0, math.MaxUint64 +} + +// EncodeNBT ... +func (b unknownBlock) EncodeNBT() map[string]any { + return maps.Clone(b.data) +} + +// DecodeNBT ... +func (b unknownBlock) DecodeNBT(data map[string]any) any { + b.data = maps.Clone(data) + return b +} + +// stateHash is a struct that may be used as a map key for block states. It contains the name of the block state +// and an encoded version of the properties. +type stateHash struct { + name, properties string +} + +// hashProperties produces a hash for the block properties held by the BlockState. +func hashProperties(properties map[string]any) string { + if properties == nil { + return "" + } + keys := make([]string, 0, len(properties)) + for k := range properties { + keys = append(keys, k) + } + slices.Sort(keys) + + var b strings.Builder + for _, k := range keys { + switch v := properties[k].(type) { + case bool: + if v { + b.WriteByte(1) + } else { + b.WriteByte(0) + } + case uint8: + b.WriteByte(v) + case int32: + a := *(*[4]byte)(unsafe.Pointer(&v)) + b.Write(a[:]) + case string: + b.WriteString(v) + default: + // If block encoding is broken, we want to find out as soon as possible. This saves a lot of time + // debugging in-game. + panic(fmt.Sprintf("invalid block property type %T for property %v", v, k)) + } + } + + return b.String() +} diff --git a/server/world/block_states.nbt b/server/world/block_states.nbt new file mode 100644 index 0000000..1e4d946 Binary files /dev/null and b/server/world/block_states.nbt differ diff --git a/server/world/chunk/block_registry.go b/server/world/chunk/block_registry.go new file mode 100644 index 0000000..24888d2 --- /dev/null +++ b/server/world/chunk/block_registry.go @@ -0,0 +1,32 @@ +package chunk + +// BlockRegistry provides the minimal block registry API required by the chunk package. +// +// Implementations must be safe for concurrent read access after construction/finalization, as chunks and chunk +// encoding/decoding may happen from multiple goroutines. +type BlockRegistry interface { + // BlockCount returns the number of runtime IDs known by the registry. + BlockCount() int + // AirRuntimeID returns the runtime ID representing air. + AirRuntimeID() uint32 + // RuntimeIDToState resolves a runtime ID to its (name, properties) state representation. + RuntimeIDToState(runtimeID uint32) (name string, properties map[string]any, found bool) + // StateToRuntimeID resolves a (name, properties) state representation to a runtime ID. + StateToRuntimeID(name string, properties map[string]any) (runtimeID uint32, found bool) + // FilteringBlock returns the light filtering value for the runtime ID. + FilteringBlock(rid uint32) uint8 + // LightBlock returns the light emission value for the runtime ID. + LightBlock(rid uint32) uint8 + // RandomTickBlock reports whether the runtime ID receives random ticks. + RandomTickBlock(rid uint32) bool + // NBTBlock reports whether the runtime ID uses NBT data and requires NBT-aware encoding/decoding. + NBTBlock(rid uint32) bool + // LiquidDisplacingBlock reports whether the runtime ID displaces liquids. + LiquidDisplacingBlock(rid uint32) bool + // LiquidBlock reports whether the runtime ID represents a liquid block. + 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 new file mode 100644 index 0000000..ddee338 --- /dev/null +++ b/server/world/chunk/chunk.go @@ -0,0 +1,245 @@ +package chunk + +import ( + "slices" + + "github.com/df-mc/dragonfly/server/block/cube" +) + +// Chunk is a segment in the world with a size of 16x16x256 blocks. A chunk contains multiple sub chunks +// and stores other information such as biomes. +// It is not safe to call methods on Chunk simultaneously from multiple goroutines. +type Chunk struct { + // r holds the (vertical) range of the Chunk. It includes both the minimum and maximum coordinates. + r cube.Range + // br is the block registry used for this chunk. + br BlockRegistry + // air is the runtime ID of air. + air uint32 + // recalculateHeightMap is true if the chunk's height map should be recalculated on the next call to the HeightMap + // function. + recalculateHeightMap bool + // heightMap is the height map of the chunk. + heightMap HeightMap + // sub holds all sub chunks part of the chunk. The pointers held by the array are nil if no sub chunk is + // allocated at the indices. + sub []*SubChunk + // biomes is an array of biome IDs. There is one biome ID for every column in the chunk. + biomes []*PalettedStorage +} + +// New initialises a new chunk and returns it, so that it may be used. +// The BlockRegistry passed must be finalized and must correspond to the runtime IDs used in the chunk's storages. +func New(br BlockRegistry, r cube.Range) *Chunk { + n := (r.Height() >> 4) + 1 + sub, biomes := make([]*SubChunk, n), make([]*PalettedStorage, n) + air := br.AirRuntimeID() + for i := 0; i < n; i++ { + sub[i] = NewSubChunk(air) + biomes[i] = emptyStorage(0) + } + return &Chunk{ + r: r, + br: br, + air: air, + sub: sub, + biomes: biomes, + recalculateHeightMap: true, + heightMap: make(HeightMap, 256), + } +} + +// 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) { + return false + } + + if c.r != chunk.r || c.air != chunk.air || len(c.sub) != len(chunk.sub) { + return false + } + + for i, s := range c.sub { + if !s.Equals(chunk.sub[i]) { + return false + } + } + + return true +} + +// Range returns the cube.Range of the Chunk as passed to New. +func (chunk *Chunk) Range() cube.Range { + return chunk.r +} + +// Sub returns a list of all sub chunks present in the chunk. +func (chunk *Chunk) Sub() []*SubChunk { + return chunk.sub +} + +// Block returns the runtime ID of the block at a given x, y and z in a chunk at the given layer. If no +// sub chunk exists at the given y, the block is assumed to be air. +func (chunk *Chunk) Block(x uint8, y int16, z uint8, layer uint8) uint32 { + sub := chunk.SubChunk(y) + if sub.Empty() || uint8(len(sub.storages)) <= layer { + return chunk.air + } + return sub.storages[layer].At(x, uint8(y), z) +} + +// SetBlock sets the runtime ID of a block at a given x, y and z in a chunk at the given layer. If no +// SubChunk exists at the given y, a new SubChunk is created and the block is set. +func (chunk *Chunk) SetBlock(x uint8, y int16, z uint8, layer uint8, block uint32) { + sub := chunk.sub[chunk.SubIndex(y)] + if uint8(len(sub.storages)) <= layer && block == chunk.air { + // Air was set at n layer, but there were less than n layers, so there already was air there. + // Don't do anything with this, just return. + return + } + sub.Layer(layer).Set(x, uint8(y), z, block) + chunk.recalculateHeightMap = true +} + +// Biome returns the biome ID at a specific column in the chunk. +func (chunk *Chunk) Biome(x uint8, y int16, z uint8) uint32 { + return chunk.biomes[chunk.SubIndex(y)].At(x, uint8(y), z) +} + +// SetBiome sets the biome ID at a specific column in the chunk. +func (chunk *Chunk) SetBiome(x uint8, y int16, z uint8, biome uint32) { + chunk.biomes[chunk.SubIndex(y)].Set(x, uint8(y), z, biome) +} + +// Light returns the light level at a specific position in the chunk. +func (chunk *Chunk) Light(x uint8, y int16, z uint8) uint8 { + ux, uy, uz, sub := x&0xf, uint8(y&0xf), z&0xf, chunk.SubChunk(y) + sky := sub.SkyLight(ux, uy, uz) + if sky == 15 { + // The skylight was already on the maximum value, so return it without checking block light. + return sky + } + if block := sub.BlockLight(ux, uy, uz); block > sky { + return block + } + return sky +} + +// SkyLight returns the skylight level at a specific position in the chunk. +func (chunk *Chunk) SkyLight(x uint8, y int16, z uint8) uint8 { + return chunk.SubChunk(y).SkyLight(x&15, uint8(y&15), z&15) +} + +// HighestLightBlocker iterates from the highest non-empty sub chunk downwards to find the Y value of the +// highest block that completely blocks any light from going through. If none is found, the value returned is +// the minimum height. +func (chunk *Chunk) HighestLightBlocker(x, z uint8) int16 { + return chunk.highestLightBlocker(x, z, false) +} + +// highestLightBlocker iterates from the highest non-empty sub chunk downwards +// to find the Y value of the highest block that completely blocks any light +// from going through. If none is found, the value returned is the minimum +// height. If addOne is true, one is added to the Y returned if a block was +// found. +func (chunk *Chunk) highestLightBlocker(x, z uint8, addOne bool) int16 { + var plus int16 + if addOne { + plus++ + } + for index := int16(len(chunk.sub) - 1); index >= 0; index-- { + if sub := chunk.sub[index]; !sub.Empty() { + for y := 15; y >= 0; y-- { + if chunk.br.FilteringBlock(sub.storages[0].At(x, uint8(y), z)) == 15 { + return int16(y) | chunk.SubY(index) + plus + } + } + } + } + return int16(chunk.r[0]) +} + +// HighestBlock iterates from the highest non-empty sub chunk downwards to find the Y value of the highest +// non-air block at an x and z. If no blocks are present in the column, the minimum height is returned. +func (chunk *Chunk) HighestBlock(x, z uint8) int16 { + for index := int16(len(chunk.sub) - 1); index >= 0; index-- { + if sub := chunk.sub[index]; !sub.Empty() { + for y := 15; y >= 0; y-- { + if rid := sub.storages[0].At(x, uint8(y), z); rid != chunk.air { + return int16(y) | chunk.SubY(index) + } + } + } + } + return int16(chunk.r[0]) +} + +// HeightMap returns the height map of the chunk. If the chunk is edited, the height map will be recalculated on the +// next call to this function. +func (chunk *Chunk) HeightMap() HeightMap { + if chunk.recalculateHeightMap { + for x := uint8(0); x < 16; x++ { + for z := uint8(0); z < 16; z++ { + chunk.heightMap.Set(x, z, chunk.highestLightBlocker(x, z, true)) + } + } + chunk.recalculateHeightMap = false + } + return chunk.heightMap +} + +// Compact compacts the chunk as much as possible, getting rid of any sub chunks that are empty, and compacts +// all storages in the sub chunks to occupy as little space as possible. +// Compact should be called right before the chunk is saved in order to optimise the storage space. +func (chunk *Chunk) Compact() { + for i := range chunk.sub { + chunk.sub[i].compact() + } +} + +// SubChunk finds the correct SubChunk in the Chunk by a Y value. +func (chunk *Chunk) SubChunk(y int16) *SubChunk { + return chunk.sub[chunk.SubIndex(y)] +} + +// SubIndex returns the sub chunk Y index matching the y value passed. +func (chunk *Chunk) SubIndex(y int16) int16 { + return (y - int16(chunk.r[0])) >> 4 +} + +// SubY returns the sub chunk Y value matching the index passed. +func (chunk *Chunk) SubY(index int16) int16 { + return (index << 4) + int16(chunk.r[0]) +} + +// 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 { + for i, sub := range slices.Backward(chunk.sub) { + if !sub.Empty() { + return uint16(i + 1) + } + } + return 0 +} diff --git a/server/world/chunk/col.go b/server/world/chunk/col.go new file mode 100644 index 0000000..b6fe4d8 --- /dev/null +++ b/server/world/chunk/col.go @@ -0,0 +1,29 @@ +package chunk + +import ( + "github.com/df-mc/dragonfly/server/block/cube" +) + +type Column struct { + Chunk *Chunk + Entities []Entity + BlockEntities []BlockEntity + Tick int64 + ScheduledBlocks []ScheduledBlockUpdate +} + +type BlockEntity struct { + Pos cube.Pos + Data map[string]any +} + +type Entity struct { + ID int64 + Data map[string]any +} + +type ScheduledBlockUpdate struct { + Pos cube.Pos + Block uint32 + Tick int64 +} diff --git a/server/world/chunk/conversion.go b/server/world/chunk/conversion.go new file mode 100644 index 0000000..9852d49 --- /dev/null +++ b/server/world/chunk/conversion.go @@ -0,0 +1,45 @@ +package chunk + +import ( + "bytes" + _ "embed" + "github.com/sandertv/gophertunnel/minecraft/nbt" +) + +// legacyBlockEntry represents a block entry used in versions prior to 1.13. +type legacyBlockEntry struct { + Name string `nbt:"name"` + Meta int16 `nbt:"meta"` +} + +var ( + //go:embed legacy_states.nbt + legacyMappingsData []byte + // legacyMappings allows simple conversion from a legacy block entry to a new one. + legacyMappings = make(map[legacyBlockEntry]blockEntry) +) + +// upgradeLegacyEntry upgrades a legacy block entry to a new one. +func upgradeLegacyEntry(name string, meta int16) (blockEntry, bool) { + entry, ok := legacyMappings[legacyBlockEntry{Name: name, Meta: meta}] + if !ok { + // Also try cases where the meta should be disregarded. + entry, ok = legacyMappings[legacyBlockEntry{Name: name}] + } + return entry, ok +} + +// init creates conversions for each legacy and alias entry. +func init() { + var entry struct { + Legacy legacyBlockEntry `nbt:"legacy"` + Updated blockEntry `nbt:"updated"` + } + dec := nbt.NewDecoder(bytes.NewBuffer(legacyMappingsData)) + for { + if err := dec.Decode(&entry); err != nil { + break + } + legacyMappings[entry.Legacy] = entry.Updated + } +} diff --git a/server/world/chunk/decode.go b/server/world/chunk/decode.go new file mode 100644 index 0000000..d235517 --- /dev/null +++ b/server/world/chunk/decode.go @@ -0,0 +1,187 @@ +package chunk + +import ( + "bytes" + "fmt" + + "github.com/df-mc/dragonfly/server/block/cube" +) + +// NetworkDecode decodes the network serialised data passed into a Chunk if successful. If not, the chunk +// returned is nil and the error non-nil. +// The sub chunk count passed must be that found in the LevelChunk packet. +// NetworkDecode creates a new buffer and calls NetworkDecodeBuffer. +// +// The BlockRegistry passed must be finalized and must correspond to the runtime IDs used in the chunk data. +// noinspection GoUnusedExportedFunction +func NetworkDecode(br BlockRegistry, data []byte, count int, r cube.Range) (*Chunk, error) { + return NetworkDecodeBuffer(br, bytes.NewBuffer(data), count, r) +} + +// NetworkDecodeBuffer decodes the network serialised data from buf passed into a Chunk if successful. If not, the chunk +// returned is nil and the error non-nil. +// The sub chunk count passed must be that found in the LevelChunk packet. +// noinspection GoUnusedExportedFunction +func NetworkDecodeBuffer(br BlockRegistry, buf *bytes.Buffer, count int, r cube.Range) (*Chunk, error) { + var ( + c = New(br, r) + err error + ) + for i := 0; i < count; i++ { + index := uint8(i) + c.sub[index], err = decodeSubChunk(buf, c, &index, NetworkEncoding) + if err != nil { + return nil, err + } + } + var last *PalettedStorage + for i := 0; i < len(c.sub); i++ { + b, err := decodePalettedStorage(buf, NetworkEncoding, BiomePaletteEncoding) + if err != nil { + return nil, err + } + if b == nil { + // b == nil means this paletted storage had the flag pointing to the previous one. It basically means we should + // inherit whatever palette we decoded last. + if i == 0 { + // This should never happen and there is no way to handle this. + return nil, fmt.Errorf("first biome storage pointed to previous one") + } + b = last + } else { + last = b + } + c.biomes[i] = b + } + return c, nil +} + +// DiskDecode decodes the data from a SerialisedData object into a chunk and returns it. If the data was invalid, +// an error is returned. +// +// The BlockRegistry passed must be finalized and must correspond to the runtime IDs used in the chunk data. +func DiskDecode(br BlockRegistry, data SerialisedData, r cube.Range) (*Chunk, error) { + c := New(br, r) + + err := decodeBiomes(bytes.NewBuffer(data.Biomes), c, DiskEncoding) + if err != nil { + return nil, err + } + for i, sub := range data.SubChunks { + if len(sub) == 0 { + // No data for this sub chunk. + continue + } + index := uint8(i) + if c.sub[index], err = decodeSubChunk(bytes.NewBuffer(sub), c, &index, DiskEncoding); err != nil { + return nil, err + } + } + return c, nil +} + +// decodeSubChunk decodes a SubChunk from a bytes.Buffer. The Encoding passed defines how the block storages of the +// SubChunk are decoded. +func decodeSubChunk(buf *bytes.Buffer, c *Chunk, index *byte, e Encoding) (*SubChunk, error) { + ver, err := buf.ReadByte() + if err != nil { + return nil, fmt.Errorf("error reading version: %w", err) + } + sub := NewSubChunk(c.air) + switch ver { + default: + return nil, fmt.Errorf("unknown sub chunk version %v: can't decode", ver) + case 1: + // Version 1 only has one layer for each sub chunk, but uses the format with palettes. + storage, err := decodePalettedStorage(buf, e, BlockPaletteEncoding{Blocks: c.br}) + if err != nil { + return nil, err + } + sub.storages = append(sub.storages, storage) + case 8, 9: + // Version 8 allows up to 256 layers for one sub chunk. + storageCount, err := buf.ReadByte() + if err != nil { + return nil, fmt.Errorf("error reading storage count: %w", err) + } + if ver == 9 { + uIndex, err := buf.ReadByte() + if err != nil { + return nil, fmt.Errorf("error reading sub-chunk index: %w", err) + } + // The index as written here isn't the actual index of the sub-chunk within the chunk. Rather, it is the Y + // value of the sub-chunk. This means that we need to translate it to an index. + *index = uint8(int8(uIndex) - int8(c.r[0]>>4)) + } + sub.storages = make([]*PalettedStorage, storageCount) + + for i := byte(0); i < storageCount; i++ { + sub.storages[i], err = decodePalettedStorage(buf, e, BlockPaletteEncoding{Blocks: c.br}) + if err != nil { + return nil, err + } + } + } + return sub, nil +} + +// decodeBiomes reads the paletted storages holding biomes from buf and stores it into the Chunk passed. +func decodeBiomes(buf *bytes.Buffer, c *Chunk, e Encoding) error { + var last *PalettedStorage + if buf.Len() != 0 { + for i := 0; i < len(c.sub); i++ { + b, err := decodePalettedStorage(buf, e, BiomePaletteEncoding) + if err != nil { + return err + } + // b == nil means this paletted storage had the flag pointing to the previous one. It basically means we should + // inherit whatever palette we decoded last. + if i == 0 && b == nil { + // This should never happen and there is no way to handle this. + return fmt.Errorf("first biome storage pointed to previous one") + } + if b == nil { + // This means this paletted storage had the flag pointing to the previous one. It basically means we should + // inherit whatever palette we decoded last. + b = last + } else { + last = b + } + c.biomes[i] = b + } + } + return nil +} + +// decodePalettedStorage decodes a PalettedStorage from a bytes.Buffer. The Encoding passed is used to read either a +// network or disk block storage. +func decodePalettedStorage(buf *bytes.Buffer, e Encoding, pe paletteEncoding) (*PalettedStorage, error) { + blockSize, err := buf.ReadByte() + if err != nil { + return nil, fmt.Errorf("error reading block size: %w", err) + } + blockSize >>= 1 + if blockSize == 0x7f { + return nil, nil + } + + size := paletteSize(blockSize) + if size > 32 { + return nil, fmt.Errorf("cannot read paletted storage (size=%v) %T: size too large", blockSize, pe) + } + uint32Count := size.uint32s() + + uint32s := make([]uint32, uint32Count) + byteCount := uint32Count * 4 + + data := buf.Next(byteCount) + if len(data) != byteCount { + return nil, fmt.Errorf("cannot read paletted storage (size=%v) %T: not enough block data present: expected %v bytes, got %v", blockSize, pe, byteCount, len(data)) + } + for i := 0; i < uint32Count; i++ { + // Explicitly don't use the binary package to greatly improve performance of reading the uint32s. + uint32s[i] = uint32(data[i*4]) | uint32(data[i*4+1])<<8 | uint32(data[i*4+2])<<16 | uint32(data[i*4+3])<<24 + } + p, err := e.decodePalette(buf, paletteSize(blockSize), pe) + return newPalettedStorage(uint32s, p), err +} diff --git a/server/world/chunk/encode.go b/server/world/chunk/encode.go new file mode 100644 index 0000000..0815ae6 --- /dev/null +++ b/server/world/chunk/encode.go @@ -0,0 +1,112 @@ +package chunk + +import ( + "bytes" + "sync" +) + +const ( + // SubChunkVersion is the current version of the written sub chunks, specifying the format they are + // written on disk and over network. + SubChunkVersion = 9 + // CurrentBlockVersion is the current version of blocks (states) of the game. This version is composed + // of 4 bytes indicating a version, interpreted as a big endian int. The current version represents + // 1.16.0.14 {1, 16, 0, 14}. + CurrentBlockVersion int32 = 18040335 +) + +var ( + // pool is used to pool byte buffers used for encoding chunks. + pool = sync.Pool{ + New: func() any { + return bytes.NewBuffer(make([]byte, 0, 1024)) + }, + } +) + +type ( + // SerialisedData holds the serialised data of a chunk. It consists of the chunk's block data itself, a height + // map, the biomes and entities and block entities. + SerialisedData struct { + // sub holds the data of the serialised sub chunks in a chunk. Sub chunks that are empty or that otherwise + // don't exist are represented as an empty slice (or technically, nil). + SubChunks [][]byte + // Biomes is the biome data of the chunk, which is composed of a biome storage for each sub-chunk. + Biomes []byte + } + // blockEntry represents a block as found in a disk save of a world. + blockEntry struct { + Name string `nbt:"name"` + State map[string]any `nbt:"states"` + Version int32 `nbt:"version"` + } +) + +// Encode encodes Chunk to an intermediate representation SerialisedData. An Encoding may be passed to encode either for +// network or disk purposed, the most notable difference being that the network encoding generally uses varints and no +// NBT. +func Encode(c *Chunk, e Encoding) SerialisedData { + d := SerialisedData{SubChunks: make([][]byte, len(c.sub))} + for i := range c.sub { + d.SubChunks[i] = EncodeSubChunk(c, e, i) + } + d.Biomes = EncodeBiomes(c, e) + return d +} + +// EncodeSubChunk encodes a sub-chunk from a chunk into bytes. An Encoding may be passed to encode either for network or +// disk purposed, the most notable difference being that the network encoding generally uses varints and no NBT. +func EncodeSubChunk(c *Chunk, e Encoding, ind int) []byte { + buf := pool.Get().(*bytes.Buffer) + defer func() { + buf.Reset() + pool.Put(buf) + }() + + s := c.sub[ind] + _, _ = buf.Write([]byte{SubChunkVersion, byte(len(s.storages)), uint8(ind + (c.r[0] >> 4))}) + for _, storage := range s.storages { + encodePalettedStorage(buf, storage, nil, e, BlockPaletteEncoding{Blocks: c.br}) + } + sub := make([]byte, buf.Len()) + _, _ = buf.Read(sub) + return sub +} + +// EncodeBiomes encodes the biomes of a chunk into bytes. An Encoding may be passed to encode either for network or +// disk purposed, the most notable difference being that the network encoding generally uses varints and no NBT. +func EncodeBiomes(c *Chunk, e Encoding) []byte { + buf := pool.Get().(*bytes.Buffer) + defer func() { + buf.Reset() + pool.Put(buf) + }() + + var previous *PalettedStorage + for _, b := range c.biomes { + encodePalettedStorage(buf, b, previous, e, BiomePaletteEncoding) + previous = b + } + biomes := make([]byte, buf.Len()) + _, _ = buf.Read(biomes) + return biomes +} + +// encodePalettedStorage encodes a PalettedStorage into a bytes.Buffer. The Encoding passed is used to write the Palette +// of the PalettedStorage. +func encodePalettedStorage(buf *bytes.Buffer, storage, previous *PalettedStorage, e Encoding, pe paletteEncoding) { + if storage.Equal(previous) { + _, _ = buf.Write([]byte{0x7f<<1 | e.network()}) + return + } + b := make([]byte, len(storage.indices)*4+1) + b[0] = byte(storage.bitsPerIndex<<1) | e.network() + + for i, v := range storage.indices { + // Explicitly don't use the binary package to greatly improve performance of writing the uint32s. + b[i*4+1], b[i*4+2], b[i*4+3], b[i*4+4] = byte(v), byte(v>>8), byte(v>>16), byte(v>>24) + } + _, _ = buf.Write(b) + + e.encodePalette(buf, storage.palette, pe) +} diff --git a/server/world/chunk/encoding.go b/server/world/chunk/encoding.go new file mode 100644 index 0000000..4ed7675 --- /dev/null +++ b/server/world/chunk/encoding.go @@ -0,0 +1,184 @@ +package chunk + +import ( + "bytes" + "encoding/binary" + "fmt" + + "github.com/df-mc/worldupgrader/blockupgrader" + "github.com/sandertv/gophertunnel/minecraft/nbt" + "github.com/sandertv/gophertunnel/minecraft/protocol" +) + +type ( + // Encoding is an encoding type used for Chunk encoding. Implementations of this interface are DiskEncoding and + // NetworkEncoding, which can be used to encode a Chunk to an intermediate disk or network representation respectively. + Encoding interface { + encodePalette(buf *bytes.Buffer, p *Palette, e paletteEncoding) + decodePalette(buf *bytes.Buffer, blockSize paletteSize, e paletteEncoding) (*Palette, error) + network() byte + } + // paletteEncoding is an encoding type used for Chunk encoding. It is used to encode different types of palettes + // (for example, blocks or biomes) differently. + paletteEncoding interface { + encode(buf *bytes.Buffer, v uint32) + decode(buf *bytes.Buffer) (uint32, error) + } +) + +var ( + // DiskEncoding is the Encoding for writing a Chunk to disk. It writes block palettes using NBT and does not use + // varints. + DiskEncoding diskEncoding + // NetworkEncoding is the Encoding used for sending a Chunk over network. It does not use NBT and writes varints. + NetworkEncoding networkEncoding + // BiomePaletteEncoding is the paletteEncoding used for encoding a palette of biomes. + BiomePaletteEncoding biomePaletteEncoding +) + +// biomePaletteEncoding implements the encoding of biome palettes to disk. +type biomePaletteEncoding struct{} + +func (biomePaletteEncoding) encode(buf *bytes.Buffer, v uint32) { + _ = binary.Write(buf, binary.LittleEndian, v) +} +func (biomePaletteEncoding) decode(buf *bytes.Buffer) (uint32, error) { + var v uint32 + return v, binary.Read(buf, binary.LittleEndian, &v) +} + +// BlockPaletteEncoding implements the encoding of block palettes to disk. It requires a BlockRegistry for converting +// between runtime IDs and block states. +type BlockPaletteEncoding struct { + Blocks BlockRegistry +} + +func (bpe BlockPaletteEncoding) encode(buf *bytes.Buffer, v uint32) { + _ = nbt.NewEncoderWithEncoding(buf, nbt.LittleEndian).Encode(bpe.EncodeBlockState(v)) +} +func (bpe BlockPaletteEncoding) decode(buf *bytes.Buffer) (uint32, error) { + var m map[string]any + if err := nbt.NewDecoderWithEncoding(buf, nbt.LittleEndian).Decode(&m); err != nil { + return 0, fmt.Errorf("error decoding block palette entry: %w", err) + } + return bpe.DecodeBlockState(m) +} + +func (bpe BlockPaletteEncoding) EncodeBlockState(v uint32) blockEntry { + // Get the block state registered with the runtime IDs we have in the palette of the block storage + // as we need the name and data value to store. + name, props, _ := bpe.Blocks.RuntimeIDToState(v) + return blockEntry{Name: name, State: props, Version: CurrentBlockVersion} +} + +func (bpe BlockPaletteEncoding) DecodeBlockState(m map[string]any) (uint32, error) { + // Decode the name and version of the block entry. + name, _ := m["name"].(string) + version, _ := m["version"].(int32) + + // Now check for a state field. + stateI, ok := m["states"] + if version < 17694723 { + // This entry is a pre-1.13 block state, so decode the meta value instead. + meta, _ := m["val"].(int16) + + // Upgrade the pre-1.13 state into a post-1.13 state. + state, ok := upgradeLegacyEntry(name, meta) + if !ok { + return 0, fmt.Errorf("cannot find mapping for legacy block entry: %v, %v", name, meta) + } + + // Update the name, state, and version. + name = state.Name + stateI = state.State + version = state.Version + } else if !ok { + // The state is a post-1.13 block state, but the states field is missing, likely due to a broken world + // conversion. + stateI = make(map[string]any) + } + state, ok := stateI.(map[string]any) + if !ok { + return 0, fmt.Errorf("invalid state in block entry") + } + + // Upgrade the block state if necessary. + upgraded := blockupgrader.Upgrade(blockupgrader.BlockState{ + Name: name, + Properties: state, + Version: version, + }) + + v, ok := bpe.Blocks.StateToRuntimeID(upgraded.Name, upgraded.Properties) + if !ok { + return 0, fmt.Errorf("cannot get runtime ID of block state %v{%+v} %v", upgraded.Name, upgraded.Properties, upgraded.Version) + } + return v, nil +} + +// diskEncoding implements the Chunk encoding for writing to disk. +type diskEncoding struct{} + +func (diskEncoding) network() byte { return 0 } +func (diskEncoding) encodePalette(buf *bytes.Buffer, p *Palette, e paletteEncoding) { + if p.size != 0 { + _ = binary.Write(buf, binary.LittleEndian, uint32(p.Len())) + } + for _, v := range p.values { + e.encode(buf, v) + } +} +func (diskEncoding) decodePalette(buf *bytes.Buffer, blockSize paletteSize, e paletteEncoding) (*Palette, error) { + paletteCount := uint32(1) + if blockSize != 0 { + if err := binary.Read(buf, binary.LittleEndian, &paletteCount); err != nil { + return nil, fmt.Errorf("error reading palette entry count: %w", err) + } + } + + var err error + palette := newPalette(blockSize, make([]uint32, paletteCount)) + for i := uint32(0); i < paletteCount; i++ { + palette.values[i], err = e.decode(buf) + if err != nil { + return nil, err + } + } + if paletteCount == 0 { + return palette, fmt.Errorf("invalid palette entry count: found 0, but palette with %v bits per block must have at least 1 value", blockSize) + } + return palette, nil +} + +// networkEncoding implements the Chunk encoding for sending over network. +type networkEncoding struct{} + +func (networkEncoding) network() byte { return 1 } +func (networkEncoding) encodePalette(buf *bytes.Buffer, p *Palette, _ paletteEncoding) { + if p.size != 0 { + _ = protocol.WriteVarint32(buf, int32(p.Len())) + } + for _, val := range p.values { + _ = protocol.WriteVarint32(buf, int32(val)) + } +} +func (networkEncoding) decodePalette(buf *bytes.Buffer, blockSize paletteSize, _ paletteEncoding) (*Palette, error) { + var paletteCount int32 = 1 + if blockSize != 0 { + if err := protocol.Varint32(buf, &paletteCount); err != nil { + return nil, fmt.Errorf("error reading palette entry count: %w", err) + } + if paletteCount <= 0 { + return nil, fmt.Errorf("invalid palette entry count %v", paletteCount) + } + } + + blocks, temp := make([]uint32, paletteCount), int32(0) + for i := int32(0); i < paletteCount; i++ { + if err := protocol.Varint32(buf, &temp); err != nil { + return nil, fmt.Errorf("error decoding palette entry: %w", err) + } + blocks[i] = uint32(temp) + } + return &Palette{values: blocks, size: blockSize}, nil +} diff --git a/server/world/chunk/heightmap.go b/server/world/chunk/heightmap.go new file mode 100644 index 0000000..3983b3a --- /dev/null +++ b/server/world/chunk/heightmap.go @@ -0,0 +1,46 @@ +package chunk + +import ( + "math" +) + +// HeightMap represents the height map of a chunk. It holds the y value of all the highest blocks in the chunk +// that diffuse or obstruct light. +type HeightMap []int16 + +// At returns the height map value at a specific column in the chunk. +func (h HeightMap) At(x, z uint8) int16 { + return h[(uint16(x)<<4)|uint16(z)] +} + +// Set changes the height map value at a specific column in the chunk. +func (h HeightMap) Set(x, z uint8, val int16) { + h[(uint16(x)<<4)|uint16(z)] = val +} + +// HighestNeighbour returns the height map value of the highest directly neighbouring column of the x and z values +// passed. +func (h HeightMap) HighestNeighbour(x, z uint8) int16 { + highest := int16(math.MinInt16) + if x != 15 { + if val := h.At(x+1, z); val > highest { + highest = val + } + } + if x != 0 { + if val := h.At(x-1, z); val > highest { + highest = val + } + } + if z != 15 { + if val := h.At(x, z+1); val > highest { + highest = val + } + } + if z != 0 { + if val := h.At(x, z-1); val > highest { + highest = val + } + } + return highest +} diff --git a/server/world/chunk/legacy_states.nbt b/server/world/chunk/legacy_states.nbt new file mode 100644 index 0000000..9f2509f Binary files /dev/null and b/server/world/chunk/legacy_states.nbt differ diff --git a/server/world/chunk/light.go b/server/world/chunk/light.go new file mode 100644 index 0000000..3757c8d --- /dev/null +++ b/server/world/chunk/light.go @@ -0,0 +1,131 @@ +package chunk + +import "github.com/df-mc/dragonfly/server/block/cube" + +// insertBlockLightNodes iterates over the chunk and looks for blocks that have a light level of at least 1. +// If one is found, a node is added for it to the node queue. +func (a *lightArea) insertBlockLightNodes(queue *lightQueue) { + a.iterSubChunks(a.anyLightBlocks, func(pos cube.Pos) { + if level := a.highest(pos, a.br.LightBlock); level > 0 { + queue.push(node(pos, level, BlockLight)) + } + }) +} + +// anyLightBlocks checks if there are any blocks in the SubChunk passed that emit light. +func (a *lightArea) anyLightBlocks(sub *SubChunk) bool { + for _, layer := range sub.storages { + for _, id := range layer.palette.values { + if a.br.LightBlock(id) != 0 { + return true + } + } + } + return false +} + +// insertSkyLightNodes iterates over the chunk and inserts a light node anywhere at the highest block in the +// chunk. In addition, any skylight above those nodes will be set to 15. +func (a *lightArea) insertSkyLightNodes(queue *lightQueue) { + a.iterHeightmap(func(x, z int, height, highestNeighbour, highestY, lowestY int) { + pos := cube.Pos{x, height, z} + if height <= a.r.Max() { + // Only set light if we're not at the top of the world. + a.setLight(pos, SkyLight, 15) + + if pos[1] > lowestY { + if level := a.highest(pos.Sub(cube.Pos{0, 1}), a.br.FilteringBlock); level != 15 && level != 0 { + // If we hit a block like water or leaves (something that diffuses but does not block light), we + // need a node above this block regardless of the neighbours. + queue.push(node(pos, 15, SkyLight)) + } + } + } + for y := pos[1]; y < highestY; y++ { + // We can do a bit of an optimisation here: We don't need to insert nodes if the neighbours are + // lower than the current one, on the same Y level, or one level higher, because light in + // this column can't spread below that anyway. + if pos[1]++; pos[1] < highestNeighbour { + queue.push(node(pos, 15, SkyLight)) + continue + } + // Fill the rest with full skylight. + a.setLight(pos, SkyLight, 15) + } + }) +} + +// insertLightSpreadingNodes inserts light nodes into the node queue passed which, when propagated, will +// spread into the neighbouring chunks. +func (a *lightArea) insertLightSpreadingNodes(queue *lightQueue, lt light) { + a.iterEdges(a.nodesNeeded(lt), func(pa, pb cube.Pos) { + la, lb := a.light(pa, lt), a.light(pb, lt) + if la == lb || la-1 == lb || lb-1 == la { + // No chance for this to spread. Don't check for the highest filtering blocks on the side. + return + } + if filter := a.highest(pb, a.br.FilteringBlock) + 1; la > filter && la-filter > lb { + queue.push(node(pb, la-filter, lt)) + } else if filter = a.highest(pa, a.br.FilteringBlock) + 1; lb > filter && lb-filter > la { + queue.push(node(pa, lb-filter, lt)) + } + }) +} + +// nodesNeeded checks if any light nodes of a specific light type are needed between two neighbouring SubChunks when +// spreading light between them. +func (a *lightArea) nodesNeeded(lt light) func(sa, sb *SubChunk) bool { + if lt == SkyLight { + return func(sa, sb *SubChunk) bool { + return &sa.skyLight[0] != &sb.skyLight[0] + } + } + return func(sa, sb *SubChunk) bool { + // Don't add nodes if both sub chunks are either both fully filled with light or have no light at all. + return &sa.blockLight[0] != &sb.blockLight[0] + } +} + +// propagate spreads the next light node in the node queue passed through the lightArea a. propagate adds the neighbours +// of the node to the queue for as long as it is able to spread. +func (a *lightArea) propagate(queue *lightQueue) { + n, ok := queue.pop() + if !ok { + return + } + if a.light(n.pos, n.lt) >= n.level { + return + } + a.setLight(n.pos, n.lt, n.level) + + x, y, z := n.pos[0], n.pos[1], n.pos[2] + a.propagateNeighbour(queue, n.lt, n.level, x+1, y, z) + a.propagateNeighbour(queue, n.lt, n.level, x-1, y, z) + a.propagateNeighbour(queue, n.lt, n.level, x, y+1, z) + a.propagateNeighbour(queue, n.lt, n.level, x, y-1, z) + a.propagateNeighbour(queue, n.lt, n.level, x, y, z+1) + a.propagateNeighbour(queue, n.lt, n.level, x, y, z-1) +} + +func (a *lightArea) propagateNeighbour(queue *lightQueue, lt light, level uint8, x, y, z int) { + if y < a.r.Min() || y > a.r.Max() || x < a.baseX || z < a.baseZ || x >= a.baseX+a.w*16 || z >= a.baseZ+a.w*16 { + return + } + pos := cube.Pos{x, y, z} + filter := a.highest(pos, a.br.FilteringBlock) + 1 + if level > filter && a.light(pos, lt) < level-filter { + queue.push(node(pos, level-filter, lt)) + } +} + +// lightNode is a node pushed to the queue which is used to propagate light. +type lightNode struct { + pos cube.Pos + lt light + level uint8 +} + +// node creates a new lightNode using the position, level and light type passed. +func node(pos cube.Pos, level uint8, lt light) lightNode { + return lightNode{pos: pos, level: level, lt: lt} +} diff --git a/server/world/chunk/light_area.go b/server/world/chunk/light_area.go new file mode 100644 index 0000000..85e7f77 --- /dev/null +++ b/server/world/chunk/light_area.go @@ -0,0 +1,288 @@ +package chunk + +import ( + "bytes" + "math" + + "github.com/df-mc/dragonfly/server/block/cube" +) + +// lightArea represents a square area of N*N chunks. It is used for light calculation specifically. +type lightArea struct { + br BlockRegistry + baseX, baseZ int + c []*Chunk + w int + r cube.Range +} + +// lightQueue is a FIFO ring buffer used during light propagation. +type lightQueue struct { + nodes []lightNode + head int + tail int + size int +} + +// initialLightQueueCapacity is the starting size for light propagation queues. A lightNode is 48 bytes on +// 64-bit platforms, so 1024 entries cost about 48 KiB. This avoids the first grow/copy for busier lighting +// runs while keeping the queue transient and able to grow for larger chunks. +const initialLightQueueCapacity = 1024 + +// newLightQueue creates an empty queue sized to capacity (at least 1). +func newLightQueue(capacity int) *lightQueue { + if capacity < 1 { + capacity = 1 + } + return &lightQueue{nodes: make([]lightNode, capacity)} +} + +// push appends a node to the tail, growing storage if full. +func (q *lightQueue) push(n lightNode) { + if q.size == len(q.nodes) { + q.grow() + } + q.nodes[q.tail] = n + q.tail = (q.tail + 1) % len(q.nodes) + q.size++ +} + +// pop removes and returns the oldest queued node. +func (q *lightQueue) pop() (lightNode, bool) { + if q.size == 0 { + return lightNode{}, false + } + n := q.nodes[q.head] + q.head = (q.head + 1) % len(q.nodes) + q.size-- + return n, true +} + +// empty returns true when no nodes are queued. +func (q *lightQueue) empty() bool { + return q.size == 0 +} + +// grow expands the ring buffer and reorders elements to start at index 0. +func (q *lightQueue) grow() { + nodes := make([]lightNode, len(q.nodes)<<1) + if q.head < q.tail { + copy(nodes, q.nodes[q.head:q.tail]) + } else { + n := copy(nodes, q.nodes[q.head:]) + copy(nodes[n:], q.nodes[:q.tail]) + } + q.head = 0 + q.tail = q.size + q.nodes = nodes +} + +// LightArea creates a lightArea with the lower corner of the lightArea at baseX and baseZ. The length of the Chunk +// slice must be a square of a number, so 1, 4, 9 etc. +func LightArea(c []*Chunk, baseX, baseZ int) *lightArea { + w := int(math.Sqrt(float64(len(c)))) + if len(c) != w*w { + panic("area must have a square chunk area") + } + return &lightArea{ + br: c[0].br, + c: c, + w: w, + baseX: baseX << 4, + baseZ: baseZ << 4, + r: c[0].r, + } +} + +// Fill executes the light 'filling' stage, where the lightArea is filled with light coming only from the +// individual chunks within the lightArea itself, without light crossing chunk borders. +func (a *lightArea) Fill() { + a.initialiseLightSlices() + queue := newLightQueue(initialLightQueueCapacity) + a.insertBlockLightNodes(queue) + a.insertSkyLightNodes(queue) + + for !queue.empty() { + a.propagate(queue) + } +} + +// Spread executes the light 'spreading' stage, where the lightArea has light spread from every Chunk into the +// neighbouring chunks. The neighbouring chunks must have passed the light 'filling' stage before this +// function is called for an lightArea that includes them. +func (a *lightArea) Spread() { + queue := newLightQueue(initialLightQueueCapacity) + a.insertLightSpreadingNodes(queue, BlockLight) + a.insertLightSpreadingNodes(queue, SkyLight) + + for !queue.empty() { + a.propagate(queue) + } +} + +// light returns the light at a cube.Pos with the light type l. +func (a *lightArea) light(pos cube.Pos, l light) uint8 { + return l.light(a.sub(pos), uint8(pos[0]&0xf), uint8(pos[1]&0xf), uint8(pos[2]&0xf)) +} + +// light sets the light at a cube.Pos with the light type l. +func (a *lightArea) setLight(pos cube.Pos, l light, v uint8) { + l.setLight(a.sub(pos), uint8(pos[0]&0xf), uint8(pos[1]&0xf), uint8(pos[2]&0xf), v) +} + +// iterSubChunks iterates over all blocks of the lightArea on a per-SubChunk basis. A filter function may be passed to +// specify if a SubChunk should be iterated over. If it returns false, it will not be iterated over. +func (a *lightArea) iterSubChunks(filter func(sub *SubChunk) bool, f func(pos cube.Pos)) { + for cx := 0; cx < a.w; cx++ { + for cz := 0; cz < a.w; cz++ { + baseX, baseZ, c := a.baseX+(cx<<4), a.baseZ+(cz<<4), a.c[a.chunkIndex(cx, cz)] + + for index, sub := range c.sub { + if !filter(sub) { + continue + } + baseY := int(c.SubY(int16(index))) + a.iterSubChunk(func(x, y, z int) { + f(cube.Pos{x + baseX, y + baseY, z + baseZ}) + }) + } + } + } +} + +// iterEdges iterates over all chunk edges within the lightArea and calls the function f with the cube.Pos at either +// side of the edge. +func (a *lightArea) iterEdges(filter func(a, b *SubChunk) bool, f func(a, b cube.Pos)) { + minY, maxY := a.r[0]>>4, a.r[1]>>4 + // First iterate over chunk X, Y and Z, so we can filter out a complete 16x16 sheet of blocks if the + // filter function returns false. + for cu := 1; cu < a.w; cu++ { + u := cu << 4 + for cv := 0; cv < a.w; cv++ { + v := cv << 4 + for cy := minY; cy < maxY; cy++ { + baseY := cy << 4 + + xa, za := cube.Pos{a.baseX + u, baseY, a.baseZ + v}, cube.Pos{a.baseX + v, baseY, a.baseZ + u} + xb, zb := xa.Side(cube.FaceWest), za.Side(cube.FaceNorth) + + addX, addZ := filter(a.sub(xa), a.sub(xb)), filter(a.sub(za), a.sub(zb)) + if !addX && !addZ { + continue + } + // The order of these loops allows us to take care of block spreading over both the X and Z axis by + // just swapping around the axes. + for addV := 0; addV < 16; addV++ { + for y := 0; y < 16; y++ { + // Finally, iterate over the 16x16 sheet and actually do the per-block checks. + if addX { + f(xa.Add(cube.Pos{0, y, addV}), xb.Add(cube.Pos{0, y, addV})) + } + if addZ { + f(za.Add(cube.Pos{addV, y}), zb.Add(cube.Pos{addV, y})) + } + } + } + } + } + } +} + +// iterHeightmap iterates over the height map of the lightArea and calls the function f with the height map value, the +// height map value of the highest neighbour and the Y value of the highest non-empty SubChunk. +func (a *lightArea) iterHeightmap(f func(x, z int, height, highestNeighbour, highestY, lowestY int)) { + m, highestY := a.c[0].HeightMap(), a.c[0].Range().Min() + lowestY := highestY + for index := range a.c[0].sub { + if a.c[0].sub[index].Empty() { + continue + } + highestY = int(a.c[0].SubY(int16(index))) + 15 + } + for x := uint8(0); x < 16; x++ { + for z := uint8(0); z < 16; z++ { + f(int(x)+a.baseX, int(z)+a.baseZ, int(m.At(x, z)), int(m.HighestNeighbour(x, z)), highestY, lowestY) + } + } +} + +// iterSubChunk iterates over the coordinates of a SubChunk (0-15 on all axes) and calls the function f for each of +// those coordinates. +func (a *lightArea) iterSubChunk(f func(x, y, z int)) { + for y := 0; y < 16; y++ { + for x := 0; x < 16; x++ { + for z := 0; z < 16; z++ { + f(x, y, z) + } + } + } +} + +// highest looks up through the blocks at first and second layer at the cube.Pos passed, calls the lightBlocking +// function for each runtime ID, and returns the highest value. +func (a *lightArea) highest(pos cube.Pos, lightBlocking func(rid uint32) uint8) uint8 { + x, y, z, sub := uint8(pos[0]&0xf), uint8(pos[1]&0xf), uint8(pos[2]&0xf), a.sub(pos) + storages, l := sub.storages, len(sub.storages) + + switch l { + case 0: + return 0 + case 1: + return lightBlocking(storages[0].At(x, y, z)) + default: + level := lightBlocking(storages[0].At(x, y, z)) + if v := lightBlocking(storages[1].At(x, y, z)); v > level { + return v + } + return level + } +} + +var ( + fullLight = bytes.Repeat([]byte{0xff}, 2048) + fullLightPtr = &fullLight[0] + noLight = make([]uint8, 2048) + noLightPtr = &noLight[0] +) + +// initialiseLightSlices initialises all light slices in the sub chunks of all chunks either with full light if there is +// no sub chunk with any blocks above it, or with empty light if there is. The sub chunks with empty light are then +// ready to be properly calculated. +func (a *lightArea) initialiseLightSlices() { + for _, c := range a.c { + index := len(c.sub) - 1 + for index >= 0 { + sub := c.sub[index] + if !sub.Empty() { + // We've hit the topmost empty SubChunk. + break + } + sub.skyLight = fullLight + sub.blockLight = noLight + index-- + } + for index >= 0 { + // Fill up the rest of the sub chunks with empty light. We will do light calculation for these sub chunks + // later on. + c.sub[index].skyLight = noLight + c.sub[index].blockLight = noLight + index-- + } + } +} + +// sub returns the SubChunk corresponding to a cube.Pos. +func (a *lightArea) sub(pos cube.Pos) *SubChunk { + return a.chunk(pos).SubChunk(int16(pos[1])) +} + +// chunk returns the Chunk corresponding to a cube.Pos. +func (a *lightArea) chunk(pos cube.Pos) *Chunk { + x, z := pos[0]-a.baseX, pos[2]-a.baseZ + return a.c[a.chunkIndex(x>>4, z>>4)] +} + +// chunkIndex finds the index in the chunk slice of an lightArea for a Chunk at a specific x and z. +func (a *lightArea) chunkIndex(x, z int) int { + return x + (z * a.w) +} diff --git a/server/world/chunk/light_type.go b/server/world/chunk/light_type.go new file mode 100644 index 0000000..cf5dbd3 --- /dev/null +++ b/server/world/chunk/light_type.go @@ -0,0 +1,23 @@ +package chunk + +var ( + // SkyLight holds a light implementation that can be used for propagating sky light through a sub chunk. + SkyLight skyLight + // BlockLight holds a light implementation that can be used for propagating block light through a sub chunk. + BlockLight blockLight +) + +type ( + // light is a type that can be used to set and retrieve light of a specific type in a sub chunk. + light interface { + light(sub *SubChunk, x, y, z uint8) uint8 + setLight(sub *SubChunk, x, y, z, v uint8) + } + skyLight struct{} + blockLight struct{} +) + +func (skyLight) light(sub *SubChunk, x, y, z uint8) uint8 { return sub.SkyLight(x, y, z) } +func (skyLight) setLight(sub *SubChunk, x, y, z, v uint8) { sub.SetSkyLight(x, y, z, v) } +func (blockLight) light(sub *SubChunk, x, y, z uint8) uint8 { return sub.BlockLight(x, y, z) } +func (blockLight) setLight(sub *SubChunk, x, y, z, v uint8) { sub.SetBlockLight(x, y, z, v) } diff --git a/server/world/chunk/palette.go b/server/world/chunk/palette.go new file mode 100644 index 0000000..f3121f4 --- /dev/null +++ b/server/world/chunk/palette.go @@ -0,0 +1,142 @@ +package chunk + +import ( + "math" + "slices" +) + +// paletteSize is the size of a palette. It indicates the amount of bits occupied per value stored. +type paletteSize byte + +// Palette is a palette of values that every PalettedStorage has. Storages hold 'pointers' to indices +// in this palette. +type Palette struct { + last uint32 + lastIndex int16 + size paletteSize + + // values is a map of values. A PalettedStorage points to the index to this value. + values []uint32 +} + +// newPalette returns a new Palette with size and a slice of added values. +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) +} + +// Add adds a values to the Palette. It does not first check if the value was already set in the Palette. +// The index at which the value was added is returned. Another bool is returned indicating if the Palette +// was resized as a result of adding the value. +func (palette *Palette) Add(v uint32) (index int16, resize bool) { + i := int16(len(palette.values)) + palette.values = append(palette.values, v) + + if palette.needsResize() { + palette.increaseSize() + return i, true + } + return i, false +} + +// Replace calls the function passed for each value present in the Palette. The value returned by the +// function replaces the value present at the index of the value passed. +func (palette *Palette) Replace(f func(v uint32) uint32) { + // Reset last runtime ID as it now has a different offset. + palette.last = math.MaxUint32 + for index, v := range palette.values { + palette.values[index] = f(v) + } +} + +// Index loops through the values of the Palette and looks for the index of the given value. If the value could +// not be found, -1 is returned. +func (palette *Palette) Index(runtimeID uint32) int16 { + if runtimeID == palette.last { + // Fast path out. + return palette.lastIndex + } + // Slow path in a separate function allows for inlining the fast path. + return palette.indexSlow(runtimeID) +} + +// indexSlow searches the index of a value in the Palette's values by iterating through the Palette's values. +func (palette *Palette) indexSlow(runtimeID uint32) int16 { + l := len(palette.values) + for i := 0; i < l; i++ { + if palette.values[i] == runtimeID { + palette.last = runtimeID + v := int16(i) + palette.lastIndex = v + return v + } + } + return -1 +} + +// Value returns the value in the Palette at a specific index. +func (palette *Palette) Value(i uint16) uint32 { + return palette.values[i] +} + +// needsResize checks if the Palette, and with it the holding PalettedStorage, needs to be resized to a bigger +// size. +func (palette *Palette) needsResize() bool { + return len(palette.values) > (1 << palette.size) +} + +var sizes = [...]paletteSize{0, 1, 2, 3, 4, 5, 6, 8, 16} +var offsets = [...]int{0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 8: 7, 16: 8} + +// increaseSize increases the size of the Palette to the next palette size. +func (palette *Palette) increaseSize() { + palette.size = sizes[offsets[palette.size]+1] +} + +// padded returns true if the Palette size is 3, 5 or 6. +func (p paletteSize) padded() bool { + return p == 3 || p == 5 || p == 6 +} + +// paletteSizeFor finds a suitable paletteSize for the amount of values passed n. +func paletteSizeFor(n int) paletteSize { + for _, size := range sizes { + if n <= (1 << size) { + return size + } + } + // Should never happen. + return 0 +} + +// uint32s returns the amount of uint32s needed to represent a storage with this palette size. +func (p paletteSize) uint32s() (n int) { + uint32Count := 0 + if p != 0 { + // indicesPerUint32 is the amount of indices that may be stored in a single uint32. + indicesPerUint32 := 32 / int(p) + // uint32Count is the amount of uint32s required to store all indices: 4096 indices need to be stored in + // total. + uint32Count = 4096 / indicesPerUint32 + } + if p.padded() { + // We've got one of the padded sizes, so the storage has another uint32 to be able to store + // every index. + uint32Count++ + } + return uint32Count +} diff --git a/server/world/chunk/paletted_storage.go b/server/world/chunk/paletted_storage.go new file mode 100644 index 0000000..ac49c67 --- /dev/null +++ b/server/world/chunk/paletted_storage.go @@ -0,0 +1,236 @@ +package chunk + +import ( + "bytes" + "slices" + "unsafe" +) + +const ( + // uint32ByteSize is the amount of bytes in a uint32. + uint32ByteSize = 4 + // uint32BitSize is the amount of bits in a uint32. + uint32BitSize = uint32ByteSize * 8 +) + +// PalettedStorage is a storage of 4096 blocks encoded in a variable amount of uint32s, storages may have values +// with a bit size per block of 0, 1, 2, 3, 4, 5, 6, 8 or 16 bits. +// 3 of these formats have additional padding in every uint32 and an additional uint32 at the end, to cater +// for the blocks that don't fit. This padding is present when the storage has a block size of 3, 5 or 6 +// bytes. +// Methods on PalettedStorage must not be called simultaneously from multiple goroutines. +type PalettedStorage struct { + // bitsPerIndex is the amount of bits required to store one block. The number increases as the block + // storage holds more unique block states. + bitsPerIndex uint16 + // filledBitsPerIndex returns the amount of blocks that are actually filled per uint32. + filledBitsPerIndex uint16 + // indexMask is the equivalent of 1 << bitsPerIndex - 1. + indexMask uint32 + + // indicesStart holds an unsafe.Pointer to the first byte in the indices slice below. + indicesStart unsafe.Pointer + + // Palette holds all block runtime IDs that the indices in the indices slice point to. These runtime IDs + // point to block states. + palette *Palette + + // indices contains all indices in the PalettedStorage. This slice has a variable size, but may not be changed + // unless the whole PalettedStorage is resized, including the Palette. + indices []uint32 +} + +// newPalettedStorage creates a new block storage using the uint32 slice as the indices and the palette passed. +// The bits per block are calculated using the length of the uint32 slice. +func newPalettedStorage(indices []uint32, palette *Palette) *PalettedStorage { + var ( + bitsPerIndex = uint16(len(indices) / uint32BitSize / uint32ByteSize) + indexMask = (uint32(1) << bitsPerIndex) - 1 + indicesStart = (unsafe.Pointer)(unsafe.SliceData(indices)) + filledBitsPerIndex uint16 + ) + if bitsPerIndex != 0 { + filledBitsPerIndex = uint32BitSize / bitsPerIndex * bitsPerIndex + } + return &PalettedStorage{filledBitsPerIndex: filledBitsPerIndex, indexMask: indexMask, indicesStart: indicesStart, bitsPerIndex: bitsPerIndex, indices: indices, palette: palette} +} + +// emptyStorage creates a PalettedStorage filled completely with a value v. +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 +} + +// At returns the value of the PalettedStorage at a given x, y and z. +func (storage *PalettedStorage) At(x, y, z byte) uint32 { + return storage.palette.Value(storage.paletteIndex(x&15, y&15, z&15)) +} + +// Set sets a value at a specific x, y and z. The Palette and PalettedStorage are expanded +// automatically to make space for the value, should that be needed. +func (storage *PalettedStorage) Set(x, y, z byte, v uint32) { + index := storage.palette.Index(v) + if index == -1 { + // The runtime ID was not yet available in the palette. We add it, then check if the block storage + // needs to be resized for the palette pointers to fit. + index = storage.addNew(v) + } + storage.setPaletteIndex(x&15, y&15, z&15, uint16(index)) +} + +// Equal checks if two PalettedStorages are equal value wise. False is returned +// if either of the storages are nil. +func (storage *PalettedStorage) Equal(other *PalettedStorage) bool { + if storage == nil || other == nil { + return false + } + if len(storage.indices) == 0 || len(other.indices) == 0 || storage.palette.values[0] == 0 || other.palette.values[0] == 0 { + return false + } + indicesA := unsafe.Slice((*byte)(unsafe.Pointer(&storage.indices[0])), len(storage.indices)*4) + indicesB := unsafe.Slice((*byte)(unsafe.Pointer(&other.indices[0])), len(other.indices)*4) + if !bytes.Equal(indicesA, indicesB) { + return false + } + paletteA := unsafe.Slice((*byte)(unsafe.Pointer(&storage.palette.values[0])), len(storage.palette.values)*4) + paletteB := unsafe.Slice((*byte)(unsafe.Pointer(&other.palette.values[0])), len(other.palette.values)*4) + return bytes.Equal(paletteA, paletteB) +} + +// addNew adds a new value to the PalettedStorage's Palette and returns its index. If needed, the storage is resized. +func (storage *PalettedStorage) addNew(v uint32) int16 { + index, resize := storage.palette.Add(v) + if resize { + storage.resize(storage.palette.size) + } + return index +} + +// paletteIndex looks up the Palette index at a given x, y and z value in the PalettedStorage. This palette +// index is not the value at this offset, but merely an index in the Palette pointing to a value. +func (storage *PalettedStorage) paletteIndex(x, y, z byte) uint16 { + if storage.bitsPerIndex == 0 { + // Unfortunately our default logic cannot deal with 0 bits per index, meaning we'll have to special case + // this. This comes with a little performance hit, but it seems to be the only way to go. An alternative would + // be not to have 0 bits per block storages in memory, but that would cause a strongly increased memory usage + // by biomes. + return 0 + } + offset := ((uint16(x) << 8) | (uint16(z) << 4) | uint16(y)) * storage.bitsPerIndex + uint32Offset, bitOffset := offset/storage.filledBitsPerIndex, offset%storage.filledBitsPerIndex + + w := *(*uint32)(unsafe.Pointer(uintptr(storage.indicesStart) + uintptr(uint32Offset<<2))) + return uint16((w >> bitOffset) & storage.indexMask) +} + +// setPaletteIndex sets the palette index at a given x, y and z to paletteIndex. This index should point +// to a value in the PalettedStorage's Palette. +func (storage *PalettedStorage) setPaletteIndex(x, y, z byte, i uint16) { + if storage.bitsPerIndex == 0 { + return + } + offset := ((uint16(x) << 8) | (uint16(z) << 4) | uint16(y)) * storage.bitsPerIndex + uint32Offset, bitOffset := offset/storage.filledBitsPerIndex, offset%storage.filledBitsPerIndex + + ptr := (*uint32)(unsafe.Pointer(uintptr(storage.indicesStart) + uintptr(uint32Offset<<2))) + *ptr = (*ptr &^ (storage.indexMask << bitOffset)) | (uint32(i) << bitOffset) +} + +// resize changes the size of a PalettedStorage to newPaletteSize. A new PalettedStorage is constructed, +// and all values available in the current storage are set in their appropriate locations in the +// new storage. +func (storage *PalettedStorage) resize(newPaletteSize paletteSize) { + if newPaletteSize == paletteSize(storage.bitsPerIndex) { + return // Don't resize if the size is already equal. + } + // Construct a new storage and set all values in there manually. We can't easily do this in a better + // way, because all values will be at a different index with a different length. + newStorage := newPalettedStorage(make([]uint32, newPaletteSize.uint32s()), storage.palette) + for x := byte(0); x < 16; x++ { + for y := byte(0); y < 16; y++ { + for z := byte(0); z < 16; z++ { + newStorage.setPaletteIndex(x, y, z, storage.paletteIndex(x, y, z)) + } + } + } + // Set the new storage. + *storage = *newStorage +} + +// compact clears unused indexes in the palette by scanning for usages in the PalettedStorage. This is a +// 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.Len() == 0 { + return + } + if storage.palette.Len() == 1 { + // A single unique value can always be represented using 0 bits per index. This avoids scanning the + // entire storage and drops any backing indices slice. + storage.bitsPerIndex = 0 + storage.filledBitsPerIndex = 0 + storage.indexMask = 0 + storage.indicesStart = nil + storage.indices = nil + storage.palette.size = 0 + return + } + + usedIndices := make([]bool, storage.palette.Len()) + for x := byte(0); x < 16; x++ { + for y := byte(0); y < 16; y++ { + for z := byte(0); z < 16; z++ { + usedIndices[storage.paletteIndex(x, y, z)] = true + } + } + } + + usedCount := 0 + allUsed := true + for _, used := range usedIndices { + if used { + usedCount++ + } else { + allUsed = false + } + } + + // If every palette entry is used and the palette size cannot shrink, nothing changes. + // This avoids allocating a new indices slice and palette values slice for already-optimal storages. + size := paletteSizeFor(usedCount) + if allUsed && size == storage.palette.size { + return + } + + newRuntimeIDs := make([]uint32, 0, usedCount) + conversion := make([]uint16, len(usedIndices)) + for index, used := range usedIndices { + if used { + conversion[index] = uint16(len(newRuntimeIDs)) + newRuntimeIDs = append(newRuntimeIDs, storage.palette.values[index]) + } + } + // Construct a new storage and set all values in there manually. We can't easily do this in a better + // way, because all values will be at a different index with a different length. + newStorage := newPalettedStorage(make([]uint32, size.uint32s()), newPalette(size, newRuntimeIDs)) + + for x := byte(0); x < 16; x++ { + for y := byte(0); y < 16; y++ { + for z := byte(0); z < 16; z++ { + // Replace all usages of the old palette indexes with the new indexes using the map we + // produced earlier. + newStorage.setPaletteIndex(x, y, z, conversion[storage.paletteIndex(x, y, z)]) + } + } + } + *storage = *newStorage +} diff --git a/server/world/chunk/sub_chunk.go b/server/world/chunk/sub_chunk.go new file mode 100644 index 0000000..7a06680 --- /dev/null +++ b/server/world/chunk/sub_chunk.go @@ -0,0 +1,149 @@ +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 { + air uint32 + storages []*PalettedStorage + blockLight []uint8 + skyLight []uint8 +} + +// Equals returns if the sub chunk passed is equal to the current one. +func (sub *SubChunk) Equals(s *SubChunk) bool { + if s.air != sub.air || len(s.storages) != len(sub.storages) { + return false + } + + for i, st := range s.storages { + if !st.Equal(sub.storages[i]) { + return false + } + } + + return true +} + +// NewSubChunk creates a new sub chunk. All sub chunks should be created through this function. +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 { + return len(sub.storages) == 0 || (len(sub.storages) == 1 && len(sub.storages[0].palette.values) == 1 && sub.storages[0].palette.values[0] == sub.air) +} + +// Layer returns a certain block storage/layer from a sub chunk. If no storage at the layer exists, the layer +// is created, as well as all layers between the current highest layer and the new highest layer. +func (sub *SubChunk) Layer(layer uint8) *PalettedStorage { + for uint8(len(sub.storages)) <= layer { + // Keep appending to storages until the requested layer is achieved. Makes working with new layers + // much easier. + sub.storages = append(sub.storages, emptyStorage(sub.air)) + } + return sub.storages[layer] +} + +// Layers returns all layers in the sub chunk. This method may also return an empty slice. +func (sub *SubChunk) Layers() []*PalettedStorage { + return sub.storages +} + +// Block returns the runtime ID of the block located at the given X, Y and Z. X, Y and Z must be in a +// range of 0-15. +func (sub *SubChunk) Block(x, y, z byte, layer uint8) uint32 { + if uint8(len(sub.storages)) <= layer { + return sub.air + } + return sub.storages[layer].At(x, y, z) +} + +// SetBlock sets the given block runtime ID at the given X, Y and Z. X, Y and Z must be in a range of 0-15. +func (sub *SubChunk) SetBlock(x, y, z byte, layer uint8, block uint32) { + sub.Layer(layer).Set(x, y, z, block) +} + +// SetBlockLight sets the block light value at a specific position in the sub chunk. +func (sub *SubChunk) SetBlockLight(x, y, z byte, level uint8) { + if ptr := &sub.blockLight[0]; ptr == noLightPtr { + // Copy the block light as soon as it is changed to create a COW system. + sub.blockLight = append([]byte(nil), sub.blockLight...) + } + index := (uint16(x) << 8) | (uint16(z) << 4) | uint16(y) + + i := index >> 1 + bit := (index & 1) << 2 + sub.blockLight[i] = (sub.blockLight[i] & (0xf0 >> bit)) | (level << bit) +} + +// BlockLight returns the block light value at a specific value at a specific position in the sub chunk. +func (sub *SubChunk) BlockLight(x, y, z byte) uint8 { + index := (uint16(x) << 8) | (uint16(z) << 4) | uint16(y) + return (sub.blockLight[index>>1] >> ((index & 1) << 2)) & 0xf +} + +// SetSkyLight sets the skylight value at a specific position in the sub chunk. +func (sub *SubChunk) SetSkyLight(x, y, z byte, level uint8) { + if ptr := &sub.skyLight[0]; ptr == fullLightPtr || ptr == noLightPtr { + // Copy the skylight as soon as it is changed to create a COW system. + sub.skyLight = append([]byte(nil), sub.skyLight...) + } + index := (uint16(x) << 8) | (uint16(z) << 4) | uint16(y) + + i := index >> 1 + bit := (index & 1) << 2 + sub.skyLight[i] = (sub.skyLight[i] & (0xf0 >> bit)) | (level << bit) +} + +// SkyLight returns the skylight value at a specific value at a specific position in the sub chunk. +func (sub *SubChunk) SkyLight(x, y, z byte) uint8 { + index := (uint16(x) << 8) | (uint16(z) << 4) | uint16(y) + return (sub.skyLight[index>>1] >> ((index & 1) << 2)) & 0xf +} + +// Compact cleans the garbage from all block storages that sub chunk contains, so that they may be +// cleanly written to a database. +func (sub *SubChunk) compact() { + newStorages := make([]*PalettedStorage, 0, len(sub.storages)) + for _, storage := range sub.storages { + storage.compact() + if len(storage.palette.values) == 1 && storage.palette.values[0] == sub.air { + // If the palette has only air in it, it means the storage is empty, so we can ignore it. + continue + } + newStorages = append(newStorages, storage) + } + sub.storages = newStorages +} diff --git a/server/world/conf.go b/server/world/conf.go new file mode 100644 index 0000000..a71fb5a --- /dev/null +++ b/server/world/conf.go @@ -0,0 +1,160 @@ +package world + +import ( + "log/slog" + "math/rand/v2" + "time" +) + +type blockRegistrySetter interface { + // SetBlockRegistry updates the registry used by the provider to encode and decode blocks. + // Config.New calls it with Config.Blocks after applying the default registry and finalizing it. + SetBlockRegistry(BlockRegistry) +} + +// Config may be used to create a new World. It holds a variety of fields that +// influence the World. +type Config struct { + // Log is the Logger that will be used to log errors and debug messages to. + // If set to nil, slog.Default() is set. + Log *slog.Logger + // Dim is the Dimension of the World. If set to nil, the World will use + // Overworld as its dimension. The dimension set here influences, among + // others, the sky colour, weather/time and liquid behaviour in that World. + Dim Dimension + // PortalDestination is a function that returns the destination World for a + // portal of a specific Dimension type. If set to nil, no portals will + // function. If the function returns a nil world for a Dimension, only + // portals of that specific Dimension type will not function. + PortalDestination func(dim Dimension) *World + // Provider is the Provider implementation used to read and write World + // data. If set to nil, the Provider used will be NopProvider, which does + // not store any data to disk. + Provider Provider + // Generator is the Generator implementation used to generate new areas of + // the World. If set to nil, the Generator used will be NopGenerator, which + // generates completely empty chunks. + Generator Generator + // ReadOnly specifies if the World should be read-only, meaning no new data + // will be written to the Provider. + ReadOnly bool + // SaveInterval specifies how often a World should be automatically saved to + // disk. This includes chunks, entities and level.dat data. If ReadOnly is + // set to false, changing SaveInterval will have no effect. + // By default, SaveInterval is set to 10 minutes. Setting SaveInterval to + // a negative number disables automatic saving entirely. + SaveInterval time.Duration + // ChunkUnloadInterval specifies how often unused chunks should be unloaded + // from memory when no longer in use. By default, this is set to 2 minutes. + // ChunkUnloadInterval should not be used to prevent chunks from unloading + // altogether. This should be done using a Loader with a custom Viewer. + ChunkUnloadInterval time.Duration + // RandomTickSpeed specifies the rate at which blocks should be ticked in + // the World. By default, each sub chunk has 3 blocks randomly ticked per + // sub chunk, so the default value is 3. Setting this value to -1 or lower + // will stop random ticking altogether, while setting it higher results in + // faster ticking. + RandomTickSpeed int + // RandSource is the rand.Source used for generation of random numbers in a + // World, such as when selecting blocks to tick or when deciding where to + // strike lightning. If set to nil, RandSource defaults to a `rand.PCG` + // source seeded with `time.Now().UnixNano()`. PCG is significantly faster + // than `rand.ChaCha8` on 64-bit systems at the expense of poorer + // statistical distribution, which is acceptable here. + // See https://go.dev/blog/chacha8rand. + RandSource rand.Source + // Entities is an EntityRegistry with all Entity types registered that may + // be added to the World. + Entities EntityRegistry + + // Blocks is the BlockRegistry used by the World. + // If left nil, DefaultBlockRegistry is used. For a non-default registry, + // use NewBlockRegistry(), register blocks/states, and call Finalize(). + Blocks BlockRegistry + + // Synchronous makes the World run without any background goroutines. + // Transactions from World.Exec run on the calling goroutine, the World is + // not saved or unloaded automatically, and time only passes on explicit + // World.AdvanceTick calls. This makes Synchronous Worlds deterministic and + // well suited to unit tests that need a World to interact with. + // A Synchronous World must be driven from one goroutine. Exec and + // AdvanceTick are not safe to call concurrently, including from delayed + // item or death callbacks. + Synchronous bool +} + +// New creates a new World using the Config conf. The World returned will start +// ticking as soon as a viewer is added to it and is otherwise ready for use. +func (conf Config) New() *World { + if conf.Log == nil { + conf.Log = slog.Default() + } + if conf.Dim == nil { + conf.Dim = Overworld + } + if conf.SaveInterval == 0 { + conf.SaveInterval = time.Minute * 10 + } + if conf.ChunkUnloadInterval <= 0 { + conf.ChunkUnloadInterval = time.Minute * 2 + } + if conf.Generator == nil { + conf.Generator = NopGenerator{} + } + if conf.Provider == nil { + // If no provider is set, use the default settings and the default spawn position from the generator. + s := defaultSettings() + s.Spawn = conf.Generator.DefaultSpawn(conf.Dim) + conf.Provider = NopProvider{Set: s} + } + if conf.RandomTickSpeed == 0 { + conf.RandomTickSpeed = 3 + } + if conf.Blocks == nil { + conf.Blocks = DefaultBlockRegistry + } + + // Initialize the passed block registry and also initialize the default block registry which + // is used in some vanilla paths. + conf.Blocks.Finalize() + DefaultBlockRegistry.Finalize() + if provider, ok := conf.Provider.(blockRegistrySetter); ok { + provider.SetBlockRegistry(conf.Blocks) + } + + if conf.RandSource == nil { + t := uint64(time.Now().UnixNano()) + conf.RandSource = rand.NewPCG(t, t) + } + s := conf.Provider.Settings() + w := &World{ + scheduledUpdates: newScheduledTickQueue(s.CurrentTick), + entities: make(map[*EntityHandle]ChunkPos), + viewers: make(map[*Loader]Viewer), + chunks: make(map[ChunkPos]*Column), + queueClosing: make(chan struct{}), + closing: make(chan struct{}), + queue: make(chan transaction, 128), + r: rand.New(conf.RandSource), + advance: s.ref.Add(1) == 1, + conf: conf, + ra: conf.Dim.Range(), + set: s, + } + w.weather = weather{w: w} + var h Handler = NopHandler{} + w.handler.Store(&h) + + t := ticker{interval: time.Second / 20} + if !conf.Synchronous { + w.queueing.Add(1) + w.running.Add(2) + + go t.tickLoop(w) + go w.autoSave() + go w.handleTransactions() + } + + <-w.Exec(t.tick) + return w +} diff --git a/server/world/difficulty.go b/server/world/difficulty.go new file mode 100644 index 0000000..6e19149 --- /dev/null +++ b/server/world/difficulty.go @@ -0,0 +1,120 @@ +package world + +// Difficulty represents the difficulty of a Minecraft world. The difficulty of +// a world influences all kinds of aspects of the world, such as the damage +// enemies deal to players, the way hunger depletes, whether hostile monsters +// spawn or not and more. +type Difficulty interface { + // FoodRegenerates specifies if players' food levels should automatically + // regenerate with this difficulty. + FoodRegenerates() bool + // StarvationHealthLimit specifies the amount of health at which a player + // will no longer receive damage from starvation. + StarvationHealthLimit() float64 + // FireSpreadIncrease returns a number that increases the rate at which fire + // spreads. + FireSpreadIncrease() int +} + +var ( + // DifficultyPeaceful prevents most hostile mobs from spawning and makes + // players rapidly regenerate health and food. + DifficultyPeaceful difficultyPeaceful + // DifficultyEasy has mobs that deal less damage to players than normal and + // starvation won't occur if a player has less than 5 hearts of health. + DifficultyEasy difficultyEasy + // DifficultyNormal has mobs that deal normal damage to players. Starvation + // will occur until the player is down to a single heart. + DifficultyNormal difficultyNormal + // DifficultyHard has mobs that deal above average damage to players. + // Starvation will kill players with too little food and monsters will get + // additional effects. + DifficultyHard difficultyHard +) + +var difficultyReg = newDifficultyRegistry(map[int]Difficulty{ + 0: DifficultyPeaceful, + 1: DifficultyEasy, + 2: DifficultyNormal, + 3: DifficultyHard, +}) + +// DifficultyByID looks up a Difficulty for the ID passed, returning +// DifficultyPeaceful for 0, DifficultyEasy for 1, DifficultyNormal for 2 and +// DifficultyHard for 3. If the ID is unknown, the bool returned is false. In +// this case the Difficulty returned is DifficultyNormal. +func DifficultyByID(id int) (Difficulty, bool) { + return difficultyReg.Lookup(id) +} + +// DifficultyID looks up the ID that a Difficulty was registered with. If not +// found, false is returned. +func DifficultyID(diff Difficulty) (int, bool) { + return difficultyReg.LookupID(diff) +} + +type difficultyRegistry struct { + difficulties map[int]Difficulty + ids map[Difficulty]int +} + +// newDifficultyRegistry returns an initialised difficultyRegistry. +func newDifficultyRegistry(diff map[int]Difficulty) *difficultyRegistry { + ids := make(map[Difficulty]int, len(diff)) + for k, v := range diff { + ids[v] = k + } + return &difficultyRegistry{difficulties: diff, ids: ids} +} + +// Lookup looks up a Difficulty for the ID passed, returning DifficultyPeaceful +// for 0, DifficultyEasy for 1, DifficultyNormal for 2 and DifficultyHard for +// 3. If the ID is unknown, the bool returned is false. In this case the +// Difficulty returned is DifficultyNormal. +func (reg *difficultyRegistry) Lookup(id int) (Difficulty, bool) { + dim, ok := reg.difficulties[id] + if !ok { + dim = DifficultyNormal + } + return dim, ok +} + +// LookupID looks up the ID that a Difficulty was registered with. If not found, +// false is returned. +func (reg *difficultyRegistry) LookupID(diff Difficulty) (int, bool) { + id, ok := reg.ids[diff] + return id, ok +} + +// difficultyPeaceful difficulty prevents most hostile mobs from spawning and +// makes players rapidly regenerate health and food. +type difficultyPeaceful struct{} + +func (difficultyPeaceful) FoodRegenerates() bool { return true } +func (difficultyPeaceful) StarvationHealthLimit() float64 { return 20 } +func (difficultyPeaceful) FireSpreadIncrease() int { return 0 } + +// difficultyEasy difficulty has mobs deal less damage to players than normal +// and starvation won't occur if a player has less than 5 hearts of health. +type difficultyEasy struct{} + +func (difficultyEasy) FoodRegenerates() bool { return false } +func (difficultyEasy) StarvationHealthLimit() float64 { return 10 } +func (difficultyEasy) FireSpreadIncrease() int { return 7 } + +// difficultyNormal difficulty has mobs that deal normal damage to players. +// Starvation will occur until the player is down to a single heart. +type difficultyNormal struct{} + +func (difficultyNormal) FoodRegenerates() bool { return false } +func (difficultyNormal) StarvationHealthLimit() float64 { return 2 } +func (difficultyNormal) FireSpreadIncrease() int { return 14 } + +// difficultyHard difficulty has mobs that deal above average damage to +// players. Starvation will kill players with too little food and monsters will +// get additional effects. +type difficultyHard struct{} + +func (difficultyHard) FoodRegenerates() bool { return false } +func (difficultyHard) StarvationHealthLimit() float64 { return -1 } +func (difficultyHard) FireSpreadIncrease() int { return 21 } diff --git a/server/world/dimension.go b/server/world/dimension.go new file mode 100644 index 0000000..eeb5443 --- /dev/null +++ b/server/world/dimension.go @@ -0,0 +1,109 @@ +package world + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "time" +) + +var ( + // Overworld is the Dimension implementation of a normal overworld. It has a + // blue sky under normal circumstances and has a sun, clouds, stars and a + // moon. Overworld has a building range of [-64, 320). + Overworld overworld + // Nether is a Dimension implementation with a lower base light level and a + // darker sky without sun/moon. It has a building range of [0, 128). + Nether nether + // End is a Dimension implementation with a dark sky. It has a building + // range of [0, 256). + End end +) + +var dimensionReg = newDimensionRegistry(map[int]Dimension{ + 0: Overworld, + 1: Nether, + 2: End, +}) + +// DimensionByID looks up a Dimension for the ID passed, returning Overworld +// for 0, Nether for 1 and End for 2. If the ID is unknown, the bool returned +// is false. In this case the Dimension returned is Overworld. +func DimensionByID(id int) (Dimension, bool) { + return dimensionReg.Lookup(id) +} + +// DimensionID looks up the ID that a Dimension was registered with. If not +// found, false is returned. +func DimensionID(dim Dimension) (int, bool) { + return dimensionReg.LookupID(dim) +} + +type dimensionRegistry struct { + dimensions map[int]Dimension + ids map[Dimension]int +} + +// newDimensionRegistry returns an initialised dimensionRegistry. +func newDimensionRegistry(dim map[int]Dimension) *dimensionRegistry { + ids := make(map[Dimension]int, len(dim)) + for k, v := range dim { + ids[v] = k + } + return &dimensionRegistry{dimensions: dim, ids: ids} +} + +// Lookup looks up a Dimension for the ID passed, returning Overworld for 0, +// Nether for 1 and End for 2. If the ID is unknown, the bool returned is +// false. In this case the Dimension returned is Overworld. +func (reg *dimensionRegistry) Lookup(id int) (Dimension, bool) { + dim, ok := reg.dimensions[id] + if !ok { + dim = Overworld + } + return dim, ok +} + +// LookupID looks up the ID that a Dimension was registered with. If not found, +// false is returned. +func (reg *dimensionRegistry) LookupID(dim Dimension) (int, bool) { + id, ok := reg.ids[dim] + return id, ok +} + +type ( + // Dimension is a dimension of a World. It influences a variety of + // properties of a World such as the building range, the sky colour and the + // behaviour of liquid blocks. + Dimension interface { + // Range returns the lowest and highest valid Y coordinates of a block + // in the Dimension. + Range() cube.Range + WaterEvaporates() bool + LavaSpreadDuration() time.Duration + WeatherCycle() bool + TimeCycle() bool + } + overworld struct{} + nether struct{} + end struct{} +) + +func (overworld) Range() cube.Range { return cube.Range{-64, 319} } +func (overworld) WaterEvaporates() bool { return false } +func (overworld) LavaSpreadDuration() time.Duration { return time.Second * 3 / 2 } +func (overworld) WeatherCycle() bool { return true } +func (overworld) TimeCycle() bool { return true } +func (overworld) String() string { return "Overworld" } + +func (nether) Range() cube.Range { return cube.Range{0, 127} } +func (nether) WaterEvaporates() bool { return true } +func (nether) LavaSpreadDuration() time.Duration { return time.Second / 4 } +func (nether) WeatherCycle() bool { return false } +func (nether) TimeCycle() bool { return false } +func (nether) String() string { return "Nether" } + +func (end) Range() cube.Range { return cube.Range{0, 255} } +func (end) WaterEvaporates() bool { return false } +func (end) LavaSpreadDuration() time.Duration { return time.Second * 3 / 2 } +func (end) WeatherCycle() bool { return false } +func (end) TimeCycle() bool { return false } +func (end) String() string { return "End" } diff --git a/server/world/entity.go b/server/world/entity.go new file mode 100644 index 0000000..01c6c0b --- /dev/null +++ b/server/world/entity.go @@ -0,0 +1,464 @@ +package world + +import ( + "encoding/binary" + "io" + "maps" + "slices" + "sync" + "sync/atomic" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/go-gl/mathgl/mgl64" + "github.com/google/uuid" +) + +// EntityType is the type of Entity. It specifies the name, encoded Entity +// ID and bounding box of an Entity. +type EntityType interface { + // Open returns an Entity implementation in the context of a transaction. + Open(tx *Tx, handle *EntityHandle, data *EntityData) Entity + + // EncodeEntity converts the Entity to its encoded representation: It + // returns the type of the Minecraft Entity, for example + // 'minecraft:falling_block'. + EncodeEntity() string + // BBox returns the bounding box of an Entity with this EntityType. + BBox(e Entity) cube.BBox + // DecodeNBT reads the fields from the NBT data map passed and converts it + // to an Entity of the same EntityType. + DecodeNBT(m map[string]any, data *EntityData) + // EncodeNBT encodes the Entity of the same EntityType passed to a map of + // properties that can be encoded to NBT. + EncodeNBT(data *EntityData) map[string]any +} + +// EntityConfig is used to configure the initial settings of an Entity upon +// creation using NewEntity. +type EntityConfig interface { + Apply(data *EntityData) +} + +// EntityHandle is a persistent identifier of an entity. It holds data of the +// entity that can be transformed into an Entity implementation in the context +// of a transaction. +type EntityHandle struct { + id uuid.UUID + t EntityType + + cond *sync.Cond + worldless *atomic.Bool + weakTxActive bool + w *World + + data EntityData + + // TODO Handler? Handle world change here? +} + +// EntitySpawnOpts holds spawning related options for entities created. +type EntitySpawnOpts struct { + // Position is the position that an Entity should be spawned at. + Position mgl64.Vec3 + // Rotation is the rotation that an Entity should be spawned with. + Rotation cube.Rotation + // Velocity specifies the initial velocity of the Entity. + Velocity mgl64.Vec3 + // ID specifies the UUID of an entity. This field should usually be left + // empty, as a valid UUID is generated when not set. Non-player entities + // only have the last 8 bytes of the UUID set. + ID uuid.UUID + // NameTag is the name tag that the entity is spawned with. + NameTag string +} + +// New creates an EntityHandle using an EntityType and EntityConfig passed. The +// EntityHandle may be added to a world by calling Tx.AddEntity(). +// The spawn conditions depend on the options set in opts. +func (opts EntitySpawnOpts) New(t EntityType, conf EntityConfig) *EntityHandle { + if opts.ID == uuid.Nil { + // Generate a new UUID with only the upper 8 bytes filled. This UUID + // needs to be translatable to an int64. + opts.ID = uuid.New() + clear(opts.ID[:8]) + } + handle := &EntityHandle{id: opts.ID, t: t, cond: sync.NewCond(&sync.Mutex{}), worldless: &atomic.Bool{}} + handle.worldless.Store(true) + handle.data.Pos, handle.data.Rot, handle.data.Vel = opts.Position, opts.Rotation, opts.Velocity + handle.data.Name = opts.NameTag + conf.Apply(&handle.data) + return handle +} + +// NewEntity creates an EntityHandle using an EntityType and EntityConfig +// passed. The EntityHandle may be added to a world by calling Tx.AddEntity(). +// NewEntity uses the zero value for EntitySpawnOpts. +func NewEntity(t EntityType, conf EntityConfig) *EntityHandle { + var opts EntitySpawnOpts + return opts.New(t, conf) +} + +// entityFromData reads an entity from the decoded NBT data passed and returns +// an EntityHandle. +func entityFromData(t EntityType, id int64, data map[string]any) *EntityHandle { + handle := &EntityHandle{t: t, cond: sync.NewCond(&sync.Mutex{}), worldless: &atomic.Bool{}} + binary.LittleEndian.PutUint64(handle.id[8:], uint64(id)) + handle.decodeNBT(data) + t.DecodeNBT(data, &handle.data) + return handle +} + +// Type returns the EntityType of the EntityHandle. +func (e *EntityHandle) Type() EntityType { + return e.t +} + +// Entity attempts to convert an EntityHandle to an Entity using the Tx passed. +// A non-nil Entity is returned only if the entity's world matches the world of +// the Tx. If they do not match, false is returned. +func (e *EntityHandle) Entity(tx *Tx) (Entity, bool) { + if e == nil || e.w != tx.World() { + return nil, false + } + return e.t.Open(tx, e, &e.data), true +} + +// mustEntity calls Entity but panics if the worlds do not match. +func (e *EntityHandle) mustEntity(tx *Tx) Entity { + if ent, ok := e.Entity(tx); ok { + return ent + } + panic("can't load entity with Tx of different world") +} + +// UUID returns the identifier of the EntityHandle. +func (e *EntityHandle) UUID() uuid.UUID { + return e.id +} + +// Close closes the EntityHandle. Any subsequent call to ExecWorld will return +// immediately without the transaction function being called. Close always +// returns nil. +func (e *EntityHandle) Close() error { + e.setAndUnlockWorld(closeWorld) + return nil +} + +// ExecWorld obtains the EntityHandle's World in a thread-safe way and opens a +// transaction in it when it does. If the EntityHandle has not been added to a +// world, ExecWorld will block until the EntityHandle is added to a World and +// run the transaction function once it is. If the Entity is closed before +// ExecWorld is called, ExecWorld will return false immediately without running +// the transaction function. +func (e *EntityHandle) ExecWorld(f func(tx *Tx, e Entity)) bool { + return e.execWorld(f, false) +} + +// execWorld uses a sync.Cond to synchronise access to the handler's world. We +// are dealing with a rather complicated synchronisation pattern here. The goal +// for ExecWorld is to block until e.w becomes accessible. Meanwhile, World.Exec +// may also affect e.w, which execWorld needs to deal with. +func (e *EntityHandle) execWorld(f func(tx *Tx, e Entity), weak bool) bool { + e.cond.L.Lock() + for e.w == nil || (!weak && e.weakTxActive) { + // Wait suspends the current goroutine and unlocks e.cond.L, until + // e.cond.Broadcast() is called. After this, one of the goroutines + // waiting will acquire a lock of e.cond.L again. This means that only + // one goroutine will run the code after this simultaneously. + e.cond.Wait() + } + // If a goroutine manages to exit the for loop, it will have acquired a lock + // on e.cond.L. This also means that e.w can be assumed to not be nil here. + // Because of the lock on e.cond.L, no other transaction will be able to + // change e.w until we finish. e.worldless is set to true in + // e.unsetAndLockWorld(), where the entity's world is removed. + e.worldless.Store(false) + if e.w == closeWorld { + // EntityHandle was closed. No need to continue. + e.cond.L.Unlock() + return false + } + // 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. + // 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 + // transaction turns out to be invalidated (ret == false), we simply try + // again, this time with e.execWorld(f, true) to make this goroutine bypass + // any goroutines still awaiting e.cond. + ret := e.weakExec(func(tx *Tx) { f(tx, e.mustEntity(tx)) }) + e.cond.L.Unlock() + + if !ret { + // Our weak transaction was suspended. We try again, this time with + // e.execWorld(f, true) to make this goroutine bypass any goroutines + // still awaiting e.cond. + return e.execWorld(f, true) + } + return true +} + +// weakExec performs a "weak transaction". It adds a transaction to the world +// that is invalidated when e.worldless is set to true. In this case, weakExec +// returns false. If the weak transaction is successfully executed, it returns +// true, and any calls to ExecWorld waiting on e.cond are awakened. The goal of +// weakExec is to suspend the current goroutine and unlock e.cond.L while +// waiting for previous transactions to finish. +func (e *EntityHandle) weakExec(f ExecFunc) bool { + e.weakTxActive = true + + // We create a weak transaction and start a for loop to listen for the + // length of the channel. This might look weird, but the crucial part here + // is the call to e.cond.Wait(), which unlocks e.cond.L. This is required + // to prevent a deadlock if an earlier transaction tries to change e.w. + c := e.w.weakExec(e.worldless, e.cond, f) + for len(c) == 0 && e.w != closeWorld { + // Calling e.cond.Wait() here will free the lock on e.cond.L until our + // transaction finishes. e.w.weakExec() ensures that e.cond.Broadcast() + // is called once the transaction finished/is suspended, so we can + // continue after that. + e.cond.Wait() + } + // If the EntityHandle was closed (e.w == closeWorld), we treat the + // transaction as successful, because all transactions must be cancelled. + if e.w != closeWorld && !<-c { + // Weak transaction was suspended. Return false and try again. + return false + } + // After setting e.weakTxActive back to false, we must Broadcast to make + // sure any goroutines waiting in e.execWorld as a result of the + // e.weakTxActive condition can continue. + e.weakTxActive = false + e.cond.Broadcast() + return true +} + +var closeWorld = &World{} + +// unsetAndLockWorld sets e.w to nil, causing any subsequent calls to ExecWorld +// to block until e.w is set to a non-nil value. +func (e *EntityHandle) unsetAndLockWorld() { + e.cond.L.Lock() + defer e.cond.L.Unlock() + + e.worldless.Store(true) + e.w = nil +} + +// setAndUnlockWorld sets e.w to a World passed and broadcasts e.cond, so that +// any goroutines waiting for a non-nil world are awoken. +func (e *EntityHandle) setAndUnlockWorld(w *World) { + 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.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) { + e.data.Pos = readVec3(m, "Pos") + e.data.Vel = readVec3(m, "Motion") + e.data.Rot = readRotation(m) + e.data.Age = time.Duration(readInt16(m, "Age")) * (time.Second / 20) + e.data.FireDuration = time.Duration(readInt16(m, "Fire")) * time.Second / 20 + e.data.Name, _ = m["NameTag"].(string) +} + +// encodeNBT encodes the position, velocity, rotation, age, on-fire duration and +// name tag of an entity. +func (e *EntityHandle) encodeNBT() map[string]any { + return map[string]any{ + "Pos": []float32{float32(e.data.Pos[0]), float32(e.data.Pos[1]), float32(e.data.Pos[2])}, + "Motion": []float32{float32(e.data.Vel[0]), float32(e.data.Vel[1]), float32(e.data.Vel[2])}, + "Yaw": float32(e.data.Rot[0]), + "Pitch": float32(e.data.Rot[1]), + "Fire": int16(e.data.FireDuration.Seconds() * 20), + "Age": int16(e.data.Age / (time.Second * 20)), + "NameTag": e.data.Name, + } +} + +// EntityData holds data shared by every entity. It is kept in an EntityHandle. +type EntityData struct { + Pos, Vel mgl64.Vec3 + Rot cube.Rotation + Name string + FireDuration time.Duration + Age time.Duration + + Data any +} + +// Entity represents an Entity in the world, typically an object that may be moved around and can be +// interacted with by other entities. +// Viewers of a world may view an Entity when near it. +type Entity interface { + io.Closer + // H returns the EntityHandle that points to the entity. + H() *EntityHandle + // Position returns the current position of the Entity in the world. + Position() mgl64.Vec3 + // Rotation returns the yaw (horizontal rotation) and pitch (vertical + // rotation) of the entity in degrees. + Rotation() cube.Rotation +} + +// TickerEntity represents an Entity that has a Tick method which should be called every time the Entity is +// ticked every 20th of a second. +type TickerEntity interface { + Entity + // Tick ticks the Entity with the current World and tick passed. + Tick(tx *Tx, current int64) +} + +// EntityAction represents an action that may be performed by an Entity. Typically, these actions are sent to +// viewers in a world so that they can see these actions. +type EntityAction interface { + EntityAction() +} + +// DamageSource represents the source of the damage dealt to an Entity. This +// source may be passed to the Hurt() method of an Entity in order to deal +// damage to an Entity with a specific source. +type DamageSource interface { + // ReducedByArmour checks if the source of damage may be reduced if the + // receiver of the damage is wearing armour. + ReducedByArmour() bool + // ReducedByResistance specifies if the Source is affected by the resistance + // effect. If false, damage dealt to an Entity with this source will not be + // lowered if the Entity has the resistance effect. + ReducedByResistance() bool + // Fire specifies if the Source is fire related and should be ignored when + // an Entity has the fire resistance effect. + Fire() bool + // IgnoreTotem specifies whether the totem will be ignored if the damage is lethal. + IgnoreTotem() bool +} + +// HealingSource represents a source of healing for an Entity. This source may +// be passed to the Heal() method of a living Entity. +type HealingSource interface { + HealingSource() +} + +// EntityRegistry is a mapping that EntityTypes may be registered to. It is used +// for loading entities from disk in a World's Provider. +type EntityRegistry struct { + conf EntityRegistryConfig + ent map[string]EntityType +} + +// EntityRegistryConfig holds functions used by the block and item packages to +// create entities as a result of their behaviour. ALL functions of +// EntityRegistryConfig must be filled out for the behaviour of these blocks and +// items not to fail. +type EntityRegistryConfig struct { + Item func(opts EntitySpawnOpts, it any) *EntityHandle + FallingBlock func(opts EntitySpawnOpts, bl Block) *EntityHandle + TNT func(opts EntitySpawnOpts, fuse time.Duration) *EntityHandle + BottleOfEnchanting func(opts EntitySpawnOpts, owner Entity) *EntityHandle + Arrow func(opts EntitySpawnOpts, conf ArrowSpawnConfig) *EntityHandle + Egg func(opts EntitySpawnOpts, owner Entity) *EntityHandle + EnderPearl func(opts EntitySpawnOpts, owner Entity) *EntityHandle + Firework func(opts EntitySpawnOpts, firework Item, owner Entity, sidewaysVelocityMultiplier, upwardsAcceleration float64, attached bool) *EntityHandle + LingeringPotion func(opts EntitySpawnOpts, t any, owner Entity) *EntityHandle + Snowball func(opts EntitySpawnOpts, owner Entity) *EntityHandle + SplashPotion func(opts EntitySpawnOpts, t any, owner Entity) *EntityHandle + Lightning func(opts EntitySpawnOpts) *EntityHandle +} + +// ArrowSpawnConfig holds the options used to spawn an arrow entity. +type ArrowSpawnConfig struct { + // Damage specifies the base damage dealt by the arrow. + Damage float64 + // Owner is the entity that fired the arrow. + Owner Entity + // Critical specifies if the arrow should deal critical damage. + Critical bool + // DisablePickup specifies if picking up the arrow should be disabled. + DisablePickup bool + // ObtainArrowOnPickup specifies if the arrow should be returned as an item when picked up. + ObtainArrowOnPickup bool + // PunchLevel specifies the level of punch knockback applied to the arrow. + PunchLevel int + // PiercingLevel is the crossbow Piercing enchantment level. The arrow passes + // through PiercingLevel entities and damages PiercingLevel+1 in total. A + // value of 0 means no piercing. + PiercingLevel int + // Tip specifies the potion tip carried by the arrow. + Tip any +} + +// New creates an EntityRegistry using conf and the EntityTypes passed. +func (conf EntityRegistryConfig) New(ent []EntityType) EntityRegistry { + m := make(map[string]EntityType, len(ent)) + for _, e := range ent { + name := e.EncodeEntity() + if _, ok := m[name]; ok { + panic("cannot register the same entity (" + name + ") twice") + } + m[name] = e + } + return EntityRegistry{conf: conf, ent: m} +} + +// Config returns the EntityRegistryConfig that was used to create the +// EntityRegistry. +func (reg EntityRegistry) Config() EntityRegistryConfig { + return reg.conf +} + +// Lookup looks up an EntityType by its name. If found, the EntityType is +// returned and the bool is true. The bool is false otherwise. +func (reg EntityRegistry) Lookup(name string) (EntityType, bool) { + t, ok := reg.ent[name] + return t, ok +} + +// Types returns all EntityTypes passed upon construction of the EntityRegistry. +func (reg EntityRegistry) Types() []EntityType { + return slices.Collect(maps.Values(reg.ent)) +} + +func readVec3(x map[string]any, k string) mgl64.Vec3 { + if i, ok := x[k].([]any); ok { + if len(i) != 3 { + return mgl64.Vec3{} + } + var v mgl64.Vec3 + for index, f := range i { + f32, _ := f.(float32) + v[index] = float64(f32) + } + return v + } else if i, ok := x[k].([]float32); ok { + if len(i) != 3 { + return mgl64.Vec3{} + } + return mgl64.Vec3{float64(i[0]), float64(i[1]), float64(i[2])} + } + return mgl64.Vec3{} +} + +func readFloat32(m map[string]any, k string) float32 { + v, _ := m[k].(float32) + return v +} + +func readRotation(m map[string]any) cube.Rotation { + return cube.Rotation{float64(readFloat32(m, "Yaw")), float64(readFloat32(m, "Pitch"))} +} + +func readInt16(m map[string]any, k string) int16 { + v, _ := m[k].(int16) + return v +} diff --git a/server/world/entity_animation.go b/server/world/entity_animation.go new file mode 100644 index 0000000..b9f459c --- /dev/null +++ b/server/world/entity_animation.go @@ -0,0 +1,63 @@ +package world + +// EntityAnimation represents an animation that may be played on an entity from an active resource pack on +// the client. +type EntityAnimation struct { + name string + nextState string + controller string + stopCondition string +} + +// NewEntityAnimation returns a new animation that can be played on an entity. If no controller or stop +// condition is set, the animation will play for its full duration, including looping. Controllers can be set +// to manage multiple states of animations. It is also possible to use vanilla animations/controllers if they +// work for your entity, i.e. "animation.pig.baby_transform". +func NewEntityAnimation(name string) EntityAnimation { + return EntityAnimation{name: name} +} + +// Name returns the name of the animation to be played. +func (a EntityAnimation) Name() string { + return a.name +} + +// Controller returns the name of the controller to be used for the animation. +func (a EntityAnimation) Controller() string { + return a.controller +} + +// WithController returns a copy of the EntityAnimation with the provided animation controller. An animation +// controller with the same name must be defined in a resource pack for it to work. +func (a EntityAnimation) WithController(controller string) EntityAnimation { + a.controller = controller + return a +} + +// NextState returns the state to transition to after the animation has finished playing within the +// animation controller. +func (a EntityAnimation) NextState() string { + return a.nextState +} + +// WithNextState returns a copy of the EntityAnimation with the provided state to transition to after the +// animation has finished playing within the animation controller. +func (a EntityAnimation) WithNextState(state string) EntityAnimation { + a.nextState = state + return a +} + +// StopCondition returns the condition that must be met for the animation to stop playing. This is often +// a Molang expression that can be used to query various entity properties to determine when the animation +// should stop playing. +func (a EntityAnimation) StopCondition() string { + return a.stopCondition +} + +// WithStopCondition returns a copy of the EntityAnimation with the provided stop condition. The stop condition +// is a Molang expression that can be used to query various entity properties to determine when the animation +// should stop playing. +func (a EntityAnimation) WithStopCondition(condition string) EntityAnimation { + a.stopCondition = condition + return a +} diff --git a/server/world/game_mode.go b/server/world/game_mode.go new file mode 100644 index 0000000..0ae79f0 --- /dev/null +++ b/server/world/game_mode.go @@ -0,0 +1,142 @@ +package world + +// GameMode represents a game mode that may be assigned to a player. Upon joining the world, players will be +// given the default game mode that the world holds. +// Game modes specify the way that a player interacts with and plays in the world. +type GameMode interface { + // AllowsEditing specifies if a player with this GameMode can edit the World it's in. + AllowsEditing() bool + // AllowsTakingDamage specifies if a player with this GameMode can take damage from other entities. + AllowsTakingDamage() bool + // CreativeInventory specifies if a player with this GameMode has access to the creative inventory. + CreativeInventory() bool + // HasCollision specifies if a player with this GameMode can collide with blocks or entities in the world. + HasCollision() bool + // AllowsFlying specifies if a player with this GameMode can fly freely. + AllowsFlying() bool + // AllowsInteraction specifies if a player with this GameMode can interact with the world through entities or if it + // can use items in the world. + AllowsInteraction() bool + // Visible specifies if a player with this GameMode can be visible to other players. If false, the player will be + // invisible under any circumstance. + Visible() bool +} + +var ( + // GameModeSurvival is the survival game mode: Players with this game mode have limited supplies and can break blocks + // after taking some time. + GameModeSurvival survival + // GameModeCreative represents the creative game mode: Players with this game mode have infinite blocks and + // items and can break blocks instantly. Players with creative mode can also fly. + GameModeCreative creative + // GameModeAdventure represents the adventure game mode: Players with this game mode cannot edit the world + // (placing or breaking blocks). + GameModeAdventure adventure + // GameModeSpectator represents the spectator game mode: Players with this game mode cannot interact with the + // world and cannot be seen by other players. spectator players can fly, like creative mode, and can + // move through blocks. + GameModeSpectator spectator +) + +var gameModeReg = newGameModeRegistry(map[int]GameMode{ + 0: GameModeSurvival, + 1: GameModeCreative, + 2: GameModeAdventure, + 3: GameModeSpectator, +}) + +// GameModeByID looks up a GameMode for the ID passed, returning +// GameModeSurvival for 0, GameModeCreative for 1, GameModeAdventure for 2 and +// GameModeSpectator for 3. If the ID is unknown, the bool returned is false. In +// this case the GameMode returned is GameModeSurvival. +func GameModeByID(id int) (GameMode, bool) { + return gameModeReg.Lookup(id) +} + +// GameModeID looks up the ID that a GameMode was registered with. If not +// found, false is returned. +func GameModeID(mode GameMode) (int, bool) { + return gameModeReg.LookupID(mode) +} + +type gameModeRegistry struct { + gameModes map[int]GameMode + ids map[GameMode]int +} + +// newGameModeRegistry returns an initialised gameModeRegistry. +func newGameModeRegistry(mode map[int]GameMode) *gameModeRegistry { + ids := make(map[GameMode]int, len(mode)) + for k, v := range mode { + ids[v] = k + } + return &gameModeRegistry{gameModes: mode, ids: ids} +} + +// Lookup looks up a GameMode for the ID passed, returning GameModeSurvival for +// 0, GameModeCreative for 1, GameModeAdventure for 2 and GameModeSpectator for +// 3. If the ID is unknown, the bool returned is false. In this case the +// GameMode returned is GameModeSurvival. +func (reg *gameModeRegistry) Lookup(id int) (GameMode, bool) { + mode, ok := reg.gameModes[id] + if !ok { + mode = GameModeSurvival + } + return mode, ok +} + +// LookupID looks up the ID that a GameMode was registered with. If not found, +// false is returned. +func (reg *gameModeRegistry) LookupID(mode GameMode) (int, bool) { + id, ok := reg.ids[mode] + return id, ok +} + +// survival is the survival game mode: Players with this game mode have limited supplies and can break blocks after +// taking some time. +type survival struct{} + +func (survival) AllowsEditing() bool { return true } +func (survival) AllowsTakingDamage() bool { return true } +func (survival) CreativeInventory() bool { return false } +func (survival) HasCollision() bool { return true } +func (survival) AllowsFlying() bool { return false } +func (survival) AllowsInteraction() bool { return true } +func (survival) Visible() bool { return true } + +// creative represents the creative game mode: Players with this game mode have infinite blocks and +// items and can break blocks instantly. Players with creative mode can also fly. +type creative struct{} + +func (creative) AllowsEditing() bool { return true } +func (creative) AllowsTakingDamage() bool { return false } +func (creative) CreativeInventory() bool { return true } +func (creative) HasCollision() bool { return true } +func (creative) AllowsFlying() bool { return true } +func (creative) AllowsInteraction() bool { return true } +func (creative) Visible() bool { return true } + +// adventure represents the adventure game mode: Players with this game mode cannot edit the world +// (placing or breaking blocks). +type adventure struct{} + +func (adventure) AllowsEditing() bool { return false } +func (adventure) AllowsTakingDamage() bool { return true } +func (adventure) CreativeInventory() bool { return false } +func (adventure) HasCollision() bool { return true } +func (adventure) AllowsFlying() bool { return false } +func (adventure) AllowsInteraction() bool { return true } +func (adventure) Visible() bool { return true } + +// spectator represents the spectator game mode: Players with this game mode cannot interact with the +// world and cannot be seen by other players. spectator players can fly, like creative mode, and can +// move through blocks. +type spectator struct{} + +func (spectator) AllowsEditing() bool { return false } +func (spectator) AllowsTakingDamage() bool { return false } +func (spectator) CreativeInventory() bool { return false } +func (spectator) HasCollision() bool { return false } +func (spectator) AllowsFlying() bool { return true } +func (spectator) AllowsInteraction() bool { return false } +func (spectator) Visible() bool { return false } diff --git a/server/world/generator.go b/server/world/generator.go new file mode 100644 index 0000000..6afd07b --- /dev/null +++ b/server/world/generator.go @@ -0,0 +1,26 @@ +package world + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world/chunk" +) + +// Generator handles the generating of newly created chunks. Worlds have one generator which is used to +// generate chunks when the provider of the world cannot find a chunk at a given chunk position. +type Generator interface { + // GenerateChunk generates a chunk at a chunk position passed. The generator sets blocks in the chunk that + // is passed to the method. + GenerateChunk(pos ChunkPos, chunk *chunk.Chunk) + // DefaultSpawn returns the default spawn position for worlds using this generator in the dimension passed. + DefaultSpawn(dim Dimension) cube.Pos +} + +// NopGenerator is the default generator a world. It places no blocks in the world which results in a void +// world. +type NopGenerator struct{} + +// GenerateChunk ... +func (NopGenerator) GenerateChunk(ChunkPos, *chunk.Chunk) {} + +// DefaultSpawn ... +func (NopGenerator) DefaultSpawn(Dimension) cube.Pos { return cube.Pos{} } diff --git a/server/world/generator/flat.go b/server/world/generator/flat.go new file mode 100644 index 0000000..f221b96 --- /dev/null +++ b/server/world/generator/flat.go @@ -0,0 +1,59 @@ +package generator + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/chunk" +) + +// Flat is the flat generator of World. It generates flat worlds (like those in vanilla) with no other +// decoration. It may be constructed by calling NewFlat. +type Flat struct { + // biome is the encoded biome that the generator should use. + biome uint32 + // layers is a list of block runtime ID layers placed by the Flat generator. The layers are ordered in a way where + // the last element in the slice is placed as the bottom-most block of the chunk. + layers []uint32 +} + +// NewFlat creates a new Flat generator. Chunks generated are completely filled with the world.Biome passed. layers is a +// list of block layers placed by the Flat generator. The layers are ordered in a way where the last element in the +// slice is placed as the bottom-most block of the chunk. +func NewFlat(biome world.Biome, layers []world.Block) Flat { + return NewFlatWithRegistry(biome, layers, world.DefaultBlockRegistry) +} + +// NewFlatWithRegistry creates a new Flat generator using the block registry passed to resolve layers to runtime IDs. +// Use this constructor when the generator is used in a World with a non-default block registry. +func NewFlatWithRegistry(biome world.Biome, layers []world.Block, br world.BlockRegistry) Flat { + f := Flat{ + biome: uint32(biome.EncodeBiome()), + layers: make([]uint32, len(layers)), + } + for i, b := range layers { + f.layers[i] = br.BlockRuntimeID(b) + } + return f +} + +// GenerateChunk ... +func (f Flat) GenerateChunk(pos world.ChunkPos, chunk *chunk.Chunk) { + min, max := int16(chunk.Range().Min()), int16(chunk.Range().Max()) + n := int16(len(f.layers)) + + for x := range uint8(16) { + for z := range uint8(16) { + for y := int16(0); y <= max; y++ { + if y < n { + chunk.SetBlock(x, min+y, z, 0, f.layers[n-y-1]) + } + chunk.SetBiome(x, min+y, z, f.biome) + } + } + } +} + +// DefaultSpawn ... +func (f Flat) DefaultSpawn(dim world.Dimension) cube.Pos { + return cube.Pos{0, dim.Range().Min() + len(f.layers) + 1, 0} +} diff --git a/server/world/handler.go b/server/world/handler.go new file mode 100644 index 0000000..bdd248a --- /dev/null +++ b/server/world/handler.go @@ -0,0 +1,96 @@ +package world + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/event" + "github.com/go-gl/mathgl/mgl64" +) + +type Context = event.Context[*Tx] + +// Handler handles events that are called by a world. Implementations of +// Handler may be used to listen to specific events such as when an Entity is +// added to the world. +type Handler interface { + // HandleLiquidFlow handles the flowing of a liquid from one block position + // from into another block position into. The liquid that will replace the + // block is also passed. This replaced block might also be a Liquid. The + // Liquid's depth and falling state can be checked to see if the resulting + // liquid is a new source block (in the case of water). + HandleLiquidFlow(ctx *Context, from, into cube.Pos, liquid Liquid, replaced Block) + // HandleLiquidDecay handles the decaying of a Liquid block at a position. + // Liquid decaying happens when there is no Liquid that can serve as the + // source block neighbouring it. The state of the Liquid before and after + // the decaying is passed. The Liquid after is nil if the liquid is + // completely removed as a result of the decay. + HandleLiquidDecay(ctx *Context, pos cube.Pos, before, after Liquid) + // HandleLiquidHarden handles the hardening of a liquid at hardenedPos. The + // liquid that was hardened, liquidHardened, and the liquid that caused it + // to harden, otherLiquid, are passed. The block created as a result is also + // passed. + HandleLiquidHarden(ctx *Context, hardenedPos cube.Pos, liquidHardened, otherLiquid, newBlock Block) + // HandleSound handles a Sound being played in the World at a specific + // position. ctx.Cancel() may be called to stop the Sound from playing to + // viewers of the position. + HandleSound(ctx *Context, s Sound, pos mgl64.Vec3) + // HandleFireSpread handles when a fire block spreads from one block to + // another block. When this event handler gets called, both the position of + // the original fire will be passed, and the position where it will spread + // to after the event. The age of the fire may also be altered by changing + // the underlying value of the newFireAge pointer, which decides how long + // the fire will stay before burning out. + HandleFireSpread(ctx *Context, from, to cube.Pos) + // HandleBlockBurn handles a block at a cube.Pos being burnt by fire. This + // event may be called for blocks such as wood, that can be broken by fire. + // HandleBlockBurn is often succeeded by HandleFireSpread, when fire spreads + // to the position of the original block and the Context is not cancelled in + // HandleBlockBurn. + HandleBlockBurn(ctx *Context, pos cube.Pos) + // HandleCropTrample handles an Entity trampling a crop. + HandleCropTrample(ctx *Context, pos cube.Pos) + // HandleLeavesDecay handles the decaying of a Leaves block at a position. + // Leaves decaying happens when there is no wood block neighbouring it. + // 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. + HandleEntitySpawn(tx *Tx, e Entity) + // HandleEntityDespawn handles an Entity being despawned from a World + // through a call to Tx.RemoveEntity. + HandleEntityDespawn(tx *Tx, e Entity) + // HandleExplosion handles an explosion in the world. ctx.Cancel() may be called + // to cancel the explosion. + // The affected entities, affected blocks, item drop chance, and whether the + // explosion spawns fire may be altered. + HandleExplosion(ctx *Context, position mgl64.Vec3, entities *[]Entity, blocks *[]cube.Pos, itemDropChance *float64, spawnFire *bool) + // HandleRedstoneUpdate handles a redstone update at a position. ctx.Cancel() may be called + // to cancel the redstone update. + HandleRedstoneUpdate(ctx *Context, pos cube.Pos) + // HandleClose handles the World being closed. HandleClose may be used as a + // moment to finish code running on other goroutines that operates on the + // World specifically. HandleClose is called directly before the World stops + // ticking and before any chunks are saved to disk. + HandleClose(tx *Tx) +} + +// Compile time check to make sure NopHandler implements Handler. +var _ Handler = (*NopHandler)(nil) + +// NopHandler implements the Handler interface but does not execute any code +// when an event is called. The default Handler of worlds is set to NopHandler. +// Users may embed NopHandler to avoid having to implement each method. +type NopHandler struct{} + +func (NopHandler) HandleLiquidFlow(*Context, cube.Pos, cube.Pos, Liquid, Block) {} +func (NopHandler) HandleLiquidDecay(*Context, cube.Pos, Liquid, Liquid) {} +func (NopHandler) HandleLiquidHarden(*Context, cube.Pos, Block, Block, Block) {} +func (NopHandler) HandleSound(*Context, Sound, mgl64.Vec3) {} +func (NopHandler) HandleFireSpread(*Context, cube.Pos, cube.Pos) {} +func (NopHandler) HandleBlockBurn(*Context, cube.Pos) {} +func (NopHandler) HandleCropTrample(*Context, cube.Pos) {} +func (NopHandler) HandleLeavesDecay(*Context, cube.Pos) {} +func (NopHandler) HandleEntitySpawn(*Tx, Entity) {} +func (NopHandler) HandleEntityDespawn(*Tx, Entity) {} +func (NopHandler) HandleExplosion(*Context, mgl64.Vec3, *[]Entity, *[]cube.Pos, *float64, *bool) {} +func (NopHandler) HandleRedstoneUpdate(*Context, cube.Pos) {} +func (NopHandler) HandleClose(*Tx) {} diff --git a/server/world/item.go b/server/world/item.go new file mode 100644 index 0000000..9fb5961 --- /dev/null +++ b/server/world/item.go @@ -0,0 +1,132 @@ +package world + +import ( + _ "embed" + "fmt" + "github.com/df-mc/dragonfly/server/item/category" + "github.com/sandertv/gophertunnel/minecraft/nbt" + "image" +) + +// Item represents an item that may be added to an inventory. It has a method to encode the item to an ID and +// a metadata value. +type Item interface { + // EncodeItem encodes the item to its Minecraft representation, which consists of a numerical ID and a + // metadata value. + EncodeItem() (name string, meta int16) +} + +// CustomItem represents an item that is non-vanilla and requires a resource pack and extra steps to show it +// to the client. +type CustomItem interface { + Item + // Name is the name that will be displayed on the item to all clients. + Name() string + // Texture is the Image of the texture for this item. + Texture() image.Image + // Category is the category the item will be listed under in the creative inventory. + Category() category.Category +} + +// RegisterItem registers an item with the ID and meta passed. Once registered, items may be obtained from an +// ID and metadata value using itemByID(). +// If an item with the ID and meta passed already exists, RegisterItem panics. +func RegisterItem(item Item) { + name, meta := item.EncodeItem() + h := itemHash{name: name, meta: meta} + + if _, ok := items[h]; ok { + panic(fmt.Sprintf("item registered with name %v and meta %v already exists", name, meta)) + } + if c, ok := item.(CustomItem); ok { + nextRID := int32(len(itemNamesToRuntimeIDs)) + itemRuntimeIDsToNames[nextRID] = name + itemNamesToRuntimeIDs[name] = nextRID + + customItems = append(customItems, c) + } + if _, ok := itemNamesToRuntimeIDs[name]; !ok { + panic(fmt.Sprintf("item name %v does not have a runtime ID", name)) + } + items[h] = item +} + +// itemHash is a combination of an item's name and metadata. It is used as a key in hash maps. +type itemHash struct { + name string + meta int16 +} + +var ( + //go:embed vanilla_items.nbt + itemRuntimeIDData []byte + // items holds a list of all registered items, indexed using the itemHash created when calling + // Item.EncodeItem. + items = map[itemHash]Item{} + // customItems holds a list of all registered custom items. + customItems []CustomItem + // itemRuntimeIDsToNames holds a map to translate item runtime IDs to string IDs. + itemRuntimeIDsToNames = map[int32]string{} + // itemNamesToRuntimeIDs holds a map to translate item string IDs to runtime IDs. + itemNamesToRuntimeIDs = map[string]int32{} +) + +// init reads all item entries from the resource JSON, and sets the according values in the runtime ID maps. +func init() { + var m map[string]struct { + RuntimeID int32 `nbt:"runtime_id"` + ComponentBased bool `nbt:"component_based"` + Version int32 `nbt:"version"` + Data map[string]any `nbt:"data,omitempty"` + } + err := nbt.Unmarshal(itemRuntimeIDData, &m) + if err != nil { + panic(err) + } + for name, e := range m { + itemNamesToRuntimeIDs[name] = e.RuntimeID + itemRuntimeIDsToNames[e.RuntimeID] = name + } +} + +// ItemByName attempts to return an item by a name and a metadata value. +func ItemByName(name string, meta int16) (Item, bool) { + it, ok := items[itemHash{name: name, meta: meta}] + if !ok { + // Also try obtaining the item with a metadata value of 0, for cases with durability. + it, ok = items[itemHash{name: name}] + } + return it, ok +} + +// ItemRuntimeID attempts to return the runtime ID of the Item passed. False is returned if the Item is not +// registered. +func ItemRuntimeID(i Item) (rid int32, meta int16, ok bool) { + name, meta := i.EncodeItem() + rid, ok = itemNamesToRuntimeIDs[name] + return rid, meta, ok +} + +// ItemByRuntimeID attempts to return an Item by the runtime ID passed. If no item with that runtime ID exists, +// false is returned. ItemByRuntimeID also tries to find the item with a metadata value of 0. +func ItemByRuntimeID(rid int32, meta int16) (Item, bool) { + name, ok := itemRuntimeIDsToNames[rid] + if !ok { + return nil, false + } + return ItemByName(name, meta) +} + +// Items returns a slice of all registered items. +func Items() []Item { + m := make([]Item, 0, len(items)) + for _, i := range items { + m = append(m, i) + } + return m +} + +// CustomItems returns a slice of all registered custom items. +func CustomItems() []CustomItem { + return customItems +} diff --git a/server/world/loader.go b/server/world/loader.go new file mode 100644 index 0000000..e76e867 --- /dev/null +++ b/server/world/loader.go @@ -0,0 +1,199 @@ +package world + +import ( + "github.com/go-gl/mathgl/mgl64" + "maps" + "math" + "sync" +) + +// Loader implements the loading of the world. A loader can typically be moved around the world to load +// different parts of the world. An example usage is the player, which uses a loader to load chunks around it +// so that it can view them. +type Loader struct { + r int + w *World + viewer Viewer + + mu sync.RWMutex + pos ChunkPos + loadQueue []ChunkPos + loaded map[ChunkPos]*Column + + closed bool +} + +// NewLoader creates a new loader using the chunk radius passed. Chunks beyond this radius from the position +// of the loader will never be loaded. +// The Viewer passed will handle the loading of chunks, including the viewing of entities that were loaded in +// those chunks. +func NewLoader(chunkRadius int, world *World, v Viewer) *Loader { + l := &Loader{r: chunkRadius, loaded: make(map[ChunkPos]*Column), viewer: v} + l.world(world) + return l +} + +// World returns the World that the Loader is in. +func (l *Loader) World() *World { + l.mu.RLock() + defer l.mu.RUnlock() + return l.w +} + +// ChangeWorld changes the World of the Loader. The currently loaded chunks are reset and any future loading +// is done from the new World. +func (l *Loader) ChangeWorld(tx *Tx, new *World) { + l.mu.Lock() + defer l.mu.Unlock() + + loaded := maps.Clone(l.loaded) + l.w.Exec(func(tx *Tx) { + for pos := range loaded { + tx.World().removeViewer(tx, pos, l) + } + }) + clear(l.loaded) + l.w.viewerMu.Lock() + delete(l.w.viewers, l) + l.w.viewerMu.Unlock() + + l.world(new) +} + +// ChangeRadius changes the maximum chunk radius of the Loader. +func (l *Loader) ChangeRadius(tx *Tx, new int) { + l.mu.Lock() + defer l.mu.Unlock() + + l.r = new + l.evictUnused(tx) + l.populateLoadQueue() +} + +// Move moves the loader to the position passed. The position is translated to a chunk position to load +func (l *Loader) Move(tx *Tx, pos mgl64.Vec3) { + l.mu.Lock() + defer l.mu.Unlock() + + chunkPos := chunkPosFromVec3(pos) + if chunkPos == l.pos { + return + } + l.pos = chunkPos + l.evictUnused(tx) + l.populateLoadQueue() +} + +// Load loads n chunks around the centre of the chunk, starting with the middle and working outwards. For +// every chunk loaded, the Viewer passed through construction in New has its ViewChunk method called. +// Load does nothing for n <= 0. +func (l *Loader) Load(tx *Tx, n int) { + l.mu.Lock() + defer l.mu.Unlock() + + if l.closed || l.w == nil { + return + } + for i := 0; i < n; i++ { + if len(l.loadQueue) == 0 { + break + } + + pos := l.loadQueue[0] + c := tx.w.chunk(pos) + + l.viewer.ViewChunk(pos, l.w.Dimension(), c.BlockEntities, c.Chunk) + l.w.addViewer(tx, c, l) + + l.loaded[pos] = c + + // Shift the first element from the load queue off so that we can take a new one during the next + // iteration. + l.loadQueue = l.loadQueue[1:] + } +} + +// Chunk attempts to return a chunk at the given ChunkPos. If the chunk is not loaded, the second return value will +// be false. +func (l *Loader) Chunk(pos ChunkPos) (*Column, bool) { + l.mu.RLock() + c, ok := l.loaded[pos] + l.mu.RUnlock() + return c, ok +} + +// Close closes the loader. It unloads all chunks currently loaded for the viewer, and hides all entities that +// are currently shown to it. +func (l *Loader) Close(tx *Tx) { + l.mu.Lock() + defer l.mu.Unlock() + + for pos := range l.loaded { + tx.World().removeViewer(tx, pos, l) + } + l.loaded = map[ChunkPos]*Column{} + + l.w.viewerMu.Lock() + delete(l.w.viewers, l) + l.w.viewerMu.Unlock() + + l.closed = true + l.viewer = nil +} + +// world sets the loader's world, adds them to the world's viewer list, then starts populating the load queue. +// This is only here to get rid of duplicated code, ChangeWorld should be used instead of this. +func (l *Loader) world(new *World) { + l.w = new + l.w.addWorldViewer(l) + l.populateLoadQueue() +} + +// evictUnused gets rid of chunks in the loaded map which are no longer within the chunk radius of the loader, +// and should therefore be removed. +func (l *Loader) evictUnused(tx *Tx) { + for pos := range l.loaded { + diffX, diffZ := pos[0]-l.pos[0], pos[1]-l.pos[1] + dist := math.Sqrt(float64(diffX*diffX) + float64(diffZ*diffZ)) + if int(dist) > l.r { + delete(l.loaded, pos) + l.w.removeViewer(tx, pos, l) + } + } +} + +// populateLoadQueue populates the load queue of the loader. This method is called once to create the order in +// which chunks around the position the loader is now in should be loaded. Chunks are ordered to be loaded +// from the middle outwards. +func (l *Loader) populateLoadQueue() { + // We'll first load the chunk positions to load in a map indexed by the distance to the centre (basically, + // what precedence it should have), and put them in the loadQueue in that order. + queue := map[int32][]ChunkPos{} + + r := int32(l.r) + for x := -r; x <= r; x++ { + for z := -r; z <= r; z++ { + distance := math.Sqrt(float64(x*x) + float64(z*z)) + chunkDistance := int32(math.Round(distance)) + if chunkDistance > r { + // The chunk was outside the chunk radius. + continue + } + pos := ChunkPos{x + l.pos[0], z + l.pos[1]} + if _, ok := l.loaded[pos]; ok { + // The chunk was already loaded, so we don't need to do anything. + continue + } + if m, ok := queue[chunkDistance]; ok { + queue[chunkDistance] = append(m, pos) + continue + } + queue[chunkDistance] = []ChunkPos{pos} + } + } + + l.loadQueue = l.loadQueue[:0] + for i := int32(0); i < r; i++ { + l.loadQueue = append(l.loadQueue, queue[i]...) + } +} diff --git a/server/world/mcdb/conf.go b/server/world/mcdb/conf.go new file mode 100644 index 0000000..83d46d7 --- /dev/null +++ b/server/world/mcdb/conf.go @@ -0,0 +1,72 @@ +package mcdb + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/mcdb/leveldat" + "github.com/df-mc/goleveldb/leveldb" + "github.com/df-mc/goleveldb/leveldb/opt" +) + +// Config holds the optional parameters of a DB. +type Config struct { + // Log is the Logger that will be used to log errors and debug messages to. + // If set to nil, Log is set to slog.Default(). + Log *slog.Logger + // LDBOptions holds LevelDB specific default options, such as the block size + // or compression used in the database. + LDBOptions *opt.Options + + // Blocks is the BlockRegistry used for chunk decoding/encoding. If nil, world.DefaultBlockRegistry is used. + // When using a non-default registry, pass the same registry used by the World. + Blocks world.BlockRegistry +} + +// Open creates a new DB reading and writing from/to files under the path +// passed. If a world is present at the path, Open will parse its data and +// initialise the world with it. If the data cannot be parsed, an error is +// returned. +func (conf Config) Open(dir string) (*DB, error) { + if conf.Log == nil { + conf.Log = slog.Default() + } + conf.Log = conf.Log.With("provider", "mcdb") + if conf.LDBOptions == nil { + conf.LDBOptions = new(opt.Options) + } + if conf.LDBOptions.BlockSize == 0 { + conf.LDBOptions.BlockSize = 16 * opt.KiB + } + + _ = os.MkdirAll(filepath.Join(dir, "db"), 0777) + + db := &DB{conf: conf, dir: dir, ldat: &leveldat.Data{}} + db.SetBlockRegistry(conf.Blocks) + if _, err := os.Stat(filepath.Join(dir, "level.dat")); os.IsNotExist(err) { + // A level.dat was not currently present for the world. + db.ldat.FillDefault() + } else { + ldat, err := leveldat.ReadFile(filepath.Join(dir, "level.dat")) + if err != nil { + return nil, fmt.Errorf("open db: read level.dat: %w", err) + } + ver := ldat.Ver() + if ver != leveldat.Version && ver >= 10 { + return nil, fmt.Errorf("open db: level.dat version %v is unsupported", ver) + } + if err = ldat.Unmarshal(db.ldat); err != nil { + return nil, fmt.Errorf("open db: unmarshal level.dat: %w", err) + } + } + db.set = db.ldat.Settings() + ldb, err := leveldb.OpenFile(filepath.Join(dir, "db"), conf.LDBOptions) + if err != nil { + return nil, fmt.Errorf("open db: leveldb: %w", err) + } + db.ldb = ldb + return db, nil +} diff --git a/server/world/mcdb/db.go b/server/world/mcdb/db.go new file mode 100644 index 0000000..06b97ae --- /dev/null +++ b/server/world/mcdb/db.go @@ -0,0 +1,544 @@ +package mcdb + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "math/rand/v2" + "os" + "path/filepath" + "slices" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/chunk" + "github.com/df-mc/dragonfly/server/world/mcdb/leveldat" + "github.com/df-mc/goleveldb/leveldb" + "github.com/google/uuid" + "github.com/sandertv/gophertunnel/minecraft/nbt" +) + +// DB implements a world provider for the Minecraft world format, which +// is based on a leveldb database. +type DB struct { + conf Config + ldb *leveldb.DB + dir string + ldat *leveldat.Data + set *world.Settings +} + +// Open creates a new provider reading and writing from/to files under the path +// passed using default options. If a world is present at the path, Open will +// parse its data and initialise the world with it. If the data cannot be +// parsed, an error is returned. +func Open(dir string) (*DB, error) { + var conf Config + return conf.Open(dir) +} + +// SetBlockRegistry updates the block registry used for chunk and scheduled update encoding/decoding. +// It should be called before any columns are loaded or stored. +func (db *DB) SetBlockRegistry(br world.BlockRegistry) { + if br == nil { + br = world.DefaultBlockRegistry + } + br.Finalize() + db.conf.Blocks = br +} + +// Settings returns the world.Settings of the world loaded by the DB. +func (db *DB) Settings() *world.Settings { + return db.set +} + +// SaveSettings saves the world.Settings passed to the level.dat. +func (db *DB) SaveSettings(s *world.Settings) { + db.ldat.PutSettings(s) +} + +// playerData holds the fields that indicate where player data is stored for a player with a specific UUID. +type playerData struct { + UUID string `nbt:"MsaId"` + ServerID string `nbt:"ServerId"` + SelfSignedID string `nbt:"SelfSignedId"` +} + +// LoadPlayerSpawnPosition loads the players spawn position stored in the level.dat from their UUID. +func (db *DB) LoadPlayerSpawnPosition(id uuid.UUID) (pos cube.Pos, exists bool, err error) { + serverData, _, exists, err := db.loadPlayerData(id) + if !exists || err != nil { + return cube.Pos{}, exists, err + } + x, y, z := serverData["SpawnX"], serverData["SpawnY"], serverData["SpawnZ"] + if x == nil || y == nil || z == nil { + return cube.Pos{}, true, fmt.Errorf("error reading spawn fields from server data for player %v", id) + } + return cube.Pos{int(x.(int32)), int(y.(int32)), int(z.(int32))}, true, nil +} + +// loadPlayerData loads the data stored in a LevelDB database for a specific UUID. +func (db *DB) loadPlayerData(id uuid.UUID) (serverData map[string]interface{}, key string, exists bool, err error) { + data, err := db.ldb.Get([]byte("player_"+id.String()), nil) + if errors.Is(err, leveldb.ErrNotFound) { + return nil, "", false, nil + } else if err != nil { + return nil, "", true, fmt.Errorf("error reading player data for uuid %v: %w", id, err) + } + + var d playerData + if err := nbt.UnmarshalEncoding(data, &d, nbt.LittleEndian); err != nil { + return nil, "", true, fmt.Errorf("error decoding player data for uuid %v: %w", id, err) + } + if d.UUID != id.String() || d.ServerID == "" { + return nil, d.ServerID, true, fmt.Errorf("invalid player data for uuid %v: %v", id, d) + } + serverDB, err := db.ldb.Get([]byte(d.ServerID), nil) + if err != nil { + return nil, d.ServerID, true, fmt.Errorf("error reading server data for player %v (%v): %w", id, d.ServerID, err) + } + + if err := nbt.UnmarshalEncoding(serverDB, &serverData, nbt.LittleEndian); err != nil { + return nil, d.ServerID, true, fmt.Errorf("error decoding server data for player %v", id) + } + return serverData, d.ServerID, true, nil +} + +// SavePlayerSpawnPosition saves the player spawn position passed to the levelDB database. +func (db *DB) SavePlayerSpawnPosition(id uuid.UUID, pos cube.Pos) error { + _, err := db.ldb.Get([]byte("player_"+id.String()), nil) + d := make(map[string]interface{}) + k := "player_server_" + id.String() + + if errors.Is(err, leveldb.ErrNotFound) { + data, err := nbt.MarshalEncoding(playerData{UUID: id.String(), ServerID: k}, nbt.LittleEndian) + if err != nil { + panic(err) + } + if err := db.ldb.Put([]byte("player_"+id.String()), data, nil); err != nil { + return fmt.Errorf("write player data (uuid=%v): %w", id, err) + } + } else if d, k, _, err = db.loadPlayerData(id); err != nil { + return err + } + d["SpawnX"], d["SpawnY"], d["SpawnZ"] = int32(pos.X()), int32(pos.Y()), int32(pos.Z()) + + data, err := nbt.MarshalEncoding(d, nbt.LittleEndian) + if err != nil { + panic(err) + } + if err = db.ldb.Put([]byte(k), data, nil); err != nil { + return fmt.Errorf("write server data for player %v: %w", id, err) + } + return nil +} + +// LoadColumn reads a world.Column from the DB at a position and dimension in +// the DB. If no column at that position exists, errors.Is(err, +// leveldb.ErrNotFound) equals true. +func (db *DB) LoadColumn(pos world.ChunkPos, dim world.Dimension) (*chunk.Column, error) { + k := dbKey{pos: pos, dim: dim} + col, err := db.column(k) + if err != nil { + return nil, fmt.Errorf("load column %v (%v): %w", pos, dim, err) + } + return col, nil +} + +const chunkVersion = 42 + +func (db *DB) column(k dbKey) (*chunk.Column, error) { + var cdata chunk.SerialisedData + col := new(chunk.Column) + + ver, err := db.version(k) + if err != nil { + return nil, fmt.Errorf("read version: %w", err) + } + if ver != chunkVersion { + db.conf.Log.Debug("column: unsupported chunk version, trying to load anyway", "X", k.pos[0], "Z", k.pos[1], "dimension", fmt.Sprint(k.dim), "ver", ver) + } + cdata.Biomes, err = db.biomes(k) + if err != nil && !errors.Is(err, leveldb.ErrNotFound) { + // Some chunks still use 2D chunk data and might not have this field, in + // which case we can just move on. + return nil, fmt.Errorf("read biomes: %w", err) + } + cdata.SubChunks, err = db.subChunks(k) + if err != nil { + return nil, fmt.Errorf("read sub chunks: %w", err) + } + col.Chunk, err = chunk.DiskDecode(db.conf.Blocks, cdata, k.dim.Range()) + if err != nil { + return nil, fmt.Errorf("decode chunk data: %w", err) + } + col.Entities, err = db.entities(k) + if err != nil && !errors.Is(err, leveldb.ErrNotFound) { + // Not all chunks need to have entities, so an ErrNotFound is fine here. + return nil, fmt.Errorf("read entities: %w", err) + } + col.BlockEntities, err = db.blockEntities(k) + if err != nil && !errors.Is(err, leveldb.ErrNotFound) { + // Same as with entities, an ErrNotFound is fine here. + return nil, fmt.Errorf("read block entities: %w", err) + } + col.ScheduledBlocks, col.Tick, err = db.scheduledUpdates(k) + if err != nil && !errors.Is(err, leveldb.ErrNotFound) { + return nil, fmt.Errorf("read scheduled updates: %w", err) + } + return col, nil +} + +func (db *DB) version(k dbKey) (byte, error) { + p, err := db.ldb.Get(k.Sum(keyVersion), nil) + if errors.Is(err, leveldb.ErrNotFound) { + // Although the version at `keyVersion` may not be found, there is + // another `keyVersionOld` where the version may be found. + if p, err = db.ldb.Get(k.Sum(keyVersionOld), nil); err != nil { + return 0, err + } + } + if err != nil { + return 0, err + } + if n := len(p); n != 1 { + return 0, fmt.Errorf("expected 1 version byte, got %v", n) + } + return p[0], nil +} + +func (db *DB) biomes(k dbKey) ([]byte, error) { + biomes, err := db.ldb.Get(k.Sum(key3DData), nil) + if err != nil { + return nil, err + } + // The first 512 bytes is a heightmap (16*16 int16s), the biomes follow. We + // calculate a heightmap on startup so the heightmap can be discarded. + if n := len(biomes); n <= 512 { + return nil, fmt.Errorf("expected at least 513 bytes for 3D data, got %v", n) + } + return biomes[512:], nil +} + +func (db *DB) subChunks(k dbKey) ([][]byte, error) { + r := k.dim.Range() + sub := make([][]byte, (r.Height()>>4)+1) + + var err error + for i := range sub { + y := uint8(i + (r[0] >> 4)) + sub[i], err = db.ldb.Get(k.Sum(keySubChunkData, y), nil) + if errors.Is(err, leveldb.ErrNotFound) { + // No sub chunk present at this Y level. We skip this one and move + // to the next, which might still be present. + continue + } else if err != nil { + return nil, fmt.Errorf("sub chunk %v: %w", int8(i), err) + } + } + return sub, nil +} + +func (db *DB) entities(k dbKey) ([]chunk.Entity, error) { + // https://learn.microsoft.com/en-us/minecraft/creator/documents/actorstorage + ids, err := db.ldb.Get(append([]byte(keyEntityIdentifiers), index(k.pos, k.dim)...), nil) + if err != nil { + // Key not found, try old method of loading entities. + return db.entitiesOld(k) + } + entities := make([]chunk.Entity, 0, len(ids)/8) + for i := 0; i < len(ids); i += 8 { + id := int64(binary.LittleEndian.Uint64(ids[i : i+8])) + data, err := db.ldb.Get(entityIndex(id), nil) + if err != nil { + db.conf.Log.Error("read entity: "+err.Error(), "ID", id) + return nil, err + } + ent := chunk.Entity{ID: id, Data: make(map[string]any)} + if err = nbt.UnmarshalEncoding(data, &ent.Data, nbt.LittleEndian); err != nil { + db.conf.Log.Error("decode entity nbt: "+err.Error(), "ID", id) + } + entities = append(entities, ent) + } + return entities, nil +} + +func (db *DB) entitiesOld(k dbKey) ([]chunk.Entity, error) { + data, err := db.ldb.Get(k.Sum(keyEntitiesOld), nil) + if err != nil { + return nil, err + } + var entities []chunk.Entity + + buf := bytes.NewBuffer(data) + dec, ok := nbt.NewDecoderWithEncoding(buf, nbt.LittleEndian), false + + for buf.Len() != 0 { + ent := chunk.Entity{Data: make(map[string]any)} + if err := dec.Decode(&ent.Data); err != nil { + return nil, fmt.Errorf("decode entity nbt: %w", err) + } + ent.ID, ok = ent.Data["UniqueID"].(int64) + if !ok { + db.conf.Log.Error("missing unique ID field, generating random", "data", fmt.Sprint(ent.Data)) + ent.ID = rand.Int64() + } + entities = append(entities, ent) + } + return entities, nil +} + +func (db *DB) blockEntities(k dbKey) ([]chunk.BlockEntity, error) { + var blockEntities []chunk.BlockEntity + + data, err := db.ldb.Get(k.Sum(keyBlockEntities), nil) + if err != nil { + return blockEntities, err + } + + buf := bytes.NewBuffer(data) + dec := nbt.NewDecoderWithEncoding(buf, nbt.LittleEndian) + + for buf.Len() != 0 { + be := chunk.BlockEntity{Data: make(map[string]any)} + if err := dec.Decode(&be.Data); err != nil { + return blockEntities, fmt.Errorf("decode nbt: %w", err) + } + be.Pos = blockPosFromNBT(be.Data) + blockEntities = append(blockEntities, be) + } + return blockEntities, nil +} + +func (db *DB) scheduledUpdates(k dbKey) ([]chunk.ScheduledBlockUpdate, int64, error) { + data, err := db.ldb.Get(k.Sum(keyPendingScheduledTicks), nil) + if err != nil { + return nil, 0, err + } + var m scheduledUpdates + if err := nbt.UnmarshalEncoding(data, &m, nbt.LittleEndian); err != nil { + return nil, 0, fmt.Errorf("read nbt: %s", err.Error()) + } + updates := make([]chunk.ScheduledBlockUpdate, len(m.TickList)) + for i, tick := range m.TickList { + t, _ := tick["time"].(int64) + bl, _ := tick["blockState"].(map[string]any) + bpe := chunk.BlockPaletteEncoding{Blocks: db.conf.Blocks} + block, err := bpe.DecodeBlockState(bl) + if err != nil { + db.conf.Log.Error("read scheduled updates: decode block state: " + err.Error()) + continue + } + updates[i] = chunk.ScheduledBlockUpdate{Pos: blockPosFromNBT(tick), Block: block, Tick: t} + } + return updates, int64(m.CurrentTick), nil +} + +// StoreColumn stores a world.Column at a position and dimension in the DB. An +// error is returned if storing was unsuccessful. +func (db *DB) StoreColumn(pos world.ChunkPos, dim world.Dimension, col *chunk.Column) error { + k := dbKey{pos: pos, dim: dim} + if err := db.storeColumn(k, col); err != nil { + return fmt.Errorf("store column %v (%v): %w", pos, dim, err) + } + return nil +} + +func (db *DB) storeColumn(k dbKey, col *chunk.Column) error { + data := chunk.Encode(col.Chunk, chunk.DiskEncoding) + n := 7 + len(data.SubChunks) + len(col.Entities) + batch := leveldb.MakeBatch(n) + + db.storeVersion(batch, k, chunkVersion) + db.storeBiomes(batch, k, data.Biomes) + db.storeSubChunks(batch, k, data.SubChunks, col.Chunk.Range()) + db.storeFinalisation(batch, k, finalisationPopulated) + db.storeEntities(batch, k, col.Entities) + db.storeBlockEntities(batch, k, col.BlockEntities) + db.storeScheduledUpdates(batch, k, col.Tick, col.ScheduledBlocks) + + return db.ldb.Write(batch, nil) +} + +func (db *DB) storeVersion(batch *leveldb.Batch, k dbKey, ver uint8) { + batch.Put(k.Sum(keyVersion), []byte{ver}) +} + +var emptyHeightmap = make([]byte, 512) + +func (db *DB) storeBiomes(batch *leveldb.Batch, k dbKey, biomes []byte) { + batch.Put(k.Sum(key3DData), append(emptyHeightmap, biomes...)) +} + +func (db *DB) storeSubChunks(batch *leveldb.Batch, k dbKey, subChunks [][]byte, r cube.Range) { + for i, sub := range subChunks { + batch.Put(k.Sum(keySubChunkData, byte(i+(r[0]>>4))), sub) + } +} + +func (db *DB) storeFinalisation(batch *leveldb.Batch, k dbKey, finalisation uint32) { + p := make([]byte, 4) + binary.LittleEndian.PutUint32(p, finalisation) + batch.Put(k.Sum(keyFinalisation), p) +} + +func (db *DB) storeEntities(batch *leveldb.Batch, k dbKey, entities []chunk.Entity) { + idsKey := append([]byte(keyEntityIdentifiers), index(k.pos, k.dim)...) + + // load the ids of the previous entities + var previousIDs []int64 + digpPrev, err := db.ldb.Get(idsKey, nil) + if err != nil && !errors.Is(err, leveldb.ErrNotFound) { + db.conf.Log.Error("store entities: read chunk entity IDs: " + err.Error()) + } + if err == nil { + for i := 0; i < len(digpPrev); i += 8 { + previousIDs = append(previousIDs, int64(binary.LittleEndian.Uint64(digpPrev[i:]))) + } + } + + newIDs := make([]int64, 0, len(entities)) + for _, e := range entities { + e.Data["UniqueID"] = e.ID + b, err := nbt.MarshalEncoding(e.Data, nbt.LittleEndian) + if err != nil { + db.conf.Log.Error("store entities: encode NBT: " + err.Error()) + continue + } + batch.Put(entityIndex(e.ID), b) + newIDs = append(newIDs, e.ID) + } + + // Remove entities that are no longer referenced. + for _, uniqueID := range previousIDs { + if !slices.Contains(newIDs, uniqueID) { + batch.Delete(entityIndex(uniqueID)) + } + } + if len(entities) == 0 { + batch.Delete(idsKey) + } else { + // Save the index of entities in the chunk. + ids := make([]byte, 0, 8*len(newIDs)) + for _, uniqueID := range newIDs { + ids = binary.LittleEndian.AppendUint64(ids, uint64(uniqueID)) + } + batch.Put(idsKey, ids) + } + + // Remove old entity data for this chunk. + batch.Delete(k.Sum(keyEntitiesOld)) +} + +func entityIndex(id int64) []byte { + return binary.LittleEndian.AppendUint64([]byte(keyEntity), uint64(id)) +} + +func (db *DB) storeBlockEntities(batch *leveldb.Batch, k dbKey, blockEntities []chunk.BlockEntity) { + if len(blockEntities) == 0 { + batch.Delete(k.Sum(keyBlockEntities)) + return + } + + buf := bytes.NewBuffer(nil) + enc := nbt.NewEncoderWithEncoding(buf, nbt.LittleEndian) + for _, b := range blockEntities { + b.Data["x"], b.Data["y"], b.Data["z"] = int32(b.Pos[0]), int32(b.Pos[1]), int32(b.Pos[2]) + if err := enc.Encode(b.Data); err != nil { + db.conf.Log.Error("store block entities: encode nbt: " + err.Error()) + } + } + batch.Put(k.Sum(keyBlockEntities), buf.Bytes()) +} + +func (db *DB) storeScheduledUpdates(batch *leveldb.Batch, k dbKey, tick int64, updates []chunk.ScheduledBlockUpdate) { + if len(updates) == 0 { + batch.Delete(k.Sum(keyPendingScheduledTicks)) + return + } + list := make([]map[string]any, len(updates)) + bpe := chunk.BlockPaletteEncoding{Blocks: db.conf.Blocks} + for i, update := range updates { + list[i] = map[string]any{ + "x": int32(update.Pos[0]), "y": int32(update.Pos[1]), "z": int32(update.Pos[2]), + "time": update.Tick, "blockState": bpe.EncodeBlockState(update.Block), + } + } + b, err := nbt.MarshalEncoding(scheduledUpdates{CurrentTick: int32(tick), TickList: list}, nbt.LittleEndian) + if err != nil { + db.conf.Log.Error("store scheduled updates: encode nbt: " + err.Error()) + return + } + batch.Put(k.Sum(keyPendingScheduledTicks), b) +} + +type scheduledUpdates struct { + CurrentTick int32 `nbt:"currentTick"` + TickList []map[string]any `nbt:"tickList"` +} + +// NewColumnIterator returns a ColumnIterator that may be used to iterate over all +// position/chunk pairs in a database. +// An IteratorRange r may be passed to specify limits in terms of what chunks +// should be read. r may be set to nil to read all chunks from the DB. +func (db *DB) NewColumnIterator(r *IteratorRange) *ColumnIterator { + if r == nil { + r = &IteratorRange{} + } + return newColumnIterator(db, r) +} + +// Close closes the provider, saving any file that might need to be saved, such as the level.dat. +func (db *DB) Close() error { + db.ldat.LastPlayed = time.Now().Unix() + + var ldat leveldat.LevelDat + if err := ldat.Marshal(*db.ldat); err != nil { + return fmt.Errorf("close: %w", err) + } + if err := ldat.WriteFile(filepath.Join(db.dir, "level.dat")); err != nil { + return fmt.Errorf("close: %w", err) + } + if err := os.WriteFile(filepath.Join(db.dir, "levelname.txt"), []byte(db.ldat.LevelName), 0644); err != nil { + return fmt.Errorf("close: write levelname.txt: %w", err) + } + return db.ldb.Close() +} + +// dbKey holds a position and dimension. +type dbKey struct { + pos world.ChunkPos + dim world.Dimension +} + +// Sum converts k to its []byte representation and appends p. +func (k dbKey) Sum(p ...byte) []byte { + return append(index(k.pos, k.dim), p...) +} + +// index returns a byte buffer holding the written index of the chunk position passed. If the dimension passed +// is not world.Overworld, the length of the index returned is 12. It is 8 otherwise. +func index(position world.ChunkPos, d world.Dimension) []byte { + dim, _ := world.DimensionID(d) + x, z := uint32(position[0]), uint32(position[1]) + b := make([]byte, 12) + + binary.LittleEndian.PutUint32(b, x) + binary.LittleEndian.PutUint32(b[4:], z) + if dim == 0 { + return b[:8] + } + binary.LittleEndian.PutUint32(b[8:], uint32(dim)) + return b +} + +// blockPosFromNBT returns a position from the X, Y and Z components stored in the NBT data map passed. The +// map is assumed to have an 'x', 'y' and 'z' key. +func blockPosFromNBT(data map[string]any) cube.Pos { + x, _ := data["x"].(int32) + y, _ := data["y"].(int32) + z, _ := data["z"].(int32) + return cube.Pos{int(x), int(y), int(z)} +} diff --git a/server/world/mcdb/iterator.go b/server/world/mcdb/iterator.go new file mode 100644 index 0000000..43653ce --- /dev/null +++ b/server/world/mcdb/iterator.go @@ -0,0 +1,136 @@ +package mcdb + +import ( + "encoding/binary" + "fmt" + + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/chunk" + "github.com/df-mc/goleveldb/leveldb/iterator" +) + +// ColumnIterator iterates over a DB's position/column pairs in key order. +// +// When an error is encountered, any call to Next will return false and will +// yield no position/chunk pairs. The error can be queried by calling the Error +// method. Calling Release is still necessary. +// +// An iterator must be released after use, but it is not necessary to read +// an iterator until exhaustion. +// Also, an iterator is not necessarily safe for concurrent use, but it is +// safe to use multiple iterators concurrently, with each in a dedicated +// goroutine. +type ColumnIterator struct { + dbIter iterator.Iterator + db *DB + r *IteratorRange + + err error + + current *chunk.Column + pos world.ChunkPos + dim world.Dimension + seen map[dbKey]struct{} +} + +func newColumnIterator(db *DB, r *IteratorRange) *ColumnIterator { + return &ColumnIterator{ + db: db, + dbIter: db.ldb.NewIterator(nil, nil), + seen: make(map[dbKey]struct{}), + r: r, + } +} + +// Next moves the iterator to the next key/value pair. +// It returns false if the iterator is exhausted. +func (iter *ColumnIterator) Next() bool { + if iter.err != nil || !iter.dbIter.Next() { + iter.current = nil + iter.dim = nil + return false + } + k := iter.dbIter.Key() + kLen := len(k) + if (kLen != 9 && kLen != 13) || (k[kLen-1] != keyVersion && k[kLen-1] != keyVersionOld) { + return iter.Next() + } + iter.dim = world.Dimension(world.Overworld) + if kLen > 9 { + var ok bool + id := int(binary.LittleEndian.Uint32(k[8:12])) + if iter.dim, ok = world.DimensionByID(id); !ok { + iter.err = fmt.Errorf("unknown dimension id %v", id) + return false + } + } + iter.pos = world.ChunkPos{ + int32(binary.LittleEndian.Uint32(k[:4])), + int32(binary.LittleEndian.Uint32(k[4:8])), + } + if !iter.r.within(iter.pos, iter.dim) { + return iter.Next() + } + key := dbKey{dim: iter.dim, pos: iter.pos} + if _, ok := iter.seen[key]; ok { + // Already encountered this chunk. This might happen if there are + // multiple version keys. + return iter.Next() + } + iter.current, iter.err = iter.db.LoadColumn(iter.pos, iter.dim) + if iter.err != nil { + iter.err = fmt.Errorf("load chunk %v: %w", iter.pos, iter.err) + return false + } + iter.seen[key] = struct{}{} + return true +} + +// Column returns the value of the current position/column pair, or nil if none. +func (iter *ColumnIterator) Column() *chunk.Column { + return iter.current +} + +// Position returns the position of the current position/column pair. +func (iter *ColumnIterator) Position() world.ChunkPos { + return iter.pos +} + +// Dimension returns the dimension of the current position/column pair, or nil +// if none. +func (iter *ColumnIterator) Dimension() world.Dimension { + return iter.dim +} + +// Release releases associated resources. Release should always success +// and can be called multiple times without causing error. +func (iter *ColumnIterator) Release() { + iter.dbIter.Release() +} + +// Error returns any accumulated error. Exhausting all the key/value pairs +// is not considered to be an error. +func (iter *ColumnIterator) Error() error { + return iter.err +} + +// IteratorRange is a range used to limit what columns are accumulated by a +// ColumnIterator. +type IteratorRange struct { + // Min and Max limit what chunk positions are returned by a ColumnIterator. + // A zero value for both Min and Max causes all positions to be within the + // range. + Min, Max world.ChunkPos + // Dimension specifies what world.Dimension chunks should be accumulated + // from. If nil, all dimensions will be read from. + Dimension world.Dimension +} + +// within checks if a position and dimension is within the IteratorRange. +func (r *IteratorRange) within(pos world.ChunkPos, dim world.Dimension) bool { + if dim != r.Dimension && r.Dimension != nil { + return false + } + return ((r.Min == world.ChunkPos{}) && (r.Max == world.ChunkPos{})) || + pos[0] >= r.Min[0] && pos[0] < r.Max[0] && pos[1] >= r.Min[1] && pos[1] < r.Max[1] +} diff --git a/server/world/mcdb/keys.go b/server/world/mcdb/keys.go new file mode 100644 index 0000000..eb4f249 --- /dev/null +++ b/server/world/mcdb/keys.go @@ -0,0 +1,57 @@ +package mcdb + +//lint:file-ignore U1000 Unused unexported constants are present for future code using these. + +// Keys on a per-sub chunk basis. These are prefixed by the chunk coordinates and subchunk ID. +const ( + keySubChunkData = '/' // 2f +) + +// Keys on a per-chunk basis. These are prefixed by only the chunk coordinates. +const ( + // keyVersion holds a single byte of data with the version of the chunk. + keyVersion = ',' // 2c + // keyVersionOld was replaced by keyVersion. It is still used by vanilla to check compatibility, but vanilla no + // longer writes this tag. + keyVersionOld = 'v' // 76 + // keyBlockEntities holds n amount of NBT compound tags appended to each other (not a TAG_List, just appended). The + // compound tags contain the position of the block entities. + keyBlockEntities = '1' // 31 + // keyEntitiesOld holds n amount of NBT compound tags appended to each other (not a TAG_List, just appended). The + // compound tags contain the position of the entities. + keyEntitiesOld = '2' // 32 + // keyPendingScheduledTicks holds an NBT structure containing all scheduled + // ticks that were pending in the chunk. + keyPendingScheduledTicks = '3' + // keyFinalisation contains a single LE int32 that indicates the state of generation of the chunk. If 0, the chunk + // needs to be ticked. If 1, the chunk needs to be populated and if 2 (which is the state generally found in world + // saves from vanilla), the chunk is fully finalised. + keyFinalisation = '6' // 36 + // key3DData holds 3-dimensional biomes for the entire chunk. + key3DData = '+' // 2b + // key2DData is no longer used in worlds with world height change. It was replaced by key3DData in newer worlds + // which has 3-dimensional biomes. + key2DData = '-' // 2d + // keyChecksum holds a list of checksums of some sort. It's not clear of what data this checksum is composed or what + // these checksums are used for. + keyChecksums = ';' // 3b + + keyEntityIdentifiers = "digp" + + keyEntity = "actorprefix" +) + +// Keys on a per-world basis. These are found only once in a leveldb world save. +const ( + keyAutonomousEntities = "AutonomousEntities" + keyOverworld = "Overworld" + keyMobEvents = "mobevents" + keyBiomeData = "BiomeData" + keyScoreboard = "scoreboard" + keyLocalPlayer = "~local_player" +) + +const ( + finalisationGenerated = iota + 1 + finalisationPopulated +) diff --git a/server/world/mcdb/leveldat/data.go b/server/world/mcdb/leveldat/data.go new file mode 100644 index 0000000..68f4503 --- /dev/null +++ b/server/world/mcdb/leveldat/data.go @@ -0,0 +1,275 @@ +package leveldat + +import ( + "math" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol" +) + +// Data holds a collection of data that specify a range of Settings of the +// world. These Settings usually alter the way that players interact with the +// world. The data held here is usually saved in a level.dat file of the world. +// Data may be used in LevelDat.Unmarshal to collect the data of the level.dat. +type Data struct { + BaseGameVersion string `nbt:"baseGameVersion"` + BiomeOverride string + ConfirmedPlatformLockedContent bool + CentreMapsToOrigin bool `nbt:"CenterMapsToOrigin"` + CheatsEnabled bool `nbt:"cheatsEnabled"` + DaylightCycle int32 `nbt:"daylightCycle"` + Difficulty int32 + EduOffer int32 `nbt:"eduOffer"` + FlatWorldLayers string + ForceGameType bool + GameType int32 + Generator int32 + InventoryVersion string + LANBroadcast bool + LANBroadcastIntent bool + LastPlayed int64 + LevelName string + LimitedWorldOriginX int32 + LimitedWorldOriginY int32 + LimitedWorldOriginZ int32 + LimitedWorldDepth int32 `nbt:"limitedWorldDepth"` + LimitedWorldWidth int32 `nbt:"limitedWorldWidth"` + LocatorBar bool `nbt:"locatorbar"` + MinimumCompatibleClientVersion []int32 + MultiPlayerGame bool `nbt:"MultiplayerGame"` + MultiPlayerGameIntent bool `nbt:"MultiplayerGameIntent"` + NetherScale int32 + NetworkVersion int32 + Platform int32 + PlatformBroadcastIntent int32 + RandomSeed int64 + ShowTags bool `nbt:"showtags"` + SingleUseWorld bool `nbt:"isSingleUseWorld"` + SpawnX, SpawnY, SpawnZ int32 + SpawnV1Villagers bool + StorageVersion int32 + Time int64 + XBLBroadcast bool + XBLBroadcastIntent int32 + XBLBroadcastMode int32 + Abilities struct { + AttackMobs bool `nbt:"attackmobs"` + AttackPlayers bool `nbt:"attackplayers"` + Build bool `nbt:"build"` + Mine bool `nbt:"mine"` + DoorsAndSwitches bool `nbt:"doorsandswitches"` + FlySpeed float32 `nbt:"flySpeed"` + Flying bool `nbt:"flying"` + InstantBuild bool `nbt:"instabuild"` + Invulnerable bool `nbt:"invulnerable"` + Lightning bool `nbt:"lightning"` + MayFly bool `nbt:"mayfly"` + OP bool `nbt:"op"` + OpenContainers bool `nbt:"opencontainers"` + PermissionsLevel int32 `nbt:"permissionsLevel"` + PlayerPermissionsLevel int32 `nbt:"playerPermissionsLevel"` + Teleport bool `nbt:"teleport"` + 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"` + AllowAnonymousBlockDropsInEditorWorlds bool `nbt:"allowAnonymousBlockDropsInEditorWorlds"` + PlayerWaypoints int32 `nbt:"playerwaypoints"` + ServerEditorConnectionPolicy int32 `nbt:"serverEditorConnectionPolicy"` +} + +// FillDefault fills out d with all the default level.dat values. +func (d *Data) FillDefault() { + d.Abilities.AttackMobs = true + d.Abilities.AttackPlayers = true + d.Abilities.Build = true + d.Abilities.DoorsAndSwitches = true + d.Abilities.FlySpeed = 0.05 + d.Abilities.Mine = true + d.Abilities.OpenContainers = true + d.Abilities.PlayerPermissionsLevel = 1 + d.Abilities.WalkSpeed = 0.1 + d.Abilities.VerticalFlySpeed = 1.0 + d.BaseGameVersion = "*" + d.CommandBlockOutput = true + d.CommandBlocksEnabled = true + d.CommandsEnabled = true + d.Difficulty = 2 + d.DoDayLightCycle = true + d.DoEntityDrops = true + d.DoFireTick = true + d.DoInsomnia = true + d.DoMobLoot = true + d.DoMobSpawning = true + d.DoTileDrops = true + d.DoWeatherCycle = true + d.DrowningDamage = true + d.FallDamage = true + d.FireDamage = true + d.FreezeDamage = true + d.FunctionCommandLimit = 10000 + d.GameType = 1 + d.Generator = 2 + d.HasBeenLoadedInCreative = true + d.InventoryVersion = protocol.CurrentVersion + d.LANBroadcast = true + d.LANBroadcastIntent = true + d.LastOpenedWithVersion = minimumCompatibleClientVersion + d.LevelName = "World" + d.LightningLevel = 1.0 + d.LimitedWorldDepth = 16 + d.LimitedWorldOriginY = math.MaxInt16 + d.LimitedWorldWidth = 16 + d.MaxCommandChainLength = math.MaxUint16 + d.MinimumCompatibleClientVersion = minimumCompatibleClientVersion + d.MobGriefing = true + d.MultiPlayerGame = true + d.MultiPlayerGameIntent = true + d.NaturalRegeneration = true + d.NetherScale = 8 + d.NetworkVersion = protocol.CurrentProtocol + d.PVP = true + d.Platform = 2 + d.PlatformBroadcastIntent = 3 + d.RainLevel = 1.0 + d.RandomSeed = time.Now().Unix() + d.RandomTickSpeed = 1 + d.RespawnBlocksExplode = true + d.SendCommandFeedback = true + d.ServerChunkTickRange = 6 + d.ShowBorderEffect = true + d.ShowDeathMessages = true + d.ShowTags = true + d.SpawnMobs = true + d.SpawnRadius = 5 + d.SpawnRadius = 5 + d.SpawnY = math.MaxInt16 + d.StorageVersion = 9 + d.TNTExplodes = true + d.WorldVersion = 1 + d.XBLBroadcastIntent = 3 +} + +// Settings returns a world.Settings value based on the properties stored in d. +func (d *Data) Settings() *world.Settings { + d.WorldStartCount += 1 + difficulty, _ := world.DifficultyByID(int(d.Difficulty)) + mode, _ := world.GameModeByID(int(d.GameType)) + return &world.Settings{ + Name: d.LevelName, + Spawn: cube.Pos{int(d.SpawnX), int(d.SpawnY), int(d.SpawnZ)}, + Time: d.Time, + TimeCycle: d.DoDayLightCycle, + RainTime: int64(d.RainTime), + Raining: d.RainLevel > 0, + ThunderTime: int64(d.LightningTime), + Thundering: d.LightningLevel > 0, + WeatherCycle: d.DoWeatherCycle, + CurrentTick: d.CurrentTick, + DefaultGameMode: mode, + Difficulty: difficulty, + TickRange: d.ServerChunkTickRange, + } +} + +// PutSettings updates d with the Settings stored in s. +func (d *Data) PutSettings(s *world.Settings) { + d.LevelName = s.Name + d.SpawnX, d.SpawnY, d.SpawnZ = int32(s.Spawn.X()), int32(s.Spawn.Y()), int32(s.Spawn.Z()) + d.LimitedWorldOriginX, d.LimitedWorldOriginY, d.LimitedWorldOriginZ = d.SpawnX, d.SpawnY, d.SpawnZ + d.Time = s.Time + d.DoDayLightCycle = s.TimeCycle + d.DoWeatherCycle = s.WeatherCycle + d.RainTime, d.RainLevel = int32(s.RainTime), 0 + d.LightningTime, d.LightningLevel = int32(s.ThunderTime), 0 + if s.Raining { + d.RainLevel = 1 + } + if s.Thundering { + d.LightningLevel = 1 + } + d.CurrentTick = s.CurrentTick + d.ServerChunkTickRange = s.TickRange + mode, _ := world.GameModeID(s.DefaultGameMode) + d.GameType = int32(mode) + difficulty, _ := world.DifficultyID(s.Difficulty) + d.Difficulty = int32(difficulty) +} diff --git a/server/world/mcdb/leveldat/level_dat.go b/server/world/mcdb/leveldat/level_dat.go new file mode 100644 index 0000000..4e4d63c --- /dev/null +++ b/server/world/mcdb/leveldat/level_dat.go @@ -0,0 +1,104 @@ +package leveldat + +import ( + "bufio" + "encoding/binary" + "fmt" + "io" + "os" + + "github.com/sandertv/gophertunnel/minecraft/nbt" +) + +// LevelDat implements the encoding and decoding of level.dat files. An empty +// LevelDat is a valid value and may be used to Marshal and Write to a writer or +// file afterward. +type LevelDat struct { + hdr header + data []byte +} + +// header holds the header for a level.dat file. +type header struct { + StorageVersion int32 + FileLength int32 +} + +// ReadFile reads a level.dat at a path and returns it. +func ReadFile(name string) (*LevelDat, error) { + f, err := os.Open(name) + if err != nil { + return nil, fmt.Errorf("level.dat: open file: %w", err) + } + defer f.Close() + return Read(bufio.NewReader(f)) +} + +// Read reads a level.dat from r and returns it. +func Read(r io.Reader) (*LevelDat, error) { + var ldat LevelDat + if err := binary.Read(r, binary.LittleEndian, &ldat.hdr); err != nil { + return nil, fmt.Errorf("level.dat: read header: %w", err) + } + ldat.data = make([]byte, 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 +} + +// Unmarshal decodes the level.dat properties from ld into dst. Unmarshal +// returns an error if dst was unable to store all properties found in the +// level.dat. +func (ld *LevelDat) Unmarshal(dst any) error { + if err := nbt.UnmarshalEncoding(ld.data, dst, nbt.LittleEndian); err != nil { + return fmt.Errorf("level.dat: decode nbt: %w", err) + } + return nil +} + +// Ver returns the version of the level.dat decoded, or 0 if ld is the empty +// value. +func (ld *LevelDat) Ver() int { + return int(ld.hdr.StorageVersion) +} + +// Marshal encodes src and stores it in the level.dat. src should be either a +// struct or a map of fields. Marshal updates the storage version to the latest. +func (ld *LevelDat) Marshal(src any) error { + var err error + ld.data, err = nbt.MarshalEncoding(src, nbt.LittleEndian) + if err != nil { + return fmt.Errorf("level.dat: encode nbt: %w", err) + } + ld.hdr = header{ + StorageVersion: Version, + FileLength: int32(len(ld.data)), + } + return nil +} + +// Write writes ld to w. +func (ld *LevelDat) Write(w io.Writer) error { + if err := binary.Write(w, binary.LittleEndian, ld.hdr); err != nil { + return fmt.Errorf("level.dat: write header: %w", err) + } + if _, err := w.Write(ld.data); err != nil { + return fmt.Errorf("level.dat: write data: %w", err) + } + return nil +} + +// WriteFile writes ld to a file at name. +func (ld *LevelDat) WriteFile(name string) error { + f, err := os.OpenFile(name, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("level.dat: open file: %w", err) + } + w := bufio.NewWriter(f) + defer func() { + _ = w.Flush() + _ = f.Close() + }() + return ld.Write(w) +} diff --git a/server/world/mcdb/leveldat/version.go b/server/world/mcdb/leveldat/version.go new file mode 100644 index 0000000..53de90b --- /dev/null +++ b/server/world/mcdb/leveldat/version.go @@ -0,0 +1,23 @@ +package leveldat + +import ( + "github.com/sandertv/gophertunnel/minecraft/protocol" + "strconv" + "strings" +) + +// Version is the current version stored in level.dat files. +const Version = 10 + +// minimumCompatibleClientVersion is the minimum compatible client version, +// required by the latest Minecraft data provider. +var minimumCompatibleClientVersion []int32 + +// init initialises the minimum compatible client version. +func init() { + fullVersion := append(strings.Split(protocol.CurrentVersion, "."), "0", "0") + for _, v := range fullVersion { + i, _ := strconv.Atoi(v) + minimumCompatibleClientVersion = append(minimumCompatibleClientVersion, int32(i)) + } +} diff --git a/server/world/network_block_hash.go b/server/world/network_block_hash.go new file mode 100644 index 0000000..63a5407 --- /dev/null +++ b/server/world/network_block_hash.go @@ -0,0 +1,93 @@ +package world + +import ( + "encoding/binary" + "fmt" + "sort" + + "github.com/segmentio/fasthash/fnv1a" +) + +// networkBlockHash produces the canonical "network block hash" for a (name, properties) block state. +// This hash is used for mapping network palette entries back to runtime IDs. +// +// The scratch slice is used to reduce allocations. The returned slice should be passed back in on subsequent calls to +// reuse the same backing array. +func networkBlockHash(name string, properties map[string]any, scratch []byte) (uint32, []byte) { + if name == "minecraft:unknown" { + return 0xfffffffe, scratch // -2 + } + + keys := make([]string, 0, len(properties)) + for k := range properties { + keys = append(keys, k) + } + sort.Strings(keys) + + data := scratch[:0] + writeString := func(str string) { + data = binary.LittleEndian.AppendUint16(data, uint16(len(str))) + data = append(data, []byte(str)...) + } + + data = append(data, 10) // compound + data = append(data, 0) + data = append(data, 0) + + data = append(data, 8) // string + writeString("name") + writeString(name) + + data = append(data, 10) // compound + writeString("states") + for _, k := range keys { + v := properties[k] + switch v := v.(type) { + case string: + data = append(data, 8) // string + writeString(k) + writeString(v) + + case uint8: + data = append(data, 1) // tagByte + writeString(k) + data = append(data, byte(v)) + case int8: + data = append(data, 1) // tagByte + writeString(k) + data = append(data, byte(v)) + case bool: + b := 0 + if v { + b = 1 + } + data = append(data, 1) // tagByte + writeString(k) + data = append(data, byte(b)) + + case uint16: + data = append(data, 2) // tagInt16 + writeString(k) + data = binary.LittleEndian.AppendUint16(data, uint16(v)) + case int16: + data = append(data, 2) // tagInt16 + writeString(k) + data = binary.LittleEndian.AppendUint16(data, uint16(v)) + + case uint32: + data = append(data, 3) // tagInt32 + writeString(k) + data = binary.LittleEndian.AppendUint32(data, uint32(v)) + case int32: + data = append(data, 3) // tagInt32 + writeString(k) + data = binary.LittleEndian.AppendUint32(data, uint32(v)) + default: + panic(fmt.Sprintf("unhandled nbt type: %T", v)) + } + } + data = append(data, 0) // end + data = append(data, 0) // end + + return fnv1a.HashBytes32(data), data +} diff --git a/server/world/particle.go b/server/world/particle.go new file mode 100644 index 0000000..afe03f3 --- /dev/null +++ b/server/world/particle.go @@ -0,0 +1,13 @@ +package world + +import ( + "github.com/go-gl/mathgl/mgl64" +) + +// Particle represents a particle that may be added to the world. These particles are then rendered client- +// side, with the server having no control over it after sending. +type Particle interface { + // Spawn spawns the particle at the position passed. Particles may execute any additional actions here, + // such as spawning different particles. + Spawn(w *World, pos mgl64.Vec3) +} diff --git a/server/world/particle/block.go b/server/world/particle/block.go new file mode 100644 index 0000000..71133aa --- /dev/null +++ b/server/world/particle/block.go @@ -0,0 +1,99 @@ +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" +) + +// Flame is a particle shown around torches. It can have any colour specified with the Colour field. +// If the colour is not specified, it will default to the normal flame particle. +type Flame struct { + particle + // Colour is the colour of the Flame particle. + Colour color.RGBA +} + +// Dust is a particle shown for redstone. It can have any colour specified with the Colour field. +// If the colour is not specified, it will default to black. +type Dust struct { + particle + + // Colour is the colour of the Dust particle. + Colour color.RGBA +} + +// BlockBreak is a particle sent when a block is broken. It represents a bunch of particles that are textured +// like the block that the particle holds. +type BlockBreak struct { + particle + // Block is the block of which particles should be shown. The particles will change depending on what + // block is held. + Block world.Block +} + +// PunchBlock is a particle shown when a player is punching a block. It shows particles of a specific block +// type at a particular face of a block. +type PunchBlock struct { + particle + // Block is the block of which particles should be shown. The particles will change depending on what + // block is punched. + Block world.Block + // Face is the face of the block that was punched. It is here that the particles will be shown. + Face cube.Face +} + +// BlockForceField is a particle that shows up as a block that turns invisible from an opaque black colour. +type BlockForceField struct{ particle } + +// BoneMeal is a particle that shows up on bone meal usage. +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 { + particle + + // Instrument is the instrument of the note block. + Instrument sound.Instrument + // Pitch is the pitch of the note. + Pitch int +} + +// DragonEggTeleport is a particle that shows up when a dragon egg teleports. +type DragonEggTeleport struct { + particle + + // Diff is a Position with the values being the difference from the original position to the new position. + Diff cube.Pos +} + +// Evaporate is a particle that shows up when a water block evaporates +type Evaporate struct{ particle } + +// WaterDrip is a particle that shows up when there is water above a block and it looks like a dripping effect. +type WaterDrip struct{ particle } + +// LavaDrip is a particle that shows up when there is lava above a block and it looks like a dripping effect. +type LavaDrip struct{ particle } + +// Lava is a particle that shows up randomly above lava. +type Lava struct{ particle } + +// DustPlume is a particle that shows up when an item is successfully inserted into a decorated pot. +type DustPlume struct{ particle } + +// particle serves as a base for all particles in this package. +type particle struct{} + +// Spawn ... +func (particle) Spawn(*world.World, mgl64.Vec3) {} diff --git a/server/world/particle/entity.go b/server/world/particle/entity.go new file mode 100644 index 0000000..f7d7e25 --- /dev/null +++ b/server/world/particle/entity.go @@ -0,0 +1,34 @@ +package particle + +import "image/color" + +// HugeExplosion is a particle shown when TNT or a creeper explodes. +type HugeExplosion struct{ particle } + +// EndermanTeleport is a particle that shows up when an enderman teleports. +type EndermanTeleport struct{ particle } + +// SnowballPoof is a particle shown when a snowball collides with something. +type SnowballPoof struct{ particle } + +// EggSmash is a particle shown when an egg smashes on something. +type EggSmash struct{ particle } + +// Splash is a particle that shows up when a splash potion is splashed. +type Splash struct { + particle + + // Colour is the colour that should be splashed. + Colour color.RGBA +} + +// Effect is a particle that shows up around an entity when it has effects on. +type Effect struct { + particle + + // Colour is the colour of the particle. + Colour color.RGBA +} + +// EntityFlame is a particle shown when an entity is set on fire. +type EntityFlame struct{ particle } diff --git a/server/world/position.go b/server/world/position.go new file mode 100644 index 0000000..b8bb05d --- /dev/null +++ b/server/world/position.go @@ -0,0 +1,66 @@ +package world + +import ( + "fmt" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/go-gl/mathgl/mgl64" + "math" +) + +// ChunkPos holds the position of a chunk. The type is provided as a utility struct for keeping track of a +// chunk's position. Chunks do not themselves keep track of that. Chunk positions are different from block +// positions in the way that increasing the X/Z by one means increasing the absolute value on the X/Z axis in +// terms of blocks by 16. +type ChunkPos [2]int32 + +// String implements fmt.Stringer and returns (x, z). +func (p ChunkPos) String() string { + return fmt.Sprintf("(%v, %v)", p[0], p[1]) +} + +// X returns the X coordinate of the chunk position. +func (p ChunkPos) X() int32 { + return p[0] +} + +// Z returns the Z coordinate of the chunk position. +func (p ChunkPos) Z() int32 { + return p[1] +} + +// SubChunkPos holds the position of a sub-chunk. The type is provided as a utility struct for keeping track of a +// sub-chunk's position. Sub-chunks do not themselves keep track of that. Sub-chunk positions are different from +// block positions in the way that increasing the X/Y/Z by one means increasing the absolute value on the X/Y/Z axis in +// terms of blocks by 16. +type SubChunkPos [3]int32 + +// String implements fmt.Stringer and returns (x, y, z). +func (p SubChunkPos) String() string { + return fmt.Sprintf("(%v, %v, %v)", p[0], p[1], p[2]) +} + +// X returns the X coordinate of the sub-chunk position. +func (p SubChunkPos) X() int32 { + return p[0] +} + +// Y returns the Y coordinate of the sub-chunk position. +func (p SubChunkPos) Y() int32 { + return p[1] +} + +// Z returns the Z coordinate of the sub-chunk position. +func (p SubChunkPos) Z() int32 { + return p[2] +} + +// chunkPosFromVec3 returns a chunk position from the Vec3 passed. The coordinates of the chunk position are +// those of the Vec3 divided by 16, then rounded down. +func chunkPosFromVec3(vec3 mgl64.Vec3) ChunkPos { + return ChunkPos{int32(math.Floor(vec3[0])) >> 4, int32(math.Floor(vec3[2])) >> 4} +} + +// chunkPosFromBlockPos returns the ChunkPos of the chunk that a block at a cube.Pos is in. +func chunkPosFromBlockPos(p cube.Pos) ChunkPos { + return ChunkPos{int32(p[0] >> 4), int32(p[2] >> 4)} +} diff --git a/server/world/provider.go b/server/world/provider.go new file mode 100644 index 0000000..713c38b --- /dev/null +++ b/server/world/provider.go @@ -0,0 +1,60 @@ +package world + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world/chunk" + "github.com/df-mc/goleveldb/leveldb" + "github.com/google/uuid" + "io" +) + +// Provider represents a value that may provide world data to a World value. It usually does the reading and +// writing of the world data so that the World may use it. +type Provider interface { + io.Closer + // Settings loads the settings for a World and returns them. + Settings() *Settings + // SaveSettings saves the settings of a World. + SaveSettings(*Settings) + + // LoadPlayerSpawnPosition loads the player spawn point if found, otherwise an error will be returned. + LoadPlayerSpawnPosition(uuid uuid.UUID) (pos cube.Pos, exists bool, err error) + // SavePlayerSpawnPosition saves the player spawn point. In vanilla, this can be done with beds in the overworld + // or respawn anchors in the nether. + SavePlayerSpawnPosition(uuid uuid.UUID, pos cube.Pos) error + // LoadColumn reads a world.Column from the DB at a position and dimension + // in the DB. If no column at that position exists, errors.Is(err, + // leveldb.ErrNotFound) equals true. + LoadColumn(pos ChunkPos, dim Dimension) (*chunk.Column, error) + // StoreColumn stores a world.Column at a position and dimension in the DB. + // An error is returned if storing was unsuccessful. + StoreColumn(pos ChunkPos, dim Dimension, col *chunk.Column) error +} + +// Compile time check to make sure NopProvider implements Provider. +var _ Provider = (*NopProvider)(nil) + +// NopProvider implements a Provider that does not perform any disk I/O. It generates values on the run and +// dynamically, instead of reading and writing data, and otherwise returns empty values. A Settings struct can be passed +// to initialise a world with specific settings. Since Settings is a pointer, using the same NopProvider for multiple +// worlds means those worlds will share the same settings. +type NopProvider struct { + Set *Settings +} + +func (n NopProvider) Settings() *Settings { + if n.Set == nil { + return defaultSettings() + } + return n.Set +} +func (NopProvider) SaveSettings(*Settings) {} +func (NopProvider) LoadColumn(ChunkPos, Dimension) (*chunk.Column, error) { + return nil, leveldb.ErrNotFound +} +func (NopProvider) StoreColumn(ChunkPos, Dimension, *chunk.Column) error { return nil } +func (NopProvider) LoadPlayerSpawnPosition(uuid.UUID) (cube.Pos, bool, error) { + return cube.Pos{}, false, nil +} +func (NopProvider) SavePlayerSpawnPosition(uuid.UUID, cube.Pos) error { return nil } +func (NopProvider) Close() error { return nil } diff --git a/server/world/redstone/state.go b/server/world/redstone/state.go new file mode 100644 index 0000000..359038b --- /dev/null +++ b/server/world/redstone/state.go @@ -0,0 +1,165 @@ +package redstone + +import ( + "slices" + + "github.com/df-mc/dragonfly/server/block/cube" +) + +const ( + // torchBurnoutThreshold is the maximum number of state changes allowed before burnout occurs. + torchBurnoutThreshold = 8 + // torchBurnoutWindowTicks is the time window during which state changes are counted. + torchBurnoutWindowTicks = 60 +) + +// State holds transient redstone runtime state owned by a world. +type State struct { + torchBurnout map[cube.Pos]torchBurnout + activeTorchUpdates map[cube.Pos]int + updateSources []cube.Pos +} + +// torchBurnout holds the burnout state and state change history for a redstone torch. +type torchBurnout struct { + expirationTicks []int64 + burnedOut bool + // selfTriggered is set when the next scheduled toggle was caused by this torch's own outgoing propagation. + selfTriggered bool +} + +// TorchBurnoutStatus returns the current transient burnout state for a redstone torch. Expired state-change +// history is pruned before the state is returned. +func (s *State) TorchBurnoutStatus(pos cube.Pos, currentTick int64) (burnedOut, recoverable bool) { + data, ok := s.pruneTorchBurnoutData(pos, currentTick) + if !ok { + return false, false + } + return data.burnedOut, len(data.expirationTicks) < torchBurnoutThreshold +} + +// PruneTorchBurnout removes idle redstone torch burnout state once all tracked state changes have expired. +func (s *State) PruneTorchBurnout(pos cube.Pos, currentTick int64) { + data, ok := s.torchBurnout[pos] + if !ok { + return + } + remaining := data.removeExpired(currentTick) + data.selfTriggered = false + if remaining == 0 && !data.burnedOut { + s.ClearTorchBurnout(pos) + return + } + s.torchBurnout[pos] = data +} + +// RecordTorchToggle records a self-triggered redstone torch toggle and reports whether it should burn out. +func (s *State) RecordTorchToggle(pos cube.Pos, currentTick int64) (burnsOut bool) { + data, ok := s.torchBurnout[pos] + if !ok { + return false + } + data.removeExpired(currentTick) + if data.selfTriggered { + data.expirationTicks = append(data.expirationTicks, currentTick+torchBurnoutWindowTicks) + } + data.selfTriggered = false + if len(data.expirationTicks) == 0 && !data.burnedOut { + s.ClearTorchBurnout(pos) + return false + } + s.torchBurnout[pos] = data + return len(data.expirationTicks) >= torchBurnoutThreshold +} + +// BurnOutTorch marks a redstone torch as burned out until a later redstone update recovers it. +func (s *State) BurnOutTorch(pos cube.Pos) { + data := s.torchBurnoutData(pos) + data.burnedOut = true + data.selfTriggered = false + s.torchBurnout[pos] = data +} + +// ClearTorchBurnout removes transient burnout state for a redstone torch. +func (s *State) ClearTorchBurnout(pos cube.Pos) { + delete(s.torchBurnout, pos) +} + +// WithActiveTorchUpdate marks redstone propagation as originating from the redstone torch at pos for the duration of fn. +func (s *State) WithActiveTorchUpdate(pos cube.Pos, fn func()) { + if s.activeTorchUpdates == nil { + s.activeTorchUpdates = make(map[cube.Pos]int) + } + s.activeTorchUpdates[pos]++ + defer func() { + if s.activeTorchUpdates[pos] <= 1 { + delete(s.activeTorchUpdates, pos) + return + } + s.activeTorchUpdates[pos]-- + }() + fn() +} + +// WithUpdateSource marks redstone propagation as originating from pos for the duration of fn. +func (s *State) WithUpdateSource(pos cube.Pos, fn func()) { + s.updateSources = append(s.updateSources, pos) + defer func() { + s.updateSources = s.updateSources[:len(s.updateSources)-1] + }() + fn() +} + +// UpdateSource returns the position that caused the current redstone update. +func (s *State) UpdateSource() (cube.Pos, bool) { + if len(s.updateSources) == 0 { + return cube.Pos{}, false + } + return s.updateSources[len(s.updateSources)-1], true +} + +// MarkTorchSelfTriggeredIfActive marks the next scheduled tick for the redstone torch at pos as self-triggered if the +// current redstone propagation originated from that torch. +func (s *State) MarkTorchSelfTriggeredIfActive(pos cube.Pos) { + if s.activeTorchUpdates[pos] == 0 { + return + } + data := s.torchBurnoutData(pos) + data.selfTriggered = true + s.torchBurnout[pos] = data +} + +// torchBurnoutData retrieves or creates burnout tracking data for the given torch position. +func (s *State) torchBurnoutData(pos cube.Pos) torchBurnout { + if s.torchBurnout == nil { + s.torchBurnout = make(map[cube.Pos]torchBurnout) + } + data, ok := s.torchBurnout[pos] + if !ok { + data.expirationTicks = make([]int64, 0, torchBurnoutThreshold+1) + } + return data +} + +// pruneTorchBurnoutData removes expired burnout history and returns the remaining torch data if it is still relevant. +func (s *State) pruneTorchBurnoutData(pos cube.Pos, currentTick int64) (torchBurnout, bool) { + data, ok := s.torchBurnout[pos] + if !ok { + return torchBurnout{}, false + } + data.removeExpired(currentTick) + if len(data.expirationTicks) == 0 && !data.burnedOut && !data.selfTriggered { + s.ClearTorchBurnout(pos) + return torchBurnout{}, false + } + s.torchBurnout[pos] = data + return data, true +} + +// removeExpired removes expired state change entries and returns the number of entries that remain. +func (data *torchBurnout) removeExpired(currentTick int64) int { + data.expirationTicks = slices.DeleteFunc(data.expirationTicks, func(t int64) bool { + return t < currentTick + }) + return len(data.expirationTicks) +} diff --git a/server/world/settings.go b/server/world/settings.go new file mode 100644 index 0000000..085a5c2 --- /dev/null +++ b/server/world/settings.go @@ -0,0 +1,59 @@ +package world + +import ( + "sync" + "sync/atomic" + + "github.com/df-mc/dragonfly/server/block/cube" +) + +// Settings holds the settings of a World. These are typically saved to a level.dat file. It is safe to pass the same +// Settings to multiple worlds created using New, in which case the Settings are synchronised between the worlds. +type Settings struct { + sync.Mutex + ref atomic.Int32 + + // Name is the display name of the World. + Name string + // Spawn is the spawn position of the World. New players that join the world will be spawned here. + Spawn cube.Pos + // Time is the current time of the World. It advances every tick if TimeCycle is set to true. + Time int64 + // TimeCycle specifies if the time should advance every tick. If set to false, time won't change. + TimeCycle bool + // RainTime is the current rain time of the World. It advances every tick if WeatherCycle is set to true. + RainTime int64 + // Raining is the current rain level of the World. + Raining bool + // ThunderTime is the current thunder time of the World. It advances every tick if WeatherCycle is set to true. + ThunderTime int64 + // Thunder is the current thunder level of the World. + Thundering bool + // WeatherCycle specifies if weather should be enabled in this world. If set to false, weather will be disabled. + WeatherCycle bool + // RequiredSleepTicks is the number of ticks that players must sleep for in order for the time to change to day. + RequiredSleepTicks int64 + // CurrentTick is the current tick of the world. This is similar to the Time, except that it has no visible effect + // to the client. It can also not be changed through commands and will only ever go up. + CurrentTick int64 + // DefaultGameMode is the GameMode assigned to players that join the World for the first time. + DefaultGameMode GameMode + // Difficulty is the difficulty of the World. Behaviour of hunger, regeneration and monsters differs based on the + // difficulty of the world. + Difficulty Difficulty + // TickRange is the radius in chunks around a Viewer that has its blocks and entities ticked when the world is + // ticked. If set to 0, blocks and entities will never be ticked. + TickRange int32 +} + +// defaultSettings returns the default Settings for a new World. +func defaultSettings() *Settings { + return &Settings{ + Name: "World", + DefaultGameMode: GameModeSurvival, + Difficulty: DifficultyNormal, + TimeCycle: true, + WeatherCycle: true, + TickRange: 6, + } +} diff --git a/server/world/sleep.go b/server/world/sleep.go new file mode 100644 index 0000000..3277345 --- /dev/null +++ b/server/world/sleep.go @@ -0,0 +1,64 @@ +package world + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/player/chat" + "github.com/google/uuid" +) + +// Sleeper represents an entity that can sleep. +type Sleeper interface { + Entity + + Name() string + UUID() uuid.UUID + + Messaget(t chat.Translation, a ...any) + SendSleepingIndicator(sleeping, max int) + + Sleep(pos cube.Pos) + Sleeping() (cube.Pos, bool) + Wake() +} + +// Time constants for sleep usage. +const ( + TimeSleep = 12542 + TimeWake = 23459 + TimeSleepWithRain = 12010 + TimeWakeWithRain = 23991 + TimeFull = 24000 +) + +// tryAdvanceDay attempts to advance the day of the world, by first ensuring that all sleepers are sleeping, and then +// updating the time of day. +func (ticker) tryAdvanceDay(tx *Tx, timeCycle bool) { + sleepers := tx.Sleepers() + time := tx.w.Time() % TimeFull + + for s := range sleepers { + if !tx.Thundering() { + if !tx.Raining() && (time <= TimeSleep || time >= TimeWake) { + return + } + if time <= TimeSleepWithRain || time >= TimeWakeWithRain { + return + } + } + + if _, ok := s.Sleeping(); !ok { + // We can't advance the time - not everyone is sleeping. + return + } + } + + for s := range sleepers { + s.Wake() + } + + totalTime := tx.w.Time() + if timeCycle { + tx.w.SetTime(totalTime + TimeFull - time) + } + tx.w.StopRaining() +} diff --git a/server/world/sound.go b/server/world/sound.go new file mode 100644 index 0000000..80b42a0 --- /dev/null +++ b/server/world/sound.go @@ -0,0 +1,11 @@ +package world + +import "github.com/go-gl/mathgl/mgl64" + +// Sound represents a sound that may be added to the world. When done, viewers of the world may be able to +// hear the sound. +type Sound interface { + // Play plays the sound. This function may play other sounds too. It is always called when World.PlaySound + // is called with the sound. + Play(w *World, pos mgl64.Vec3) +} diff --git a/server/world/sound/ambient.go b/server/world/sound/ambient.go new file mode 100644 index 0000000..824381b --- /dev/null +++ b/server/world/sound/ambient.go @@ -0,0 +1,7 @@ +package sound + +// LightningExplode ... +type LightningExplode struct{ sound } + +// LightningThunder ... +type LightningThunder struct{ sound } diff --git a/server/world/sound/block.go b/server/world/sound/block.go new file mode 100644 index 0000000..fc62f59 --- /dev/null +++ b/server/world/sound/block.go @@ -0,0 +1,223 @@ +package sound + +import ( + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// BlockPlace is a sound sent when a block is placed. +type BlockPlace struct { + // Block is the block which is placed, for which a sound should be played. The sound played depends on + // the block type. + Block world.Block + + sound +} + +// BlockBreaking is a sound sent continuously while a player is breaking a block. +type BlockBreaking struct { + // Block is the block which is being broken, for which a sound should be played. The sound played depends + // on the block type. + Block world.Block + + sound +} + +// GlassBreak is a sound played when a glass block or item is broken. +type GlassBreak struct{ sound } + +// Fizz is a sound sent when a lava block and a water block interact with each other in a way that one of +// them turns into a solid block. +type Fizz struct{ sound } + +// AnvilLand is played when an anvil lands on the ground. +type AnvilLand struct{ sound } + +// AnvilUse is played when an anvil is used. +type AnvilUse struct{ sound } + +// AnvilBreak is played when an anvil is broken. +type AnvilBreak struct{ sound } + +// ChestOpen is played when a chest is opened. +type ChestOpen struct{ sound } + +// ChestClose is played when a chest is closed. +type ChestClose struct{ sound } + +// EnderChestOpen is played when a ender chest is opened. +type EnderChestOpen struct{ sound } + +// EnderChestClose is played when a ender chest is closed. +type EnderChestClose struct{ sound } + +// BarrelOpen is played when a barrel is opened. +type BarrelOpen struct{ sound } + +// BarrelClose is played when a barrel is closed. +type BarrelClose struct{ sound } + +// Deny is a sound played when a block is placed or broken above a 'Deny' block from Education edition. +type Deny struct{ sound } + +// DoorOpen is a sound played when a door is opened. +type DoorOpen struct { + // Block is the block which is being opened, for which a sound should be played. The sound played depends on the + // block type. + Block world.Block + + sound +} + +// DoorClose is a sound played when a door is closed. +type DoorClose struct { + // Block is the block which is being closed, for which a sound should be played. The sound played depends on the + // block type. + Block world.Block + + sound +} + +// TrapdoorOpen is a sound played when a trapdoor is opened. +type TrapdoorOpen struct { + // Block is the block which is being opened, for which a sound should be played. The sound played depends on the + // block type. + Block world.Block + + sound +} + +// TrapdoorClose is a sound played when a trapdoor is closed. +type TrapdoorClose struct { + // Block is the block which is being closed, for which a sound should be played. The sound played depends on the + // block type. + Block world.Block + + sound +} + +// FenceGateOpen is a sound played when a fence gate is opened. +type FenceGateOpen struct { + // Block is the block which is being opened, for which a sound should be played. The sound played depends on the + // block type. + Block world.Block + + sound +} + +// FenceGateClose is a sound played when a fence gate is closed. +type FenceGateClose struct { + // Block is the block which is being closed, for which a sound should be played. The sound played depends on the + // block type. + Block world.Block + + sound +} + +// DoorCrash is a sound played when a door is forced open. +type DoorCrash struct{ sound } + +// Click is a clicking sound. +type Click struct{ sound } + +// Ignite is a sound played when using a flint & steel. +type Ignite struct{ sound } + +// TNT is a sound played when TNT is ignited. +type TNT struct{ sound } + +// FireExtinguish is a sound played when a fire is extinguished. +type FireExtinguish struct{ sound } + +// Note is a sound played by note blocks. +type Note struct { + sound + // Instrument is the instrument of the note block. + Instrument Instrument + // Pitch is the pitch of the note. + Pitch int +} + +// MusicDiscPlay is a sound played when a music disc has started playing in a jukebox. +type MusicDiscPlay struct { + sound + + // DiscType is the disc type of the music disc. + DiscType DiscType +} + +// MusicDiscEnd is a sound played when a music disc has stopped playing in a jukebox. +type MusicDiscEnd 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 } + +// ItemFrameRotate is a sound played when an item frame's item is rotated. +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 } + +// SmokerCrackle is a sound played every one to five seconds from a smoker. +type SmokerCrackle struct{ sound } + +// ComposterEmpty is a sound played when a composter has been emptied. +type ComposterEmpty struct{ sound } + +// ComposterFill is a sound played when a composter has been filled, but not gone up a layer. +type ComposterFill struct{ sound } + +// ComposterFillLayer is a sound played when a composter has been filled and gone up a layer. +type ComposterFillLayer struct{ sound } + +// ComposterReady is a sound played when a composter has produced bone meal and is ready to be collected. +type ComposterReady struct{ sound } + +// PotionBrewed is a sound played when a potion is brewed. +type PotionBrewed struct{ sound } + +// PowerOn is a sound played when a redstone component is powered on. +type PowerOn struct{ sound } + +// PowerOff is a sound played when a redstone component is powered off. +type PowerOff struct{ sound } + +// LecternBookPlace is a sound played when a book is placed in a lectern. +type LecternBookPlace struct{ sound } + +// SignWaxed is a sound played when a sign is waxed. +type SignWaxed struct{ sound } + +// WaxedSignFailedInteraction is a sound played when a player tries to interact with a waxed sign. +type WaxedSignFailedInteraction struct{ sound } + +// WaxRemoved is a sound played when wax is removed from a block. +type WaxRemoved struct{ sound } + +// CopperScraped is a sound played when a player scrapes a copper block to reduce its oxidation level. +type CopperScraped struct{ sound } + +// DecoratedPotInserted is a sound played when an item is successfully inserted into a decorated pot. +type DecoratedPotInserted struct { + sound + // Progress is how much of the decorated pot has been filled. + Progress float64 +} + +// DecoratedPotInsertFailed is a sound played when an item fails to be inserted into a decorated pot. +type DecoratedPotInsertFailed struct{ sound } + +// sound implements the world.Sound interface. +type sound struct{} + +// Play ... +func (sound) Play(*world.World, mgl64.Vec3) {} diff --git a/server/world/sound/disc_type.go b/server/world/sound/disc_type.go new file mode 100644 index 0000000..dadb4a2 --- /dev/null +++ b/server/world/sound/disc_type.go @@ -0,0 +1,216 @@ +package sound + +import ( + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +// DiscType represents the type of music disc. Typically, Minecraft has a total of 15 music discs. +type DiscType struct { + disc +} + +// Disc13 returns the music disc "13". +func Disc13() DiscType { + return DiscType{0} +} + +// DiscCat returns the music disc "cat". +func DiscCat() DiscType { + return DiscType{1} +} + +// DiscBlocks returns the music disc "blocks". +func DiscBlocks() DiscType { + return DiscType{2} +} + +// DiscChirp returns the music disc "chirp". +func DiscChirp() DiscType { + return DiscType{3} +} + +// DiscFar returns the music disc "far". +func DiscFar() DiscType { + return DiscType{4} +} + +// DiscMall returns the music disc "mall". +func DiscMall() DiscType { + return DiscType{5} +} + +// DiscMellohi returns the music disc "mellohi". +func DiscMellohi() DiscType { + return DiscType{6} +} + +// DiscStal returns the music disc "stal". +func DiscStal() DiscType { + return DiscType{7} +} + +// DiscStrad returns the music disc "strad". +func DiscStrad() DiscType { + return DiscType{8} +} + +// DiscWard returns the music disc "ward". +func DiscWard() DiscType { + return DiscType{9} +} + +// Disc11 returns the music disc "11". +func Disc11() DiscType { + return DiscType{10} +} + +// DiscWait returns the music disc "wait". +func DiscWait() DiscType { + return DiscType{11} +} + +// DiscOtherside returns the music disc "otherside". +func DiscOtherside() DiscType { + return DiscType{12} +} + +// DiscPigstep returns the music disc "Pigstep". +func DiscPigstep() DiscType { + return DiscType{13} +} + +// Disc5 returns the music disc "5". +func Disc5() DiscType { + return DiscType{14} +} + +// DiscRelic returns the music disc "Relic". +func DiscRelic() DiscType { + return DiscType{15} +} + +// DiscCreator returns the music disc "Creator". +func DiscCreator() DiscType { + return DiscType{16} +} + +// DiscCreatorMusicBox returns the music disc "Creator (Music Box)". +func DiscCreatorMusicBox() DiscType { + return DiscType{17} +} + +// DiscPrecipice returns the music disc "Precipice". +func DiscPrecipice() DiscType { + return DiscType{18} +} + +// DiscTears returns the music disc "Tears". +func DiscTears() DiscType { + return DiscType{19} +} + +// DiscLavaChicken returns the music disc "Lava Chicken". +func DiscLavaChicken() DiscType { + return DiscType{20} +} + +// MusicDiscs returns a list of all existing music discs. +func MusicDiscs() []DiscType { + return []DiscType{ + Disc13(), DiscCat(), DiscBlocks(), DiscChirp(), DiscFar(), DiscMall(), DiscMellohi(), DiscStal(), + DiscStrad(), DiscWard(), Disc11(), DiscWait(), DiscOtherside(), DiscPigstep(), Disc5(), DiscRelic(), + DiscCreator(), DiscCreatorMusicBox(), DiscPrecipice(), DiscTears(), DiscLavaChicken(), + } +} + +// disc is the underlying value of a DiscType struct. +type disc uint8 + +// Uint8 converts the disc to an integer that uniquely identifies its type. +func (d disc) Uint8() uint8 { + return uint8(d) +} + +// String ... +func (d disc) String() string { + switch d { + case 0: + return "13" + case 1: + return "cat" + case 2: + return "blocks" + case 3: + return "chirp" + case 4: + return "far" + case 5: + return "mall" + case 6: + return "mellohi" + case 7: + return "stal" + case 8: + return "strad" + case 9: + return "ward" + case 10: + return "11" + case 11: + return "wait" + case 12: + return "otherside" + case 13: + return "pigstep" + case 14: + return "5" + case 15: + return "relic" + case 16: + return "creator" + case 17: + return "creator_music_box" + case 18: + return "precipice" + case 19: + return "tears" + case 20: + return "lava_chicken" + } + panic("unknown record type") +} + +// DisplayName ... +func (d disc) DisplayName() string { + switch d { + case 17: + return "Creator (Music Box)" + case 20: + return "Lava Chicken" + } + if d > 12 { + return cases.Title(language.English, cases.Compact).String(d.String()) + } + return d.String() +} + +// Author ... +func (d disc) Author() string { + if d <= 11 { + return "C418" + } + switch d { + case 12, 13, 16, 17: + return "Lena Raine" + case 14: + return "Samuel Åberg" + case 15, 18: + return "Aaron Cherof" + case 19: + return "Amos Roddy" + case 20: + return "Hyper Potions" + } + panic("unknown record type") +} diff --git a/server/world/sound/entity.go b/server/world/sound/entity.go new file mode 100644 index 0000000..9547c2a --- /dev/null +++ b/server/world/sound/entity.go @@ -0,0 +1,60 @@ +package sound + +// Attack is a sound played when an entity, most notably a player, attacks another entity. +type Attack struct { + // Damage specifies if the attack actually dealt damage to the other entity. If set to false, the sound + // will be different from when set to true. + Damage bool + + sound +} + +// Drowning is a sound played when an entity is drowning in water. +type Drowning struct{ sound } + +// Burning is a sound played when an entity is on fire. +type Burning struct{ sound } + +// Fall is a sound played when an entity falls and hits ground. +type Fall struct { + // Distance is the distance the entity has fallen. + Distance float64 + + sound +} + +// Burp is a sound played when a player finishes eating an item. +type Burp struct{ sound } + +// Pop is a sound played when a chicken lays an egg. +type Pop struct{ sound } + +// Explosion is a sound played when an explosion happens, such as from a creeper or TNT. +type Explosion struct{ sound } + +// Thunder is a sound played when lightning strikes the ground. +type Thunder struct{ sound } + +// LevelUp is a sound played for a player whenever they level up. +type LevelUp struct{ sound } + +// Experience is a sound played whenever a player picks up an XP orb. +type Experience struct{ sound } + +// GhastWarning is a sound played when a ghast is ready to attack. +type GhastWarning struct{ sound } + +// GhastShoot is a sound played when a ghast shoots a fire charge. +type GhastShoot struct{ sound } + +// FireworkLaunch is a sound played when a firework is launched. +type FireworkLaunch struct{ sound } + +// FireworkHugeBlast is a sound played when a huge sphere firework explodes. +type FireworkHugeBlast struct{ sound } + +// FireworkBlast is a sound played when a small sphere firework explodes. +type FireworkBlast struct{ sound } + +// FireworkTwinkle is a sound played when a firework explodes and should twinkle. +type FireworkTwinkle struct{ sound } diff --git a/server/world/sound/goat_horn.go b/server/world/sound/goat_horn.go new file mode 100644 index 0000000..7dc0d55 --- /dev/null +++ b/server/world/sound/goat_horn.go @@ -0,0 +1,81 @@ +package sound + +// Horn represents a variant of a goat horn. +type Horn struct { + goatHornType +} + +// Ponder returns the 'Ponder' goat horn type. +func Ponder() Horn { + return Horn{0} +} + +// Sing returns the 'Sing' goat horn type. +func Sing() Horn { + return Horn{1} +} + +// Seek returns the 'Seek' goat horn type. +func Seek() Horn { + return Horn{2} +} + +// Feel returns the 'Feel' goat horn type. +func Feel() Horn { + return Horn{3} +} + +// Admire returns the 'Admire' goat horn type. +func Admire() Horn { + return Horn{4} +} + +// Call returns the 'Call' goat horn type. +func Call() Horn { + return Horn{5} +} + +// Yearn returns the 'Yearn' goat horn type. +func Yearn() Horn { + return Horn{6} +} + +// Dream returns the 'Dream' goat horn type. +func Dream() Horn { + return Horn{7} +} + +type goatHornType uint8 + +// Uint8 returns the goat horn type as a uint8. +func (g goatHornType) Uint8() uint8 { + return uint8(g) +} + +// Name returns the goat horn type's name. +func (g goatHornType) Name() string { + switch g { + case 0: + return "Ponder" + case 1: + return "Sing" + case 2: + return "Seek" + case 3: + return "Feel" + case 4: + return "Admire" + case 5: + return "Call" + case 6: + return "Yearn" + case 7: + return "Dream" + } + panic("should never happen") +} + +// GoatHorns ... +func GoatHorns() []Horn { + return []Horn{Ponder(), Sing(), Seek(), Feel(), Admire(), Call(), Yearn(), Dream()} +} diff --git a/server/world/sound/instrument.go b/server/world/sound/instrument.go new file mode 100644 index 0000000..67efd16 --- /dev/null +++ b/server/world/sound/instrument.go @@ -0,0 +1,93 @@ +package sound + +// Instrument represents a note block instrument. +type Instrument struct { + instrument +} + +type instrument int32 + +// Int32 ... +func (i instrument) Int32() int32 { + return int32(i) +} + +// Piano is an instrument type for the note block. +func Piano() Instrument { + return Instrument{0} +} + +// BassDrum is an instrument type for the note block. +func BassDrum() Instrument { + return Instrument{1} +} + +// Snare is an instrument type for the note block. +func Snare() Instrument { + return Instrument{2} +} + +// ClicksAndSticks is an instrument type for the note block. +func ClicksAndSticks() Instrument { + return Instrument{3} +} + +// Bass is an instrument type for the note block. +func Bass() Instrument { + return Instrument{4} +} + +// Flute is an instrument type for the note block. +func Flute() Instrument { + return Instrument{5} +} + +// Bell is an instrument type for the note block. +func Bell() Instrument { + return Instrument{6} +} + +// Guitar is an instrument type for the note block. +func Guitar() Instrument { + return Instrument{7} +} + +// Chimes is an instrument type for the note block. +func Chimes() Instrument { + return Instrument{8} +} + +// Xylophone is an instrument type for the note block. +func Xylophone() Instrument { + return Instrument{9} +} + +// IronXylophone is an instrument type for the note block. +func IronXylophone() Instrument { + return Instrument{10} +} + +// CowBell is an instrument type for the note block. +func CowBell() Instrument { + return Instrument{11} +} + +// Didgeridoo is an instrument type for the note block. +func Didgeridoo() Instrument { + return Instrument{12} +} + +// Bit is an instrument type for the note block. +func Bit() Instrument { + return Instrument{13} +} + +// Banjo is an instrument type for the note block. +func Banjo() Instrument { + return Instrument{14} +} + +// Pling is an instrument type for the note block. +func Pling() Instrument { + return Instrument{15} +} diff --git a/server/world/sound/item.go b/server/world/sound/item.go new file mode 100644 index 0000000..69ddda6 --- /dev/null +++ b/server/world/sound/item.go @@ -0,0 +1,98 @@ +package sound + +import "github.com/df-mc/dragonfly/server/world" + +// ItemBreak is a sound played when an item in the inventory is broken, such as when a tool reaches 0 +// durability and breaks. +type ItemBreak struct{ sound } + +// ItemThrow is a sound played when a player throws an item, such as a snowball. +type ItemThrow struct{ sound } + +// ItemUseOn is a sound played when a player uses its item on a block. An example of this is when a player +// uses a shovel to turn grass into dirt path. Note that in these cases, the Block is actually the new block, +// not the old one. +type ItemUseOn struct { + // Block is generally the block that was created by using the item on a block. The sound played differs + // depending on this field. + Block world.Block + + sound +} + +// EquipItem is a sound played when the player fast equips an item by using it. +type EquipItem struct { + // Item is the item that was equipped. The sound played differs depending on this field. + Item world.Item + + sound +} + +// BucketFill is a sound played when a bucket is filled using a liquid source block from the world. +type BucketFill struct { + // Liquid is the liquid that the bucket is filled up with. + Liquid world.Liquid + + sound +} + +// BucketEmpty is a sound played when a bucket with a liquid in it is placed into the world. +type BucketEmpty struct { + // Liquid is the liquid that the bucket places into the world. + Liquid world.Liquid + + sound +} + +// BowShoot is a sound played when a bow is shot. +type BowShoot struct{ sound } + +// CrossbowLoad is a sound when a crossbow is being loaded. +type CrossbowLoad struct { + // Stage is the stage of the crossbow. + Stage int + // QuickCharge returns if the item being used has quick charge enchantment. + QuickCharge bool + + sound +} + +// CrossbowShoot is a sound played when a crossbow is shot. +type CrossbowShoot struct{ sound } + +const ( + // CrossbowLoadingStart is the stage where a crossbows start to load. + CrossbowLoadingStart = iota + // CrossbowLoadingMiddle is the stage where a crossbow loading but not yet + // finished loading. + CrossbowLoadingMiddle + // CrossbowLoadingEnd is the stage where a crossbow is finished loading. + CrossbowLoadingEnd +) + +// ArrowHit is a sound played when an arrow hits ground. +type ArrowHit struct{ sound } + +// Teleport is a sound played upon teleportation of an enderman, or teleportation of a player by an ender pearl or a chorus fruit. +type Teleport struct{ sound } + +// UseSpyglass is a sound played when a player uses a spyglass. +type UseSpyglass struct{ sound } + +// StopUsingSpyglass is a sound played when a player stops using a spyglass. +type StopUsingSpyglass struct{ sound } + +// GoatHorn is a sound played when a player uses a goat horn. +type GoatHorn struct { + // Horn is the type of the goat horn. + Horn Horn + + sound +} + +// FireCharge is a sound played when a player lights a block on fire with a fire charge, or when a dispenser or a +// blaze shoots a fireball. +type FireCharge struct{ sound } + +// Totem is a sound played when a player uses a totem. +type Totem struct{ sound } diff --git a/server/world/structure.go b/server/world/structure.go new file mode 100644 index 0000000..ecebcd5 --- /dev/null +++ b/server/world/structure.go @@ -0,0 +1,17 @@ +package world + +// Structure represents a structure which may be placed in the world. It has fixed dimensions. +type Structure interface { + // Dimensions returns the dimensions of the structure. It returns an int array with the width, height and + // length respectively. + Dimensions() [3]int + // At returns the block at a specific location in the structure. When the structure is placed in the + // world, this method is called for every location within the dimensions of the structure. Additionally, + // At can return a Liquid to be placed in the same place as the block. + // At can return nil to not place any block at the position. Returning Air will set any block at that + // position to air, but returning nil will not do anything. + // In addition to the coordinates, At will have a function passed that may be used to get a block at a + // specific position. In scope of At(), structures should use this over World.Block(), due to the way + // chunks are locked. + At(x, y, z int, blockAt func(x, y, z int) Block) (Block, Liquid) +} diff --git a/server/world/tick.go b/server/world/tick.go new file mode 100644 index 0000000..9e47631 --- /dev/null +++ b/server/world/tick.go @@ -0,0 +1,383 @@ +package world + +import ( + "maps" + "math/rand/v2" + "slices" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/internal/sliceutil" +) + +// ticker implements World ticking methods. +type ticker struct { + interval time.Duration +} + +// tickLoop starts ticking the World 20 times every second, updating all +// entities, blocks and other features such as the time and weather of the +// world, as required. +func (t ticker) tickLoop(w *World) { + tc := time.NewTicker(t.interval) + defer tc.Stop() + for { + select { + case <-tc.C: + <-w.Exec(t.tick) + case <-w.closing: + // World is being closed: Stop ticking and get rid of a task. + w.running.Done() + return + } + } +} + +// AdvanceTick advances the World by a single tick. It is generally only useful +// for Worlds created with Config.Synchronous set: other Worlds tick +// automatically 20 times per second. Synchronous Worlds tick loaded chunks +// even when no viewers are present. +func (w *World) AdvanceTick() { + <-w.Exec(ticker{}.tick) +} + +// tick performs a tick on the World and updates the time, weather, blocks and +// entities that require updates. +func (t ticker) tick(tx *Tx) { + viewers, loaders := tx.World().allViewers() + w := tx.World() + + w.set.Lock() + if s := w.set.Spawn; s[1] > tx.Range()[1] && w.Dimension() == Overworld { + // Vanilla will set the spawn position's Y value to max to indicate that + // the player should spawn at the highest position in the world. + w.set.Spawn[1] = w.highestObstructingBlock(s[0], s[2]) + 1 + } + if len(viewers) == 0 && w.set.CurrentTick != 0 && !w.conf.Synchronous { + // Don't continue ticking if no viewers are in the world. Synchronous + // worlds only tick on explicit AdvanceTick calls, so they always tick. + w.set.Unlock() + return + } + if w.advance { + w.set.CurrentTick++ + if w.set.TimeCycle { + w.set.Time++ + } + if w.set.WeatherCycle { + w.advanceWeather() + } + } + + rain, thunder, tick, tim, cycle := w.set.Raining, w.set.Thundering && w.set.Raining, w.set.CurrentTick, int(w.set.Time), w.set.TimeCycle + + tryAdvanceDay := false + if tx.w.set.RequiredSleepTicks > 0 { + tx.w.set.RequiredSleepTicks-- + tryAdvanceDay = tx.w.set.RequiredSleepTicks <= 0 + } + + w.set.Unlock() + + if tryAdvanceDay { + t.tryAdvanceDay(tx, cycle) + } + + if tick%20 == 0 { + for _, viewer := range viewers { + if w.Dimension().TimeCycle() && cycle { + viewer.ViewTime(tim) + } + if w.Dimension().WeatherCycle() { + viewer.ViewWeather(rain, thunder) + } + } + } + if thunder { + w.tickLightning(tx) + } + + t.tickEntities(tx, tick) + w.scheduledUpdates.tick(tx, tick) + t.tickBlocksRandomly(tx, loaders, tick) + t.performNeighbourUpdates(tx) +} + +// performNeighbourUpdates performs all block updates that came as a result of a neighbouring block being changed. +func (t ticker) performNeighbourUpdates(tx *Tx) { + updates := slices.Clone(tx.World().neighbourUpdates) + clear(tx.World().neighbourUpdates) + tx.World().neighbourUpdates = tx.World().neighbourUpdates[:0] + + for _, update := range updates { + pos, changedNeighbour := update.pos, update.neighbour + if ticker, ok := tx.Block(pos).(NeighbourUpdateTicker); ok { + ticker.NeighbourUpdateTick(pos, changedNeighbour, tx) + } + if liquid, ok := tx.World().additionalLiquid(pos); ok { + if ticker, ok := liquid.(NeighbourUpdateTicker); ok { + ticker.NeighbourUpdateTick(pos, changedNeighbour, tx) + } + } + } +} + +// tickBlocksRandomly executes random block ticks in loaded chunks within range of loaders. +func (t ticker) tickBlocksRandomly(tx *Tx, loaders []*Loader, tick int64) { + var ( + r = int32(tx.World().tickRange()) + g randUint4 + blockEntities []cube.Pos + randomBlocks []cube.Pos + ) + if r == 0 { + // NOP if the simulation distance is 0. + return + } + + loaded := make([]ChunkPos, 0, len(loaders)) + if tx.World().conf.Synchronous { + loaded = slices.Collect(maps.Keys(tx.World().chunks)) + } else { + for _, loader := range loaders { + loader.mu.RLock() + pos := loader.pos + loader.mu.RUnlock() + + loaded = append(loaded, pos) + } + } + + for pos, c := range tx.World().chunks { + if !t.anyWithinDistance(pos, loaded, r) { + // No loaders in this chunk that are within the simulation distance, so proceed to the next. + continue + } + blockEntities = append(blockEntities, slices.Collect(maps.Keys(c.BlockEntities))...) + + cx, cz := int(pos[0]<<4), int(pos[1]<<4) + + // We generate up to j random positions for every sub chunk. + for j := 0; j < tx.World().conf.RandomTickSpeed; j++ { + x, y, z := g.uint4(tx.World().r), g.uint4(tx.World().r), g.uint4(tx.World().r) + + for i, sub := range c.Sub() { + if sub.Empty() { + // SubChunk is empty, so skip it right away. + continue + } + // Generally we would want to make sure the block has its block entities, but provided blocks + // with block entities are generally ticked already, we are safe to assume that blocks + // implementing the RandomTicker don't rely on additional block entity data. + if rid := sub.Layers()[0].At(x, y, z); tx.World().conf.Blocks.RandomTickBlock(rid) { + subY := (i + (tx.Range().Min() >> 4)) << 4 + randomBlocks = append(randomBlocks, cube.Pos{cx + int(x), subY + int(y), cz + int(z)}) + + // Only generate new coordinates if a tickable block was actually found. If not, we can just re-use + // the coordinates for the next sub chunk. + x, y, z = g.uint4(tx.World().r), g.uint4(tx.World().r), g.uint4(tx.World().r) + } + } + } + } + + for _, pos := range randomBlocks { + if rb, ok := tx.Block(pos).(RandomTicker); ok { + rb.RandomTick(pos, tx, tx.World().r) + } + } + for _, pos := range blockEntities { + if tb, ok := tx.Block(pos).(TickerBlock); ok { + tb.Tick(tick, pos, tx) + } + } +} + +// anyWithinDistance checks if any of the ChunkPos loaded are within the distance r of the ChunkPos pos. +func (t ticker) anyWithinDistance(pos ChunkPos, loaded []ChunkPos, r int32) bool { + for _, chunkPos := range loaded { + xDiff, zDiff := chunkPos[0]-pos[0], chunkPos[1]-pos[1] + if (xDiff*xDiff)+(zDiff*zDiff) <= r*r { + // The chunk was within the simulation distance of at least one viewer, so we can proceed to + // ticking the block. + return true + } + } + return false +} + +// tickEntities ticks all entities in the world, making sure they are still located in the correct chunks and +// updating where necessary. +func (t ticker) tickEntities(tx *Tx, tick int64) { + for handle, lastPos := range tx.World().entities { + e := handle.mustEntity(tx) + chunkPos := chunkPosFromVec3(handle.data.Pos) + + c, ok := tx.World().chunks[chunkPos] + if !ok { + continue + } + + if lastPos != chunkPos { + // The entity was stored using an outdated chunk position. We update it and make sure it is ready + // for loaders to view it. + tx.World().entities[handle] = chunkPos + c.Entities = append(c.Entities, handle) + + var viewers []Viewer + + // When changing an entity's world, then teleporting it immediately, we could end up in a situation + // where the old chunk of the entity was not loaded. In this case, it should be safe simply to ignore + // the loaders from the old chunk. We can assume they never saw the entity in the first place. + if old, ok := tx.World().chunks[lastPos]; ok { + old.Entities = sliceutil.DeleteVal(old.Entities, handle) + viewers = old.viewers + } + + for _, viewer := range viewers { + if slices.Index(c.viewers, viewer) == -1 { + // First we hide the entity from all loaders that were previously viewing it, but no + // longer are. + viewer.HideEntity(e) + } + } + for _, viewer := range c.viewers { + if slices.Index(viewers, viewer) == -1 { + // Then we show the entity to all loaders that are now viewing the entity in the new + // chunk. + showEntity(e, viewer) + } + } + } + + if tx.World().conf.Synchronous || len(c.viewers) > 0 { + if te, ok := e.(TickerEntity); ok { + te.Tick(tx, tick) + } + } + } +} + +// randUint4 is a structure used to generate random uint4s. +type randUint4 struct { + x uint64 + n uint8 +} + +// uint4 returns a random uint4. +func (g *randUint4) uint4(r *rand.Rand) uint8 { + if g.n == 0 { + g.x = r.Uint64() + g.n = 16 + } + val := g.x & 0b1111 + + g.x >>= 4 + g.n-- + return uint8(val) +} + +// scheduledTickQueue implements a queue for scheduled block updates. Scheduled +// block updates are both position and block type specific. +type scheduledTickQueue struct { + ticks []scheduledTick + furthestTicks map[scheduledTickIndex]int64 + currentTick int64 +} + +type scheduledTick struct { + pos cube.Pos + b Block + bhash uint64 + t int64 +} + +type scheduledTickIndex struct { + pos cube.Pos + hash uint64 +} + +// newScheduledTickQueue creates a queue for scheduled block ticks. +func newScheduledTickQueue(tick int64) *scheduledTickQueue { + return &scheduledTickQueue{furthestTicks: make(map[scheduledTickIndex]int64), currentTick: tick} +} + +// tick processes scheduled ticks, calling ScheduledTicker.ScheduledTick for any +// block update that is scheduled for the tick passed, and removing it from the +// queue. +func (queue *scheduledTickQueue) tick(tx *Tx, tick int64) { + queue.currentTick = tick + + w := tx.World() + for _, t := range queue.ticks { + if t.t > tick { + continue + } + b := tx.Block(t.pos) + if ticker, ok := b.(ScheduledTicker); ok && w.conf.Blocks.BlockHash(b) == t.bhash { + ticker.ScheduledTick(t.pos, tx, w.r) + } else if liquid, ok := tx.World().additionalLiquid(t.pos); ok && w.conf.Blocks.BlockHash(liquid) == t.bhash { + if ticker, ok := liquid.(ScheduledTicker); ok { + ticker.ScheduledTick(t.pos, tx, w.r) + } + } + } + + // Clear scheduled ticks that were processed from the queue. + queue.ticks = slices.DeleteFunc(queue.ticks, func(t scheduledTick) bool { + return t.t <= tick + }) + maps.DeleteFunc(queue.furthestTicks, func(index scheduledTickIndex, t int64) bool { + return t <= tick + }) +} + +// schedule schedules a block update at the position passed for the block type +// passed after a specific delay. A block update is only scheduled if no block +// update with the same position and block type is already scheduled at a later +// time than the newly scheduled update. +func (queue *scheduledTickQueue) schedule(br BlockRegistry, pos cube.Pos, b Block, delay time.Duration) { + resTick := queue.currentTick + int64(max(delay/(time.Second/20), 1)) + index := scheduledTickIndex{pos: pos, hash: br.BlockHash(b)} + if t, ok := queue.furthestTicks[index]; ok && t >= resTick { + // Already have a tick scheduled for this position that will occur after + // the delay passed. Block updates can only be scheduled if they are + // after any currently scheduled updates. + return + } + queue.furthestTicks[index] = resTick + queue.ticks = append(queue.ticks, scheduledTick{pos: pos, t: resTick, b: b, bhash: index.hash}) +} + +// fromChunk returns all scheduled ticks positioned within a ChunkPos. +func (queue *scheduledTickQueue) fromChunk(pos ChunkPos) []scheduledTick { + m := make([]scheduledTick, 0, 8) + for _, t := range queue.ticks { + if pos == chunkPosFromBlockPos(t.pos) { + m = append(m, t) + } + } + return m +} + +// removeChunk removes all scheduled ticks positioned within a ChunkPos. +func (queue *scheduledTickQueue) removeChunk(pos ChunkPos) { + queue.ticks = slices.DeleteFunc(queue.ticks, func(tick scheduledTick) bool { + return chunkPosFromBlockPos(tick.pos) == pos + }) +} + +// add adds a slice of scheduled ticks to the queue. It assumes no duplicate +// ticks are present in the slice. +func (queue *scheduledTickQueue) add(ticks []scheduledTick) { + queue.ticks = append(queue.ticks, ticks...) + for _, t := range ticks { + index := scheduledTickIndex{pos: t.pos, hash: t.bhash} + if existing, ok := queue.furthestTicks[index]; ok { + // Make sure we find the furthest tick for each of the ticks added. + // Some ticks may have the same block and position, in which case we + // need to set the furthest tick. + queue.furthestTicks[index] = max(existing, t.t) + } + } +} diff --git a/server/world/tx.go b/server/world/tx.go new file mode 100644 index 0000000..6165589 --- /dev/null +++ b/server/world/tx.go @@ -0,0 +1,389 @@ +package world + +import ( + "iter" + "sync" + "sync/atomic" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/player/chat" + "github.com/df-mc/dragonfly/server/world/redstone" + "github.com/go-gl/mathgl/mgl64" +) + +// Tx represents a synchronised transaction performed on a World. Most +// operations on a World can only be called through a transaction. Tx is not +// safe for use by multiple goroutines concurrently. +type Tx struct { + w *World + closed bool +} + +// Range returns the lower and upper bounds of the World that the Tx is +// operating on. +func (tx *Tx) Range() cube.Range { + return tx.w.ra +} + +// SetBlock writes a block to the position passed. If a chunk is not yet loaded +// at that position, the chunk is first loaded or generated if it could not be +// found in the world save. SetBlock panics if the block passed has not yet +// been registered using RegisterBlock(). Nil may be passed as the block to set +// the block to air. +// +// A SetOpts struct may be passed to additionally modify behaviour of SetBlock, +// specifically to improve performance under specific circumstances. Nil should +// be passed where performance is not essential, to make sure the world is +// updated adequately. +// +// SetBlock should be avoided in situations where performance is critical when +// needing to set a lot of blocks to the world. BuildStructure may be used +// instead. +func (tx *Tx) SetBlock(pos cube.Pos, b Block, opts *SetOpts) { + tx.World().setBlock(pos, b, opts) +} + +// Block reads a block from the position passed. If a chunk is not yet loaded +// at that position, the chunk is loaded, or generated if it could not be found +// in the world save, and the block returned. +func (tx *Tx) Block(pos cube.Pos) Block { + return tx.World().block(pos) +} + +// Liquid attempts to return a Liquid block at the position passed. This +// Liquid may be in the foreground or in any other layer. If found, the Liquid +// is returned. If not, the bool returned is false. +func (tx *Tx) Liquid(pos cube.Pos) (Liquid, bool) { + return tx.World().liquid(pos) +} + +// SetLiquid sets a Liquid at a specific position in the World. Unlike +// SetBlock, SetLiquid will not necessarily overwrite any existing blocks. It +// will instead be in the same position as a block currently there, unless +// there already is a Liquid at that position, in which case it will be +// overwritten. If nil is passed for the Liquid, any Liquid currently present +// will be removed. +func (tx *Tx) SetLiquid(pos cube.Pos, b Liquid) { + tx.World().setLiquid(pos, b) +} + +// BuildStructure builds a Structure passed at a specific position in the +// world. Unlike SetBlock, it takes a Structure implementation, which provides +// blocks to be placed at a specific location. BuildStructure is specifically +// optimised to be able to process a large batch of chunks simultaneously and +// will do so within much less time than separate SetBlock calls would. The +// method operates on a per-chunk basis, setting all blocks within a single +// chunk part of the Structure before moving on to the next chunk. +func (tx *Tx) BuildStructure(pos cube.Pos, s Structure) { + tx.World().buildStructure(pos, s) +} + +// ScheduleBlockUpdate schedules a block update at the position passed for the +// block type passed after a specific delay. If the block at that position does +// not handle block updates, nothing will happen. +// Block updates are both block and position specific. A block update is only +// scheduled if no block update with the same position and block type is +// already scheduled at a later time than the newly scheduled update. +func (tx *Tx) ScheduleBlockUpdate(pos cube.Pos, b Block, delay time.Duration) { + tx.World().scheduleBlockUpdate(pos, b, delay) +} + +// HighestLightBlocker gets the Y value of the highest fully light blocking +// block at the x and z values passed in the World. +func (tx *Tx) HighestLightBlocker(x, z int) int { + return tx.World().HighestLightBlocker(x, z) +} + +// HighestBlock looks up the highest non-air block in the World at a specific x +// and z. The y value of the highest block is returned, or 0 if no blocks were +// present in the column. +func (tx *Tx) HighestBlock(x, z int) int { + return tx.World().highestBlock(x, z) +} + +// Light returns the light level at the position passed. This is the highest of +// the sky- and block light. The light value returned is a value in the range +// 0-15, where 0 means there is no light present, whereas 15 means the block is +// fully lit. +func (tx *Tx) Light(pos cube.Pos) uint8 { + return tx.World().light(pos) +} + +// SkyLight returns the skylight level at the position passed. This light level +// is not influenced by blocks that emit light, such as torches. The light +// value, similarly to Light, is a value in the range 0-15, where 0 means no +// light is present. +func (tx *Tx) SkyLight(pos cube.Pos) uint8 { + return tx.World().skyLight(pos) +} + +// SetBiome sets the Biome at the position passed. If a chunk is not yet loaded +// at that position, the chunk is first loaded or generated if it could not be +// found in the world save. +func (tx *Tx) SetBiome(pos cube.Pos, b Biome) { + tx.World().setBiome(pos, b) +} + +// Biome reads the Biome at the position passed. If a chunk is not yet loaded +// at that position, the chunk is loaded, or generated if it could not be found +// in the world save, and the Biome returned. +func (tx *Tx) Biome(pos cube.Pos) Biome { + return tx.World().biome(pos) +} + +// Temperature returns the temperature in the World at a specific position. +// Higher altitudes and different biomes influence the temperature returned. +func (tx *Tx) Temperature(pos cube.Pos) float64 { + return tx.World().temperature(pos) +} + +// RainingAt checks if it is raining at a specific cube.Pos in the World. True +// is returned if it is raining, if the temperature is high enough in the biome +// for it not to be snow and if the block is above the top-most obstructing +// block. +func (tx *Tx) RainingAt(pos cube.Pos) bool { + return tx.World().rainingAt(pos) +} + +// SnowingAt checks if it is snowing at a specific cube.Pos in the World. True +// is returned if the temperature in the Biome at that position is sufficiently +// low, if it is raining and if it's above the top-most obstructing block. +func (tx *Tx) SnowingAt(pos cube.Pos) bool { + return tx.World().snowingAt(pos) +} + +// ThunderingAt checks if it is thundering at a specific cube.Pos in the World. +// True is returned if RainingAt returns true and if it is thundering in the +// world. +func (tx *Tx) ThunderingAt(pos cube.Pos) bool { + return tx.World().thunderingAt(pos) +} + +// Raining checks if it is raining anywhere in the World. +func (tx *Tx) Raining() bool { + return tx.World().raining() +} + +// Thundering checks if it is thundering anywhere in the World. +func (tx *Tx) Thundering() bool { + return tx.World().thundering() +} + +// AddParticle spawns a Particle at a given position in the World. Viewers that +// are viewing the chunk will be shown the particle. +func (tx *Tx) AddParticle(pos mgl64.Vec3, p Particle) { + tx.World().addParticle(pos, p) +} + +// PlayEntityAnimation plays an animation on an entity in the World. The animation is played for all viewers +// of the entity. +func (tx *Tx) PlayEntityAnimation(e Entity, a EntityAnimation) { + for _, viewer := range tx.World().viewersOf(e.Position()) { + viewer.ViewEntityAnimation(e, a) + } +} + +// PlaySound plays a sound at a specific position in the World. Viewers of that +// position will be able to hear the sound if they are close enough. +func (tx *Tx) PlaySound(pos mgl64.Vec3, s Sound) { + tx.World().playSound(tx, pos, s) +} + +// AddEntity adds an EntityHandle to a World. The Entity will be visible to all +// viewers of the World that have the chunk at the EntityHandle's position. If +// the chunk that the EntityHandle is in is not yet loaded, it will first be +// loaded. AddEntity panics if the EntityHandle is already in a world. +// AddEntity returns the Entity created by the EntityHandle. +func (tx *Tx) AddEntity(e *EntityHandle) Entity { + return tx.World().addEntity(tx, e) +} + +// 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 +// from the World, the Entity is no longer usable. +func (tx *Tx) RemoveEntity(e Entity) *EntityHandle { + return tx.World().removeEntity(e, tx) +} + +// EntitiesWithin returns an iterator that yields all entities contained within +// the cube.BBox passed. +func (tx *Tx) EntitiesWithin(box cube.BBox) iter.Seq[Entity] { + return tx.World().entitiesWithin(tx, box) +} + +// Entities returns an iterator that yields all entities in the World. +func (tx *Tx) Entities() iter.Seq[Entity] { + return tx.World().allEntities(tx) +} + +// Players returns an iterator that yields all player entities in the World. +func (tx *Tx) Players() iter.Seq[Entity] { + return tx.World().allPlayers(tx) +} + +// Viewers returns all viewers viewing the position passed. +func (tx *Tx) Viewers(pos mgl64.Vec3) []Viewer { + return tx.World().viewersOf(pos) +} + +// Sleepers returns an iterator that yields all sleeping entities currently added to the World. +func (tx *Tx) Sleepers() iter.Seq[Sleeper] { + ent := tx.Entities() + return func(yield func(Sleeper) bool) { + for e := range ent { + if sleeper, ok := e.(Sleeper); ok { + if !yield(sleeper) { + return + } + } + } + } +} + +// BroadcastSleepingIndicator broadcasts a sleeping indicator to all sleepers in the world. +func (tx *Tx) BroadcastSleepingIndicator() { + sleepers := tx.Sleepers() + + var sleeping, allSleepers int + for s := range sleepers { + allSleepers++ + if _, ok := s.Sleeping(); ok { + sleeping++ + } + } + + for s := range sleepers { + s.SendSleepingIndicator(sleeping, allSleepers) + } +} + +// BroadcastSleepingReminder broadcasts a sleeping reminder message to all sleepers in the world, excluding the sleeper +// passed. +func (tx *Tx) BroadcastSleepingReminder(sleeper Sleeper) { + sleepers := tx.Sleepers() + + var notSleeping int + for s := range sleepers { + if _, ok := s.Sleeping(); !ok { + notSleeping++ + } + } + + for s := range sleepers { + if _, ok := s.Sleeping(); !ok { + s.Messaget(chat.MessageSleeping, sleeper.Name(), notSleeping) + } + } +} + +// RedstonePower returns the redstone power emitted by the block at pos toward a neighbouring receiver. +// The face argument is relative to the receiving block. +func (tx *Tx) RedstonePower(pos cube.Pos, face cube.Face, accountForDust bool) (power int) { + b := tx.Block(pos) + if c, ok := b.(Conductor); ok { + return c.WeakPower(pos, face, tx, accountForDust) + } + // The wiki states that in the future some blocks may be transparent but still relay redstone. + // If a block implements RedstonePowerRelayer, it should always be prioritised over lightDiffuser. + if r, ok := b.(RedstonePowerRelayer); ok { + if !r.RelaysRedstonePowerThrough() { + return 0 + } + } else if d, ok := b.(lightDiffuser); ok && d.LightDiffusionLevel() != 15 { + return 0 + } + for _, f := range cube.Faces() { + if !b.Model().FaceSolid(pos, f, tx) { + return 0 + } + } + for _, f := range cube.Faces() { + c, ok := tx.Block(pos.Side(f)).(Conductor) + if !ok { + continue + } + sourcePos := pos.Side(f) + power = max(power, c.StrongPower(sourcePos, f, tx, accountForDust)) + if !accountForDust { + continue + } + if weakBlockPowerer, ok := c.(WeakBlockPowerer); ok && weakBlockPowerer.WeaklyPowersBlocks() { + power = max(power, c.WeakPower(sourcePos, f, tx, accountForDust)) + } + } + return power +} + +// World returns the World of the Tx. It panics if the transaction was already +// marked complete. +func (tx *Tx) World() *World { + if tx.closed { + panic("world.Tx: use of transaction after transaction finishes is not permitted") + } + return tx.w +} + +// CurrentTick returns the current tick of the transaction's world. +func (tx *Tx) CurrentTick() int64 { + w := tx.World() + w.set.Lock() + defer w.set.Unlock() + return w.set.CurrentTick +} + +// Redstone returns the transient redstone runtime state owned by the transaction's world. +func (tx *Tx) Redstone() *redstone.State { + return &tx.World().redstone +} + +// close finishes the Tx, causing any following call on the Tx to panic. +func (tx *Tx) close() { + tx.closed = true +} + +// normalTransaction is added to the transaction queue for transactions created +// using World.Exec(). +type normalTransaction struct { + c chan struct{} + f func(tx *Tx) +} + +// Run creates a *Tx, calls ntx.f, closes the transaction and finally closes +// ntx.c. +func (ntx normalTransaction) Run(w *World) { + tx := &Tx{w: w} + ntx.f(tx) + tx.close() + close(ntx.c) +} + +// weakTransaction is a transaction that may be cancelled by setting its invalid +// bool to false before the transaction is run. +type weakTransaction struct { + c chan bool + f func(tx *Tx) + invalid *atomic.Bool + cond *sync.Cond +} + +// Run runs the transaction, first checking if its invalid bool is false and +// creating a *Tx if so. Afterwards, a bool indicating if the transaction was +// run is added to wtx.c. Finally, wtx.cond.Broadcast() is called. +func (wtx weakTransaction) Run(w *World) { + valid := !wtx.invalid.Load() + if valid { + tx := &Tx{w: w} + wtx.f(tx) + tx.close() + } + // We have to acquire a lock on wtx.cond.L here to make sure cond.Wait() + // has been called before we call cond.Broadcast(). If not, we might + // broadcast before cond.Wait() and cause a permanent suspension. + wtx.cond.L.Lock() + defer wtx.cond.L.Unlock() + + wtx.c <- valid + wtx.cond.Broadcast() +} diff --git a/server/world/vanilla_items.nbt b/server/world/vanilla_items.nbt new file mode 100644 index 0000000..52af4d9 Binary files /dev/null and b/server/world/vanilla_items.nbt differ diff --git a/server/world/view_layer.go b/server/world/view_layer.go new file mode 100644 index 0000000..cb9098f --- /dev/null +++ b/server/world/view_layer.go @@ -0,0 +1,177 @@ +package world + +import ( + "maps" + "slices" + "sync" +) + +// layer stores the appearance overrides that a ViewLayer applies to an entity. +type layer struct { + nameTag *string + scoreTag *string + visibility VisibilityLevel +} + +// ViewLayerUpdater handles immediate updates after a ViewLayer changes how an entity is viewed. +type ViewLayerUpdater interface { + // ViewLayerEntityChanged handles an entity whose view-layer overrides changed. + ViewLayerEntityChanged(entity Entity) +} + +type viewLayerViewer interface { + ViewLayer() *ViewLayer +} + +// ViewLayer holds overrides for how entities are viewed by a single viewer. It allows entities to be +// viewed differently by different players, such as with a different name tag or visibility state. +type ViewLayer struct { + mu sync.RWMutex + entities map[*EntityHandle]layer + updater ViewLayerUpdater +} + +// NewViewLayer returns a new ViewLayer. +func NewViewLayer(updater ViewLayerUpdater) *ViewLayer { + return &ViewLayer{ + entities: map[*EntityHandle]layer{}, + updater: updater, + } +} + +// Entities returns the handles of all entities with overrides in the view layer. +func (v *ViewLayer) Entities() []*EntityHandle { + v.mu.RLock() + defer v.mu.RUnlock() + + return slices.Collect(maps.Keys(v.entities)) +} + +// ViewNameTag overwrites the public name tag of the entity and allows this ViewLayer to view a different name tag. +// Passing an empty name tag removes the name tag for this ViewLayer. +func (v *ViewLayer) ViewNameTag(entity Entity, nameTag string) { + v.update(entity, func(l *layer) { + l.nameTag = &nameTag + }) +} + +// ViewPublicNameTag removes the name tag override from the entity, causing the public name tag to be +// viewed again. +func (v *ViewLayer) ViewPublicNameTag(entity Entity) { + v.update(entity, func(l *layer) { + l.nameTag = nil + }) +} + +// NameTag returns the overwritten name tag of the entity and whether an override was set. +func (v *ViewLayer) NameTag(entity Entity) (string, bool) { + v.mu.RLock() + defer v.mu.RUnlock() + + nameTag := v.entities[entity.H()].nameTag + if nameTag == nil { + return "", false + } + return *nameTag, true +} + +// ViewScoreTag overwrites the public score tag of the entity and allows this ViewLayer to view a different score tag. +// Passing an empty score tag removes the score tag for this ViewLayer. +func (v *ViewLayer) ViewScoreTag(entity Entity, scoreTag string) { + v.update(entity, func(l *layer) { + l.scoreTag = &scoreTag + }) +} + +// ViewPublicScoreTag removes the score tag override from the entity, causing the public score tag to be +// viewed again. +func (v *ViewLayer) ViewPublicScoreTag(entity Entity) { + v.update(entity, func(l *layer) { + l.scoreTag = nil + }) +} + +// ScoreTag returns the overwritten score tag of the entity and whether an override was set. +func (v *ViewLayer) ScoreTag(entity Entity) (string, bool) { + v.mu.RLock() + defer v.mu.RUnlock() + + scoreTag := v.entities[entity.H()].scoreTag + if scoreTag == nil { + return "", false + } + return *scoreTag, true +} + +// ViewVisibility overwrites the public visibility of the entity and allows this ViewLayer to view +// this entity as (in)visible depending on the VisibilityLevel. +func (v *ViewLayer) ViewVisibility(entity Entity, level VisibilityLevel) { + v.update(entity, func(l *layer) { + l.visibility = level + }) +} + +// Visibility returns the visibility of the entity. +func (v *ViewLayer) Visibility(entity Entity) VisibilityLevel { + v.mu.RLock() + defer v.mu.RUnlock() + + return v.entities[entity.H()].visibility +} + +// Remove removes all overrides for the entity from the ViewLayer. +func (v *ViewLayer) Remove(entity Entity) { + if v.remove(entity) { + v.refresh(entity) + } +} + +// remove removes all overrides for the entity from the ViewLayer without refreshing entity metadata. It returns +// whether any overrides were removed. +func (v *ViewLayer) remove(entity Entity) bool { + handle := entity.H() + + v.mu.Lock() + _, ok := v.entities[handle] + delete(v.entities, handle) + v.mu.Unlock() + return ok +} + +// update applies a mutation to the entity's layer, removes the entry if no overrides remain, and refreshes +// the entity for the layer's viewer. +func (v *ViewLayer) update(entity Entity, update func(*layer)) { + handle := entity.H() + + v.mu.Lock() + l := v.entities[handle] + update(&l) + if l.empty() { + delete(v.entities, handle) + } else { + v.entities[handle] = l + } + v.mu.Unlock() + + v.refresh(entity) +} + +// Close closes the view layer. +func (v *ViewLayer) Close() error { + v.mu.Lock() + defer v.mu.Unlock() + + clear(v.entities) + return nil +} + +// empty checks if the layer does not override any public entity metadata. +func (l layer) empty() bool { + return l.nameTag == nil && l.scoreTag == nil && l.visibility == PublicVisibility() +} + +func (v *ViewLayer) refresh(entity Entity) { + if v.updater != nil { + v.updater.ViewLayerEntityChanged(entity) + } +} diff --git a/server/world/viewer.go b/server/world/viewer.go new file mode 100644 index 0000000..316f534 --- /dev/null +++ b/server/world/viewer.go @@ -0,0 +1,112 @@ +package world + +import ( + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world/chunk" + "github.com/go-gl/mathgl/mgl64" + "github.com/google/uuid" +) + +// Viewer is a viewer in the world. It can view changes that are made in the world, such as the addition of +// entities and the changes of blocks. +type Viewer interface { + // ViewEntity views the Entity passed. It is called for every Entity that the viewer may encounter in the + // world, either by moving entities or by moving the viewer using a world.Loader. + ViewEntity(e Entity) + // HideEntity stops viewing the Entity passed. It is called for every Entity that leaves the viewing range + // of the viewer, either by its movement or the movement of the viewer using a world.Loader. + HideEntity(e Entity) + // ViewEntityGameMode views the game mode of the Entity passed. This is necessary for game-modes like spectator, + // which may update how the Entity is viewed for others. + ViewEntityGameMode(e Entity) + // ViewEntityMovement views the movement of an Entity. The Entity is moved with a delta position, yaw and + // pitch, which, when applied to the respective values of the Entity, will result in the final values. + ViewEntityMovement(e Entity, pos mgl64.Vec3, rot cube.Rotation, onGround bool) + // ViewEntityVelocity views the velocity of an Entity. It is called right before a call to + // ViewEntityMovement so that the Viewer may interpolate the movement itself. + ViewEntityVelocity(e Entity, vel mgl64.Vec3) + // ViewEntityTeleport views the teleportation of an Entity. The Entity is immediately moved to a different + // target position. + ViewEntityTeleport(e Entity, pos mgl64.Vec3) + // ViewFurnaceUpdate updates a furnace for the associated session based on previous times. + ViewFurnaceUpdate(prevCookTime, cookTime, prevRemainingFuelTime, remainingFuelTime, prevMaxFuelTime, maxFuelTime time.Duration) + // ViewBrewingUpdate updates a brewing stand for the associated session based on previous times. + ViewBrewingUpdate(prevBrewTime, brewTime time.Duration, prevFuelAmount, fuelAmount, prevFuelTotal, fuelTotal int32) + // ViewChunk views the chunk passed at a particular position. It is called for every chunk loaded using + // the world.Loader. + ViewChunk(pos ChunkPos, dim Dimension, blockEntities map[cube.Pos]Block, c *chunk.Chunk) + // ViewTime views the time of the world. It is called every time the time is changed or otherwise every + // second. + ViewTime(t int) + // ViewTimeCycle controls the automatic time-of-day cycle (day and night) in the world for this viewer. + ViewTimeCycle(doDayLightCycle bool) + // ViewEntityItems views the items currently held by an Entity that is able to equip items. + ViewEntityItems(e Entity) + // ViewEntityArmour views the items currently equipped as armour by the Entity. + ViewEntityArmour(e Entity) + // ViewEntityAction views an action performed by an Entity. Available actions may be found in the `action` + // package, and include things such as swinging an arm. + ViewEntityAction(e Entity, a EntityAction) + // ViewEntityState views the current state of an Entity. It is called whenever an Entity changes its + // physical appearance, for example when sprinting. + ViewEntityState(e Entity) + // ViewEntityAnimation starts viewing an animation performed by an Entity. + ViewEntityAnimation(e Entity, a EntityAnimation) + // ViewParticle views a particle spawned at a given position in the world. It is called when a particle, + // for example a block breaking particle, is spawned near the player. + ViewParticle(pos mgl64.Vec3, p Particle) + // ViewSound is called when a sound is played in the world. + ViewSound(pos mgl64.Vec3, s Sound) + // ViewBlockUpdate views the updating of a block. It is called when a block is set at the position passed + // to the method. + ViewBlockUpdate(pos cube.Pos, b Block, layer int) + // ViewBlockAction views an action performed by a block. Available actions may be found in the `action` + // package, and include things such as a chest opening. + ViewBlockAction(pos cube.Pos, a BlockAction) + // ViewEmote views an emote being performed by another Entity. + ViewEmote(e Entity, emote uuid.UUID) + // ViewSkin views the current skin of a player. + ViewSkin(e Entity) + // ViewWorldSpawn views the current spawn location of the world. + ViewWorldSpawn(pos cube.Pos) + // ViewWeather views the weather of the world, including rain and thunder. + ViewWeather(raining, thunder bool) + // ViewEntityWake views an entity waking up from a bed. + ViewEntityWake(e Entity) +} + +// NopViewer is a Viewer implementation that does not implement any behaviour. It may be embedded by other structs to +// prevent having to implement all of Viewer's methods. +type NopViewer struct{} + +// Compile time check to make sure NopViewer implements Viewer. +var _ Viewer = NopViewer{} + +func (NopViewer) ViewEntity(Entity) {} +func (NopViewer) HideEntity(Entity) {} +func (NopViewer) ViewEntityGameMode(Entity) {} +func (NopViewer) ViewEntityMovement(Entity, mgl64.Vec3, cube.Rotation, bool) {} +func (NopViewer) ViewEntityVelocity(Entity, mgl64.Vec3) {} +func (NopViewer) ViewEntityTeleport(Entity, mgl64.Vec3) {} +func (NopViewer) ViewChunk(ChunkPos, Dimension, map[cube.Pos]Block, *chunk.Chunk) {} +func (NopViewer) ViewTime(int) {} +func (NopViewer) ViewTimeCycle(bool) {} +func (NopViewer) ViewEntityItems(Entity) {} +func (NopViewer) ViewEntityArmour(Entity) {} +func (NopViewer) ViewEntityAction(Entity, EntityAction) {} +func (NopViewer) ViewEntityState(Entity) {} +func (NopViewer) ViewEntityAnimation(Entity, EntityAnimation) {} +func (NopViewer) ViewParticle(mgl64.Vec3, Particle) {} +func (NopViewer) ViewSound(mgl64.Vec3, Sound) {} +func (NopViewer) ViewBlockUpdate(cube.Pos, Block, int) {} +func (NopViewer) ViewBlockAction(cube.Pos, BlockAction) {} +func (NopViewer) ViewEmote(Entity, uuid.UUID) {} +func (NopViewer) ViewSkin(Entity) {} +func (NopViewer) ViewWorldSpawn(cube.Pos) {} +func (NopViewer) ViewWeather(bool, bool) {} +func (NopViewer) ViewBrewingUpdate(time.Duration, time.Duration, int32, int32, int32, int32) {} +func (NopViewer) ViewEntityWake(Entity) {} +func (NopViewer) ViewFurnaceUpdate(time.Duration, time.Duration, time.Duration, time.Duration, time.Duration, time.Duration) { +} diff --git a/server/world/visibility_level.go b/server/world/visibility_level.go new file mode 100644 index 0000000..fdfcdff --- /dev/null +++ b/server/world/visibility_level.go @@ -0,0 +1,30 @@ +package world + +// VisibilityLevel controls whether a ViewLayer should use an entity's public visibility or force it +// visible or invisible for a viewer. +type VisibilityLevel struct { + visibility +} + +// PublicVisibility is the default visibility level where the entity is (in)visible +// depending on how it already is publicly viewed. +func PublicVisibility() VisibilityLevel { + return VisibilityLevel{0} +} + +// EnforceInvisible is the visibility level where the entity is always invisible to the viewer. +func EnforceInvisible() VisibilityLevel { + return VisibilityLevel{1} +} + +// EnforceVisible is the visibility level where the entity is always visible to the viewer. +func EnforceVisible() VisibilityLevel { + return VisibilityLevel{2} +} + +type visibility uint8 + +// EnforceVisibility returns whether metadata should be changed. +func (v visibility) EnforceVisibility() bool { + return v > 0 +} diff --git a/server/world/weather.go b/server/world/weather.go new file mode 100644 index 0000000..87a14d3 --- /dev/null +++ b/server/world/weather.go @@ -0,0 +1,261 @@ +package world + +import ( + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/go-gl/mathgl/mgl64" +) + +// weather implements weather related methods for World. World embeds this +// struct, so any exported methods on weather are exported methods on World. +type weather struct{ w *World } + +// StopWeatherCycle disables weather cycle of the World. +func (w weather) StopWeatherCycle() { + w.enableWeatherCycle(false) +} + +// StartWeatherCycle enables weather cycle of the World. +func (w weather) StartWeatherCycle() { + w.enableWeatherCycle(true) +} + +// snowingAt checks if it is snowing at a specific cube.Pos in the World. True +// is returned if the temperature in the Biome at that position is sufficiently +// low, if it is raining and if it's above the top-most obstructing block. +func (w weather) snowingAt(pos cube.Pos) bool { + if w.w == nil || !w.w.Dimension().WeatherCycle() { + return false + } + if b := w.w.biome(pos); b.Rainfall() == 0 || w.w.temperature(pos) > 0.15 { + return false + } + w.w.set.Lock() + raining := w.w.set.Raining + w.w.set.Unlock() + return raining && w.w.highestObstructingBlock(pos[0], pos[2]) < pos[1] +} + +// rainingAt checks if it is raining at a specific cube.Pos in the World. True +// is returned if it is raining, if the temperature is high enough in the biome +// for it not to be snow and if the block is above the top-most obstructing +// block. +func (w weather) rainingAt(pos cube.Pos) bool { + if w.w == nil || !w.w.Dimension().WeatherCycle() { + return false + } + if b := w.w.biome(pos); b.Rainfall() == 0 || w.w.temperature(pos) <= 0.15 { + return false + } + w.w.set.Lock() + a := w.w.set.Raining + w.w.set.Unlock() + return a && w.w.highestObstructingBlock(pos[0], pos[2]) < pos[1] +} + +// thunderingAt checks if it is thundering at a specific cube.Pos in the World. +// True is returned if rainingAt returns true and if it is thundering in the +// world. +func (w weather) thunderingAt(pos cube.Pos) bool { + raining := w.rainingAt(pos) + w.w.set.Lock() + a := w.w.set.Thundering && raining + w.w.set.Unlock() + return a && w.w.highestObstructingBlock(pos[0], pos[2]) < pos[1] +} + +// raining checks if it is raining anywhere in the World. +func (w weather) raining() bool { + if w.w == nil || !w.w.Dimension().WeatherCycle() { + return false + } + w.w.set.Lock() + defer w.w.set.Unlock() + return w.w.set.Raining +} + +// thundering checks if it is thundering anywhere in the World. +func (w weather) thundering() bool { + if w.w == nil || !w.w.Dimension().WeatherCycle() { + return false + } + w.w.set.Lock() + defer w.w.set.Unlock() + return w.w.set.Thundering +} + +// StartRaining makes it rain in the World. The time.Duration passed will +// determine how long it will rain. +func (w weather) StartRaining(dur time.Duration) { + w.w.set.Lock() + defer w.w.set.Unlock() + w.setRaining(true, dur) +} + +// StopRaining makes it stop raining in the World. +func (w weather) StopRaining() { + w.w.set.Lock() + defer w.w.set.Unlock() + + if w.w.set.Raining { + w.setRaining(false, time.Second*(time.Duration(w.w.r.IntN(8400)+600))) + if w.w.set.Thundering { + // Also reset thunder if it was previously thundering. + w.setThunder(false, time.Second*(time.Duration(w.w.r.IntN(8400)+600))) + } + } +} + +// StartThundering makes it thunder in the World. The time.Duration passed will +// determine how long it will thunder. StartThundering will also make it rain +// if it wasn't already raining. In this case the rain will, like the thunder, +// last for the time.Duration passed. +func (w weather) StartThundering(dur time.Duration) { + w.w.set.Lock() + defer w.w.set.Unlock() + + w.setThunder(true, dur) + w.setRaining(true, dur) +} + +// StopThundering makes it stop thundering in the current world. +func (w weather) StopThundering() { + w.w.set.Lock() + defer w.w.set.Unlock() + if w.w.set.Thundering && w.w.set.Raining { + w.setThunder(false, time.Second*(time.Duration(w.w.r.IntN(8400)+600))) + } +} + +// advanceWeather advances the weather counters of the World. Rain and thunder +// are stopped/started when the rain and thunder times reach 0. +func (w weather) advanceWeather() { + w.w.set.RainTime-- + w.w.set.ThunderTime-- + + if w.w.set.RainTime <= 0 { + // Wiki: The rain counter counts down to zero, and each time it reaches + // zero, the rain is toggled on or off. When the rain is turned on, the + // counter is reset to a value between 12,000-23,999 ticks (0.5-1 game + // days) and when the rain is turned off it is reset to a value of + // 12,000-179,999 ticks (0.5-7.5 game days). + if w.w.set.Raining { + w.w.setRaining(false, time.Second*(time.Duration(w.w.r.IntN(8400)+600))) + } else { + w.w.setRaining(true, time.Second*time.Duration(w.w.r.IntN(600)+600)) + } + } + if w.w.set.ThunderTime <= 0 { + // Wiki: the thunder counter toggles thunder on/off when it reaches + // zero, but clear weather overrides the "on" state. When thunder is + // turned on, the thunder counter is reset to 3,600-15,999 ticks (3-13 + // minutes), and when thunder is turned off the counter rests to + // 12,000-179,999 ticks (0.5-7.5 days). + if w.w.set.Thundering { + w.w.setThunder(false, time.Second*(time.Duration(w.w.r.IntN(8400)+600))) + } else { + w.w.setThunder(true, time.Second*time.Duration(w.w.r.IntN(620)+180)) + } + } +} + +// setRaining toggles raining depending on the raining argument. This does not +// lock the world mutex as opposed to StartRaining and StopRaining. +func (w weather) setRaining(raining bool, x time.Duration) { + w.w.set.Raining = raining + w.w.set.RainTime = int64(x.Seconds() * 20) +} + +// setThunder toggles thundering depending on the thundering argument. This +// does not lock the world mutex as opposed to StartThundering and +// StopThundering. +func (w weather) setThunder(thundering bool, x time.Duration) { + w.w.set.Thundering = thundering + w.w.set.ThunderTime = int64(x.Seconds() * 20) +} + +// enableWeatherCycle either enables or disables the weather cycle of the World. +func (w weather) enableWeatherCycle(v bool) { + if w.w == nil { + return + } + w.w.set.Lock() + defer w.w.set.Unlock() + w.w.set.WeatherCycle = v +} + +// tickLightning iterates over all loaded chunks in the World, striking +// lightning in each one with a 1/100,000 chance. +func (w weather) tickLightning(tx *Tx) { + positions := make([]ChunkPos, 0, len(w.w.chunks)/100000) + for pos := range w.w.chunks { + // Wiki: For each loaded chunk, every tick there is a 1⁄100,000 chance + // of an attempted lightning strike during a thunderstorm + if w.w.r.IntN(100000) == 0 { + positions = append(positions, pos) + } + } + + for _, pos := range positions { + w.w.strikeLightning(tx, pos) + } +} + +// strikeLightning attempts to strike lightning in the world at a specific +// ChunkPos. The final position is influenced by living entities that might be +// near the lightning strike. If there is no rain at the final position +// selected, the lightning strike will fail. +func (w weather) strikeLightning(tx *Tx, c ChunkPos) { + if w.w.conf.Entities.conf.Lightning == nil { + return + } + if pos := w.lightningPosition(tx, c); tx.ThunderingAt(cube.PosFromVec3(pos)) { + tx.AddEntity(w.w.conf.Entities.conf.Lightning(EntitySpawnOpts{Position: pos})) + } +} + +// lightningPosition finds a random position in the ChunkPos to strike +// lightning and adjusts the position to any of the living entities found in or +// above the position if any are found. +func (w weather) lightningPosition(tx *Tx, c ChunkPos) mgl64.Vec3 { + v := w.w.r.Int32() + x, z := float64(c[0]<<4+(v&0xf)), float64(c[1]<<4+((v>>8)&0xf)) + + vec := w.adjustPositionToEntities(tx, mgl64.Vec3{x, float64(tx.HighestBlock(int(x), int(z)) + 1), z}) + if pos := cube.PosFromVec3(vec); len(tx.Block(pos).Model().BBox(pos, tx)) != 0 { + // If lightning is about to strike inside a block that is not fully + // transparent. In this case, move the lightning up by one block so that + // it strikes above the block. + return vec.Add(mgl64.Vec3{0, 1}) + } + return vec +} + +// adjustPositionToEntities adjusts the mgl64.Vec3 passed to the position of +// any Entity found in the 3x3 column upwards from the mgl64.Vec3. If multiple +// entities are found, the position of one of the entities is selected +// randomly. +func (w weather) adjustPositionToEntities(tx *Tx, vec mgl64.Vec3) mgl64.Vec3 { + max := vec.Add(mgl64.Vec3{0, float64(w.w.Range().Max())}) + + list := make([]mgl64.Vec3, 0, 16) + for e := range tx.EntitiesWithin(cube.Box(vec[0], vec[1], vec[2], max[0], max[1], max[2]).GrowVec3(mgl64.Vec3{3, 3, 3})) { + if h, ok := e.(interface{ Health() float64 }); ok && h.Health() > 0 { + // Any (living) entity that is positioned higher than the highest + // block at its position is eligible to be struck by lightning. We + // first save all entity positions where this is the case. + pos := cube.PosFromVec3(e.Position()) + if tx.HighestBlock(pos[0], pos[1]) < pos[2] { + list = append(list, e.Position()) + } + } + } + // We then select one of the positions of entities higher than the highest + // block and adjust the position of the lightning to it, so that the entity + // is struck directly. + if len(list) > 0 { + vec = list[w.w.r.IntN(len(list))] + } + return vec +} diff --git a/server/world/world.go b/server/world/world.go new file mode 100644 index 0000000..eefb522 --- /dev/null +++ b/server/world/world.go @@ -0,0 +1,1406 @@ +package world + +import ( + "encoding/binary" + "errors" + "fmt" + "iter" + "maps" + "math/rand/v2" + "slices" + "sync" + "sync/atomic" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/event" + "github.com/df-mc/dragonfly/server/internal/sliceutil" + "github.com/df-mc/dragonfly/server/world/chunk" + "github.com/df-mc/dragonfly/server/world/redstone" + "github.com/df-mc/goleveldb/leveldb" + "github.com/go-gl/mathgl/mgl64" + "github.com/google/uuid" +) + +// World implements a Minecraft world. It manages all aspects of what players +// can see, such as blocks, entities and particles. World generally provides a +// synchronised state: All entities, blocks and players usually operate in this +// world, so World ensures that all its methods will always be safe for +// simultaneous calls. A nil *World is safe to use but not functional. +type World struct { + conf Config + ra cube.Range + + queue chan transaction + queueClosing chan struct{} + queueing sync.WaitGroup + + // advance is a bool that specifies if this World should advance the current + // tick, time and weather saved in the Settings struct held by the World. + advance bool + + o sync.Once + + set *Settings + handler atomic.Pointer[Handler] + + weather + + closing chan struct{} + running sync.WaitGroup + + // chunks holds a cache of chunks currently loaded. These chunks are cleared + // from this map after some time of not being used. + chunks map[ChunkPos]*Column + + // entities holds a map of entities currently loaded and the last ChunkPos + // that the Entity was in. These are tracked so that a call to RemoveEntity + // can find the correct Entity. + entities map[*EntityHandle]ChunkPos + + r *rand.Rand + + // scheduledUpdates is a map of tick time values indexed by the block + // position at which an update is scheduled. If the current tick exceeds the + // tick value passed, the block update will be performed and the entry will + // be removed from the map. + scheduledUpdates *scheduledTickQueue + neighbourUpdates []neighbourUpdate + + redstone redstone.State + + viewerMu sync.Mutex + viewers map[*Loader]Viewer +} + +// transaction is a type that may be added to the transaction queue of a World. +// Its Run method is called when the transaction is taken out of the queue. +type transaction interface { + Run(w *World) +} + +// New creates a new initialised world. The world may be used right away, but +// it will not be saved or loaded from files until it has been given a +// different provider than the default. (NopProvider) By default, the name of +// the world will be 'World'. +func New() *World { + var conf Config + return conf.New() +} + +// Name returns the display name of the world. Generally, this name is +// displayed at the top of the player list in the pause screen in-game. If a +// provider is set, the name will be updated according to the name that it +// provides. +func (w *World) Name() string { + w.set.Lock() + defer w.set.Unlock() + return w.set.Name +} + +// Dimension returns the Dimension assigned to the World in world.New. The sky +// colour and behaviour of a variety of world features differ based on the +// Dimension. +func (w *World) Dimension() Dimension { + return w.conf.Dim +} + +// Range returns the range in blocks of the World (min and max). It is +// equivalent to calling World.Dimension().Range(). +func (w *World) Range() cube.Range { + return w.ra +} + +// BlockRegistry returns the BlockRegistry used by the World. +func (w *World) BlockRegistry() BlockRegistry { + return w.conf.Blocks +} + +// ExecFunc is a function that performs a synchronised transaction on a World. +type ExecFunc func(tx *Tx) + +// Exec performs a synchronised transaction f on a World. Exec returns a channel +// that is closed once the transaction is complete. For Worlds created with +// Config.Synchronous set, the transaction is executed on the calling goroutine +// and the channel returned is closed when Exec returns. Awaiting a nested Exec +// from within a transaction deadlocks on non-synchronous Worlds. +func (w *World) Exec(f ExecFunc) <-chan struct{} { + c := make(chan struct{}) + ntx := normalTransaction{c: c, f: f} + if w.conf.Synchronous { + ntx.Run(w) + return c + } + w.queue <- ntx + return c +} + +func (w *World) weakExec(invalid *atomic.Bool, cond *sync.Cond, f ExecFunc) <-chan bool { + c := make(chan bool, 1) + if w.conf.Synchronous { + valid := !invalid.Load() + if valid { + // As in weakTransaction.Run, f must not run under cond.L: it may + // relock it, e.g. through RemoveEntity. + cond.L.Unlock() + tx := &Tx{w: w} + f(tx) + tx.close() + cond.L.Lock() + } + c <- valid + return c + } + w.queue <- weakTransaction{c: c, f: f, invalid: invalid, cond: cond} + return c +} + +// handleTransactions continuously reads transactions from the queue and runs +// them. +func (w *World) handleTransactions() { + for { + select { + case tx := <-w.queue: + tx.Run(w) + case <-w.queueClosing: + w.queueing.Done() + return + } + } +} + +// EntityRegistry returns the EntityRegistry that was passed to the World's +// Config upon construction. +func (w *World) EntityRegistry() EntityRegistry { + return w.conf.Entities +} + +// block reads a block from the position passed. If a chunk is not yet loaded +// at that position, the chunk is loaded, or generated if it could not be found +// in the world save, and the block returned. +func (w *World) block(pos cube.Pos) Block { + return w.blockInChunk(w.chunk(chunkPosFromBlockPos(pos)), pos) +} + +// blockInChunk reads a block from a chunk at the position passed. The block +// is assumed to be within the chunk passed. +func (w *World) blockInChunk(c *Column, pos cube.Pos) Block { + if pos.OutOfBounds(w.ra) { + // Fast way out. + return w.conf.Blocks.Air() + } + rid := c.Block(uint8(pos[0]), int16(pos[1]), uint8(pos[2]), 0) + if w.conf.Blocks.NBTBlock(rid) { + // The block was also a block entity, so we look it up in the block entity map. + if b, ok := c.BlockEntities[pos]; ok { + return b + } + // Despite being a block with NBT, the block didn't actually have any + // stored NBT yet. We add it here and update the block. + nbtB := w.conf.Blocks.BlockByRuntimeIDOrAir(rid).(NBTer).DecodeNBT(map[string]any{}).(Block) + c.BlockEntities[pos] = nbtB + for _, v := range c.viewers { + v.ViewBlockUpdate(pos, nbtB, 0) + } + return nbtB + } + return w.conf.Blocks.BlockByRuntimeIDOrAir(rid) +} + +// biome reads the Biome at the position passed. If a chunk is not yet loaded +// at that position, the chunk is loaded, or generated if it could not be found +// in the world save, and the Biome returned. +func (w *World) biome(pos cube.Pos) Biome { + if pos.OutOfBounds(w.Range()) { + // Fast way out. + return ocean() + } + id := int(w.chunk(chunkPosFromBlockPos(pos)).Biome(uint8(pos[0]), int16(pos[1]), uint8(pos[2]))) + b, ok := BiomeByID(id) + if !ok { + w.conf.Log.Error("biome not found by ID", "ID", id) + } + return b +} + +// HighestLightBlocker gets the Y value of the highest fully light blocking +// block at the x and z values passed in the World. +func (w *World) HighestLightBlocker(x, z int) int { + return int(w.chunk(ChunkPos{int32(x >> 4), int32(z >> 4)}).HighestLightBlocker(uint8(x), uint8(z))) +} + +// highestBlock looks up the highest non-air block in the World at a specific x +// and z The y value of the highest block is returned, or 0 if no blocks were +// present in the column. +func (w *World) highestBlock(x, z int) int { + return int(w.chunk(ChunkPos{int32(x >> 4), int32(z >> 4)}).HighestBlock(uint8(x), uint8(z))) +} + +// highestObstructingBlock returns the highest block in the World at a given x +// and z that has at least a solid top or bottom face. +func (w *World) highestObstructingBlock(x, z int) int { + yHigh := w.highestBlock(x, z) + src := worldSource{w: w} + for y := yHigh; y >= w.Range()[0]; y-- { + pos := cube.Pos{x, y, z} + m := w.block(pos).Model() + if m.FaceSolid(pos, cube.FaceUp, src) || m.FaceSolid(pos, cube.FaceDown, src) { + return y + } + } + return w.Range()[0] +} + +// SetOpts holds several parameters that may be set to disable updates in the +// World of different kinds as a result of a call to SetBlock. +type SetOpts struct { + // DisableBlockUpdates makes SetBlock not update any neighbouring blocks as + // a result of the SetBlock call. + DisableBlockUpdates bool + // DisableLiquidDisplacement disables the displacement of liquid blocks to + // the second layer (or back to the first layer, if it already was on the + // second layer). Disabling this is not widely recommended unless + // performance is very important, or where it is known no liquid can be + // present anyway. + DisableLiquidDisplacement bool +} + +// setBlock writes a block to the position passed. If a chunk is not yet loaded +// at that position, the chunk is first loaded or generated if it could not be +// found in the world save. setBlock panics if the block passed has not yet +// been registered using RegisterBlock(). Nil may be passed as the block to set +// the block to air. +// +// A SetOpts struct may be passed to additionally modify behaviour of setBlock, +// specifically to improve performance under specific circumstances. Nil should +// be passed where performance is not essential, to make sure the world is +// updated adequately. +// +// setBlock should be avoided in situations where performance is critical when +// needing to set a lot of blocks to the world. BuildStructure may be used +// instead. +func (w *World) setBlock(pos cube.Pos, b Block, opts *SetOpts) { + if pos.OutOfBounds(w.Range()) { + // Fast way out. + return + } + if opts == nil { + opts = &SetOpts{} + } + + x, y, z := uint8(pos[0]), int16(pos[1]), uint8(pos[2]) + c := w.chunk(chunkPosFromBlockPos(pos)) + + rid := w.conf.Blocks.BlockRuntimeID(b) + + var before uint32 + if rid != w.conf.Blocks.AirRuntimeID() && !opts.DisableLiquidDisplacement { + before = c.Block(x, y, z, 0) + } + + c.modified = true + c.SetBlock(x, y, z, 0, rid) + if w.conf.Blocks.NBTBlock(rid) { + c.BlockEntities[pos] = b + } else { + delete(c.BlockEntities, pos) + } + + viewers := slices.Clone(c.viewers) + + if !opts.DisableLiquidDisplacement { + var secondLayer Block + + airRID := w.conf.Blocks.AirRuntimeID() + if rid == airRID { + if li := c.Block(x, y, z, 1); li != airRID { + c.SetBlock(x, y, z, 0, li) + c.SetBlock(x, y, z, 1, airRID) + secondLayer = w.conf.Blocks.Air() + b = w.conf.Blocks.BlockByRuntimeIDOrAir(li) + } + } else if w.conf.Blocks.LiquidDisplacingBlock(rid) { + if w.conf.Blocks.LiquidBlock(before) { + l := w.conf.Blocks.BlockByRuntimeIDOrAir(before) + if b.(LiquidDisplacer).CanDisplace(l.(Liquid)) { + c.SetBlock(x, y, z, 1, before) + secondLayer = l + } + } + } else if li := c.Block(x, y, z, 1); li != airRID { + c.SetBlock(x, y, z, 1, airRID) + secondLayer = w.conf.Blocks.Air() + } + + if secondLayer != nil { + for _, viewer := range viewers { + viewer.ViewBlockUpdate(pos, secondLayer, 1) + } + } + } + + for _, viewer := range viewers { + viewer.ViewBlockUpdate(pos, b, 0) + } + + if !opts.DisableBlockUpdates { + w.doBlockUpdatesAround(pos) + } +} + +// setBiome sets the Biome at the position passed. If a chunk is not yet loaded +// at that position, the chunk is first loaded or generated if it could not be +// found in the world save. +func (w *World) setBiome(pos cube.Pos, b Biome) { + if pos.OutOfBounds(w.Range()) { + // Fast way out. + return + } + c := w.chunk(chunkPosFromBlockPos(pos)) + c.modified = true + c.SetBiome(uint8(pos[0]), int16(pos[1]), uint8(pos[2]), uint32(b.EncodeBiome())) +} + +// buildStructure builds a Structure passed at a specific position in the +// world. Unlike setBlock, it takes a Structure implementation, which provides +// blocks to be placed at a specific location. buildStructure is specifically +// optimised to be able to process a large batch of chunks simultaneously and +// will do so within much less time than separate setBlock calls would. The +// method operates on a per-chunk basis, setting all blocks within a single +// chunk part of the Structure before moving on to the next chunk. +func (w *World) buildStructure(pos cube.Pos, s Structure) { + dim := s.Dimensions() + width, height, length := dim[0], dim[1], dim[2] + maxX, maxY, maxZ := pos[0]+width, pos[1]+height, pos[2]+length + f := func(x, y, z int) Block { + return w.block(cube.Pos{pos[0] + x, pos[1] + y, pos[2] + z}) + } + + // We approach this on a per-chunk basis, so that we can keep only one chunk + // in memory at a time while not needing to acquire a new chunk lock for + // every block. This also allows us not to send block updates, but instead + // send a single chunk update once. + for chunkX := pos[0] >> 4; chunkX <= maxX>>4; chunkX++ { + for chunkZ := pos[2] >> 4; chunkZ <= maxZ>>4; chunkZ++ { + chunkPos := ChunkPos{int32(chunkX), int32(chunkZ)} + c := w.chunk(chunkPos) + + baseX, baseZ := chunkX<<4, chunkZ<<4 + for i, sub := range c.Sub() { + baseY := (i + (w.Range()[0] >> 4)) << 4 + if baseY>>4 < pos[1]>>4 { + continue + } else if baseY >= maxY { + break + } + + for localY := 0; localY < 16; localY++ { + yOffset := baseY + localY + if yOffset > w.Range()[1] || yOffset >= maxY { + // We've hit the height limit for blocks. + break + } else if yOffset < w.Range()[0] || yOffset < pos[1] { + // We've got a block below the minimum, but other blocks might still reach above + // it, so don't break but continue. + continue + } + for localX := 0; localX < 16; localX++ { + xOffset := baseX + localX + if xOffset < pos[0] || xOffset >= maxX { + continue + } + for localZ := 0; localZ < 16; localZ++ { + zOffset := baseZ + localZ + if zOffset < pos[2] || zOffset >= maxZ { + continue + } + b, liq := s.At(xOffset-pos[0], yOffset-pos[1], zOffset-pos[2], f) + if b != nil { + rid := w.conf.Blocks.BlockRuntimeID(b) + sub.SetBlock(uint8(xOffset), uint8(yOffset), uint8(zOffset), 0, rid) + + nbtPos := cube.Pos{xOffset, yOffset, zOffset} + if w.conf.Blocks.NBTBlock(rid) { + c.BlockEntities[nbtPos] = b + } else { + delete(c.BlockEntities, nbtPos) + } + } + if liq != nil { + sub.SetBlock(uint8(xOffset), uint8(yOffset), uint8(zOffset), 1, w.conf.Blocks.BlockRuntimeID(liq)) + } else if len(sub.Layers()) > 1 { + sub.SetBlock(uint8(xOffset), uint8(yOffset), uint8(zOffset), 1, w.conf.Blocks.AirRuntimeID()) + } + } + } + } + } + c.SetBlock(0, 0, 0, 0, c.Block(0, 0, 0, 0)) // Make sure the heightmap is recalculated. + c.modified = true + + // After setting all blocks of the structure within a single chunk, + // we show the new chunk to all viewers once. + for _, viewer := range c.viewers { + viewer.ViewChunk(chunkPos, w.Dimension(), c.BlockEntities, c.Chunk) + } + } + } +} + +// liquid attempts to return a Liquid block at the position passed. This +// Liquid may be in the foreground or in any other layer. If found, the Liquid +// is returned. If not, the bool returned is false. +func (w *World) liquid(pos cube.Pos) (Liquid, bool) { + if pos.OutOfBounds(w.Range()) { + // Fast way out. + return nil, false + } + c := w.chunk(chunkPosFromBlockPos(pos)) + x, y, z := uint8(pos[0]), int16(pos[1]), uint8(pos[2]) + + id := c.Block(x, y, z, 0) + b, ok := w.conf.Blocks.BlockByRuntimeID(id) + if !ok { + w.conf.Log.Error("Liquid: no block with runtime ID", "ID", id) + return nil, false + } + if liq, ok := b.(Liquid); ok { + return liq, true + } + id = c.Block(x, y, z, 1) + + b, ok = w.conf.Blocks.BlockByRuntimeID(id) + if !ok { + w.conf.Log.Error("Liquid: no block with runtime ID", "ID", id) + return nil, false + } + liq, ok := b.(Liquid) + return liq, ok +} + +// setLiquid sets a Liquid at a specific position in the World. Unlike +// setBlock, setLiquid will not necessarily overwrite any existing blocks. It +// will instead be in the same position as a block currently there, unless +// there already is a Liquid at that position, in which case it will be +// overwritten. If nil is passed for the Liquid, any Liquid currently present +// will be removed. +func (w *World) setLiquid(pos cube.Pos, b Liquid) { + if pos.OutOfBounds(w.Range()) { + // Fast way out. + return + } + chunkPos := chunkPosFromBlockPos(pos) + c := w.chunk(chunkPos) + if b == nil { + w.removeLiquids(c, pos) + w.doBlockUpdatesAround(pos) + return + } + x, y, z := uint8(pos[0]), int16(pos[1]), uint8(pos[2]) + if !replaceable(w, c, pos, b) { + if displacer, ok := w.blockInChunk(c, pos).(LiquidDisplacer); !ok || !displacer.CanDisplace(b) { + return + } + } + rid := w.conf.Blocks.BlockRuntimeID(b) + if w.removeLiquids(c, pos) { + c.SetBlock(x, y, z, 0, rid) + for _, v := range c.viewers { + v.ViewBlockUpdate(pos, b, 0) + } + } else { + c.SetBlock(x, y, z, 1, rid) + for _, v := range c.viewers { + v.ViewBlockUpdate(pos, b, 1) + } + } + c.modified = true + + w.doBlockUpdatesAround(pos) +} + +// removeLiquids removes any liquid blocks that may be present at a specific +// block position in the chunk passed. The bool returned specifies if no blocks +// were left on the foreground layer. +func (w *World) removeLiquids(c *Column, pos cube.Pos) bool { + x, y, z := uint8(pos[0]), int16(pos[1]), uint8(pos[2]) + air := w.conf.Blocks.Air() + + noneLeft := false + if noLeft, changed := w.removeLiquidOnLayer(c.Chunk, x, y, z, 0); noLeft { + if changed { + for _, v := range c.viewers { + v.ViewBlockUpdate(pos, air, 0) + } + } + noneLeft = true + } + if _, changed := w.removeLiquidOnLayer(c.Chunk, x, y, z, 1); changed { + for _, v := range c.viewers { + v.ViewBlockUpdate(pos, air, 1) + } + } + return noneLeft +} + +// removeLiquidOnLayer removes a liquid block from a specific layer in the +// chunk passed, returning true if successful. +func (w *World) removeLiquidOnLayer(c *chunk.Chunk, x uint8, y int16, z, layer uint8) (bool, bool) { + id := c.Block(x, y, z, layer) + airRID := w.conf.Blocks.AirRuntimeID() + + b, ok := w.conf.Blocks.BlockByRuntimeID(id) + if !ok { + w.conf.Log.Error("removeLiquidOnLayer: no block with runtime ID", "ID", id) + return false, false + } + if _, ok := b.(Liquid); ok { + c.SetBlock(x, y, z, layer, airRID) + return true, true + } + return id == airRID, false +} + +// additionalLiquid checks if the block at a position has additional liquid on +// another layer and returns the liquid if so. +func (w *World) additionalLiquid(pos cube.Pos) (Liquid, bool) { + if pos.OutOfBounds(w.Range()) { + // Fast way out. + return nil, false + } + c := w.chunk(chunkPosFromBlockPos(pos)) + id := c.Block(uint8(pos[0]), int16(pos[1]), uint8(pos[2]), 1) + + b, ok := w.conf.Blocks.BlockByRuntimeID(id) + if !ok { + w.conf.Log.Error("additionalLiquid: no block with runtime ID", "ID", id) + return nil, false + } + liq, ok := b.(Liquid) + return liq, ok +} + +// light returns the light level at the position passed. This is the highest of +// the sky and block light. The light value returned is a value in the range +// 0-15, where 0 means there is no light present, whereas 15 means the block is +// fully lit. +func (w *World) light(pos cube.Pos) uint8 { + if pos[1] < w.ra[0] { + // Fast way out. + return 0 + } + if pos[1] > w.ra[1] { + // Above the rest of the world, so full skylight. + return 15 + } + return w.chunk(chunkPosFromBlockPos(pos)).Light(uint8(pos[0]), int16(pos[1]), uint8(pos[2])) +} + +// skyLight returns the skylight level at the position passed. This light level +// is not influenced by blocks that emit light, such as torches. The light +// value, similarly to light, is a value in the range 0-15, where 0 means no +// light is present. +func (w *World) skyLight(pos cube.Pos) uint8 { + if pos[1] < w.ra[0] { + // Fast way out. + return 0 + } + if pos[1] > w.ra[1] { + // Above the rest of the world, so full skylight. + return 15 + } + return w.chunk(chunkPosFromBlockPos(pos)).SkyLight(uint8(pos[0]), int16(pos[1]), uint8(pos[2])) +} + +// Time returns the current time of the world. The time is incremented every +// 1/20th of a second, unless World.StopTime() is called. +func (w *World) Time() int { + if w == nil { + return 0 + } + w.set.Lock() + defer w.set.Unlock() + return int(w.set.Time) +} + +// SetTime sets the new time of the world. SetTime will always work, regardless +// of whether the time is stopped or not. +func (w *World) SetTime(new int) { + if w == nil { + return + } + w.set.Lock() + w.set.Time = int64(new) + w.set.Unlock() + + viewers, _ := w.allViewers() + for _, viewer := range viewers { + viewer.ViewTime(new) + } +} + +// StopTime stops the time in the world. When called, the time will no longer +// cycle and the world will remain at the time when StopTime is called. The +// time may be restarted by calling World.StartTime(). +func (w *World) StopTime() { + w.enableTimeCycle(false) +} + +// StartTime restarts the time in the world. When called, the time will start +// cycling again and the day/night cycle will continue. The time may be stopped +// again by calling World.StopTime(). +func (w *World) StartTime() { + w.enableTimeCycle(true) +} + +// TimeCycle returns whether time cycle is enabled. +func (w *World) TimeCycle() bool { + if w == nil { + return false + } + w.set.Lock() + defer w.set.Unlock() + return w.set.TimeCycle +} + +// enableTimeCycle enables or disables the time cycling of the World. +func (w *World) enableTimeCycle(v bool) { + if w == nil { + return + } + w.set.Lock() + defer w.set.Unlock() + w.set.TimeCycle = v + viewers, _ := w.allViewers() + for _, viewer := range viewers { + viewer.ViewTimeCycle(v) + } +} + +// temperature returns the temperature in the World at a specific position. +// Higher altitudes and different biomes influence the temperature returned. +func (w *World) temperature(pos cube.Pos) float64 { + const ( + tempDrop = 1.0 / 600 + seaLevel = 64 + ) + diff := max(pos[1]-seaLevel, 0) + return w.biome(pos).Temperature() - float64(diff)*tempDrop +} + +// addParticle spawns a Particle at a given position in the World. Viewers that +// are viewing the chunk will be shown the particle. +func (w *World) addParticle(pos mgl64.Vec3, p Particle) { + p.Spawn(w, pos) + for _, viewer := range w.viewersOf(pos) { + viewer.ViewParticle(pos, p) + } +} + +// playSound plays a sound at a specific position in the World. Viewers of that +// position will be able to hear the sound if they are close enough. +func (w *World) playSound(tx *Tx, pos mgl64.Vec3, s Sound) { + ctx := event.C(tx) + if w.Handler().HandleSound(ctx, s, pos); ctx.Cancelled() { + return + } + s.Play(w, pos) + for _, viewer := range w.viewersOf(pos) { + viewer.ViewSound(pos, s) + } +} + +// addEntity adds an EntityHandle to a World. The Entity will be visible to all +// viewers of the World that have the chunk at the EntityHandle's position. If +// the chunk that the EntityHandle is in is not yet loaded, it will first be +// 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 + + c := w.chunk(pos) + c.Entities, c.modified = append(c.Entities, handle), true + + e := handle.mustEntity(tx) + for _, v := range c.viewers { + // Show the entity to all viewers in the chunk of the entity. + showEntity(e, v) + } + w.Handler().HandleEntitySpawn(tx, e) + return e +} + +// 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 +// from the World, the Entity is no longer usable. +func (w *World) removeEntity(e Entity, tx *Tx) *EntityHandle { + handle := e.H() + pos, found := w.entities[handle] + if !found { + // The entity currently isn't in this world. + return nil + } + w.Handler().HandleEntityDespawn(tx, e) + + c := w.chunk(pos) + c.Entities, c.modified = sliceutil.DeleteVal(c.Entities, handle), true + + w.removeEntityFromViewLayers(e) + for _, v := range c.viewers { + v.HideEntity(e) + } + delete(w.entities, handle) + handle.unsetAndLockWorld() + return handle +} + +// removeEntityFromViewLayers removes stale overrides for despawned entities. Entities that own a ViewLayer, +// such as players, are skipped because they may be removed temporarily when respawning or changing worlds. +func (w *World) removeEntityFromViewLayers(e Entity) { + if _, ok := e.(viewLayerViewer); ok { + return + } + viewers, _ := w.allViewers() + for _, viewer := range viewers { + v, ok := viewer.(viewLayerViewer) + if !ok || v.ViewLayer() == nil { + continue + } + v.ViewLayer().remove(e) + } +} + +// entitiesWithin returns an iterator that yields all entities contained within +// the cube.BBox passed. +func (w *World) entitiesWithin(tx *Tx, box cube.BBox) iter.Seq[Entity] { + return func(yield func(Entity) bool) { + minPos, maxPos := chunkPosFromVec3(box.Min()), chunkPosFromVec3(box.Max()) + + for x := minPos[0]; x <= maxPos[0]; x++ { + for z := minPos[1]; z <= maxPos[1]; z++ { + c, ok := w.chunks[ChunkPos{x, z}] + if !ok { + // The chunk wasn't loaded, so there are no entities here. + continue + } + for _, handle := range slices.Clone(c.Entities) { + if !box.Vec3Within(handle.data.Pos) { + continue + } + ent, ok := handle.Entity(tx) + if ok && !yield(ent) { + return + } + } + } + } + } +} + +// allEntities returns an iterator that yields all entities in the World. +func (w *World) allEntities(tx *Tx) iter.Seq[Entity] { + return func(yield func(Entity) bool) { + for e := range w.entities { + if ent := e.mustEntity(tx); !yield(ent) { + return + } + } + } +} + +// allPlayers returns an iterator that yields all player entities in the World. +func (w *World) allPlayers(tx *Tx) iter.Seq[Entity] { + return func(yield func(Entity) bool) { + for e := range w.entities { + if e.t.EncodeEntity() == "minecraft:player" { + if ent := e.mustEntity(tx); !yield(ent) { + return + } + } + } + } +} + +// Spawn returns the spawn of the world. Every new player will by default spawn +// on this position in the world when joining. +func (w *World) Spawn() cube.Pos { + if w == nil { + return cube.Pos{} + } + + if w.Dimension() == End { + return cube.Pos{100, 50} + } else if w.Dimension() == Nether { + return cube.Pos{} + } + + w.set.Lock() + defer w.set.Unlock() + return w.set.Spawn +} + +// SetSpawn sets the spawn of the world to a different position. The player +// will be spawned in the centre of this position when newly joining. +func (w *World) SetSpawn(pos cube.Pos) { + if w == nil { + return + } + + // nether has no spawn point and end spawn point is always 100 50 0. + if w.Dimension() == Nether || w.Dimension() == End { + return + } + + w.set.Lock() + w.set.Spawn = pos + w.set.Unlock() + + viewers, _ := w.allViewers() + for _, viewer := range viewers { + viewer.ViewWorldSpawn(pos) + } +} + +// PlayerSpawn returns the spawn position of a player with a UUID in this World. +func (w *World) PlayerSpawn(id uuid.UUID) cube.Pos { + if w == nil { + return cube.Pos{} + } + pos, exist, err := w.conf.Provider.LoadPlayerSpawnPosition(id) + if err != nil { + w.conf.Log.Error("load player spawn: "+err.Error(), "ID", id) + return w.Spawn() + } + if !exist { + return w.Spawn() + } + return pos +} + +// SetPlayerSpawn sets the spawn position of a player with a UUID in this +// World. If the player has a spawn in the world, the player will be teleported +// to this location on respawn. +func (w *World) SetPlayerSpawn(id uuid.UUID, pos cube.Pos) { + if w == nil { + return + } + if err := w.conf.Provider.SavePlayerSpawnPosition(id, pos); err != nil { + w.conf.Log.Error("save player spawn: "+err.Error(), "ID", id) + } +} + +// SetRequiredSleepDuration sets the duration of time players in the world must sleep for, in order to advance to the +// next day. +func (w *World) SetRequiredSleepDuration(duration time.Duration) { + if w == nil { + return + } + w.set.Lock() + defer w.set.Unlock() + w.set.RequiredSleepTicks = duration.Milliseconds() / 50 +} + +// DefaultGameMode returns the default game mode of the world. When players +// join, they are given this game mode. The default game mode may be changed +// using SetDefaultGameMode(). +func (w *World) DefaultGameMode() GameMode { + if w == nil { + return GameModeSurvival + } + w.set.Lock() + defer w.set.Unlock() + return w.set.DefaultGameMode +} + +// SetTickRange sets the range in chunks around each Viewer that will have the +// chunks (their blocks and entities) ticked when the World is ticked. +func (w *World) SetTickRange(v int) { + if w == nil { + return + } + w.set.Lock() + defer w.set.Unlock() + w.set.TickRange = int32(v) +} + +// tickRange returns the tick range around each Viewer. +func (w *World) tickRange() int { + w.set.Lock() + defer w.set.Unlock() + return int(w.set.TickRange) +} + +// SetDefaultGameMode changes the default game mode of the world. When players +// join, they are then given that game mode. +func (w *World) SetDefaultGameMode(mode GameMode) { + if w == nil { + return + } + w.set.Lock() + defer w.set.Unlock() + w.set.DefaultGameMode = mode +} + +// Difficulty returns the difficulty of the world. Properties of mobs in the +// world and the player's hunger will depend on this difficulty. +func (w *World) Difficulty() Difficulty { + if w == nil { + return DifficultyNormal + } + w.set.Lock() + defer w.set.Unlock() + return w.set.Difficulty +} + +// SetDifficulty changes the difficulty of a world. +func (w *World) SetDifficulty(d Difficulty) { + if w == nil { + return + } + w.set.Lock() + defer w.set.Unlock() + w.set.Difficulty = d +} + +// scheduleBlockUpdate schedules a block update at the position passed for the +// block type passed after a specific delay. If the block at that position does +// not handle block updates, nothing will happen. +// Block updates are both block and position specific. A block update is only +// scheduled if no block update with the same position and block type is +// already scheduled at a later time than the newly scheduled update. +func (w *World) scheduleBlockUpdate(pos cube.Pos, b Block, delay time.Duration) { + if pos.OutOfBounds(w.Range()) { + return + } + w.scheduledUpdates.schedule(w.conf.Blocks, pos, b, delay) +} + +// doBlockUpdatesAround schedules block updates directly around and on the +// position passed. +func (w *World) doBlockUpdatesAround(pos cube.Pos) { + if w == nil || pos.OutOfBounds(w.Range()) { + return + } + changed := pos + + w.updateNeighbour(pos, changed) + pos.Neighbours(func(pos cube.Pos) { + w.updateNeighbour(pos, changed) + }, w.Range()) +} + +// neighbourUpdate represents a position that needs to be updated because of a +// neighbour that changed. +type neighbourUpdate struct { + pos, neighbour cube.Pos +} + +// updateNeighbour ticks the position passed as a result of the neighbour +// passed being updated. +func (w *World) updateNeighbour(pos, changedNeighbour cube.Pos) { + w.neighbourUpdates = append(w.neighbourUpdates, neighbourUpdate{pos: pos, neighbour: changedNeighbour}) +} + +// Handle changes the current Handler of the world. As a result, events called +// by the world will call the methods of the Handler passed. Handle sets the +// world's Handler to NopHandler if nil is passed. +func (w *World) Handle(h Handler) { + if w == nil { + return + } + if h == nil { + h = NopHandler{} + } + w.handler.Store(&h) +} + +// viewersOf returns all viewers viewing the position passed. +func (w *World) viewersOf(pos mgl64.Vec3) []Viewer { + c, ok := w.chunks[chunkPosFromVec3(pos)] + if !ok { + return nil + } + return c.viewers +} + +// PortalDestination returns the destination World for a portal of a specific +// Dimension. If no destination World could be found, the current World is +// returned. Calling PortalDestination(Nether) on an Overworld World returns +// Nether, while calling PortalDestination(Nether) on a Nether World will +// return the Overworld, for instance. +func (w *World) PortalDestination(dim Dimension) *World { + if w.conf.PortalDestination == nil { + return w + } + if res := w.conf.PortalDestination(dim); res != nil { + return res + } + return w +} + +// Save saves the World to the provider. +func (w *World) Save() { + <-w.Exec(w.save(w.saveChunk)) +} + +// save saves all loaded chunks to the World's provider. +func (w *World) save(f func(*Tx, ChunkPos, *Column)) ExecFunc { + return func(tx *Tx) { + if w.conf.ReadOnly { + return + } + w.conf.Log.Debug("Saving chunks in memory to disk...") + for pos, c := range w.chunks { + f(tx, pos, c) + } + w.conf.Log.Debug("Updating level.dat values...") + w.conf.Provider.SaveSettings(w.set) + } +} + +// saveChunk saves a chunk and its entities to disk after compacting the chunk. +func (w *World) saveChunk(_ *Tx, pos ChunkPos, c *Column) { + if !w.conf.ReadOnly && c.modified { + c.Compact() + if err := w.conf.Provider.StoreColumn(pos, w.conf.Dim, w.columnTo(c, pos)); err != nil { + w.conf.Log.Error("save chunk: "+err.Error(), "X", pos[0], "Z", pos[1]) + } + } +} + +// closeChunk saves a chunk and its entities to disk after compacting the chunk. +// Afterwards, scheduled updates from that chunk are removed and all entities +// in it are closed. +func (w *World) closeChunk(tx *Tx, pos ChunkPos, c *Column) { + w.saveChunk(tx, pos, c) + w.scheduledUpdates.removeChunk(pos) + // Note: We close c.Entities here because some entities may remove + // themselves from the world in their Close method, which can lead to + // unexpected conditions. + for _, e := range slices.Clone(c.Entities) { + _ = e.mustEntity(tx).Close() + } + clear(c.Entities) + delete(w.chunks, pos) +} + +// Close closes the world and saves all chunks currently loaded. +func (w *World) Close() error { + w.o.Do(w.close) + return nil +} + +// close stops the World from ticking, saves all chunks to the Provider and +// updates the world's settings. +func (w *World) close() { + <-w.Exec(func(tx *Tx) { + // Let user code run anything that needs to be finished before closing. + w.Handler().HandleClose(tx) + w.Handle(NopHandler{}) + + w.save(w.closeChunk)(tx) + }) + + close(w.closing) + w.running.Wait() + + close(w.queueClosing) + w.queueing.Wait() + + if w.set.ref.Add(-1); !w.advance { + return + } + w.conf.Log.Debug("Closing provider...") + if err := w.conf.Provider.Close(); err != nil { + w.conf.Log.Error("close world provider: " + err.Error()) + } +} + +// allViewers returns all viewers and loaders, regardless of where in the world +// they are viewing. +func (w *World) allViewers() ([]Viewer, []*Loader) { + w.viewerMu.Lock() + defer w.viewerMu.Unlock() + + viewers, loaders := make([]Viewer, 0, len(w.viewers)), make([]*Loader, 0, len(w.viewers)) + for k, v := range w.viewers { + viewers = append(viewers, v) + loaders = append(loaders, k) + } + return viewers, loaders +} + +// addWorldViewer adds a viewer to the world. Should only be used while the +// viewer isn't viewing any chunks. +func (w *World) addWorldViewer(l *Loader) { + w.viewerMu.Lock() + w.viewers[l] = l.viewer + w.viewerMu.Unlock() + + l.viewer.ViewTime(w.Time()) + l.viewer.ViewTimeCycle(w.TimeCycle()) + w.set.Lock() + raining, thundering := w.set.Raining, w.set.Raining && w.set.Thundering + w.set.Unlock() + l.viewer.ViewWeather(raining, thundering) + l.viewer.ViewWorldSpawn(w.Spawn()) +} + +// addViewer adds a viewer to the World at a given position. Any events that +// happen in the chunk at that position, such as block and entity changes, will +// be sent to the viewer. +func (w *World) addViewer(tx *Tx, c *Column, loader *Loader) { + c.viewers = append(c.viewers, loader.viewer) + c.loaders = append(c.loaders, loader) + + for _, entity := range c.Entities { + showEntity(entity.mustEntity(tx), loader.viewer) + } +} + +// removeViewer removes a viewer from a chunk position. All entities will be +// hidden from the viewer and no more calls will be made when events in the +// chunk happen. +func (w *World) removeViewer(tx *Tx, pos ChunkPos, loader *Loader) { + if w == nil { + return + } + c, ok := w.chunks[pos] + if !ok { + return + } + if i := slices.Index(c.loaders, loader); i != -1 { + c.viewers = slices.Delete(c.viewers, i, i+1) + c.loaders = slices.Delete(c.loaders, i, i+1) + } + + // Hide all entities in the chunk from the viewer. + for _, entity := range c.Entities { + loader.viewer.HideEntity(entity.mustEntity(tx)) + } +} + +// Handler returns the Handler of the world. +func (w *World) Handler() Handler { + if w == nil { + return NopHandler{} + } + return *w.handler.Load() +} + +// showEntity shows an Entity to a viewer of the world. It makes sure +// everything of the Entity, including the items held, is shown. +func showEntity(e Entity, viewer Viewer) { + viewer.ViewEntity(e) + viewer.ViewEntityItems(e) + viewer.ViewEntityArmour(e) +} + +// chunk reads a chunk from the position passed. If a chunk at that position is +// not yet loaded, the chunk is loaded from the provider, or generated if it +// did not yet exist. Additionally, chunks newly loaded have the light in them +// calculated before they are returned. +func (w *World) chunk(pos ChunkPos) *Column { + c, ok := w.chunks[pos] + if ok { + return c + } + c, err := w.loadChunk(pos) + chunk.LightArea([]*chunk.Chunk{c.Chunk}, int(pos[0]), int(pos[1])).Fill() + if err != nil { + w.conf.Log.Error("load chunk: "+err.Error(), "X", pos[0], "Z", pos[1]) + return c + } + w.calculateLight(pos) + return c +} + +// loadChunk attempts to load a chunk from the provider, or generates a chunk +// if one doesn't currently exist. +func (w *World) loadChunk(pos ChunkPos) (*Column, error) { + column, err := w.conf.Provider.LoadColumn(pos, w.conf.Dim) + switch { + case err == nil: + col := w.columnFrom(column, pos) + w.chunks[pos] = col + for _, e := range col.Entities { + w.entities[e] = pos + e.w = w + } + return col, nil + case errors.Is(err, leveldb.ErrNotFound): + // The provider doesn't have a chunk saved at this position, so we generate a new one. + col := newColumn(chunk.New(w.conf.Blocks, w.Range())) + w.chunks[pos] = col + + w.conf.Generator.GenerateChunk(pos, col.Chunk) + return col, nil + default: + return newColumn(chunk.New(w.conf.Blocks, w.Range())), err + } +} + +// calculateLight calculates the light in the chunk passed and spreads the +// light of any surrounding neighbours if they have all chunks loaded around it +// as a result of the one passed. +func (w *World) calculateLight(centre ChunkPos) { + for x := int32(-1); x <= 1; x++ { + for z := int32(-1); z <= 1; z++ { + // For all the neighbours of this chunk, if they exist, check if all + // neighbours of that chunk now exist because of this one. + pos := ChunkPos{centre[0] + x, centre[1] + z} + if _, ok := w.chunks[pos]; ok { + // Attempt to spread the light of all neighbours into the + // surrounding ones. + w.spreadLight(pos) + } + } + } +} + +// spreadLight spreads the light from the chunk passed at the position passed +// to all neighbours if each of them is loaded. +func (w *World) spreadLight(pos ChunkPos) { + c := make([]*chunk.Chunk, 0, 9) + for z := int32(-1); z <= 1; z++ { + for x := int32(-1); x <= 1; x++ { + neighbour, ok := w.chunks[ChunkPos{pos[0] + x, pos[1] + z}] + if !ok { + // Not all surrounding chunks existed: Stop spreading light. + return + } + c = append(c, neighbour.Chunk) + } + } + // All chunks surrounding the current one are present, so we can spread. + chunk.LightArea(c, int(pos[0])-1, int(pos[1])-1).Spread() +} + +// autoSave runs until the world is running, saving and removing chunks that +// are no longer in use. +func (w *World) autoSave() { + save := &time.Ticker{C: make(<-chan time.Time)} + if w.conf.SaveInterval > 0 { + save = time.NewTicker(w.conf.SaveInterval) + defer save.Stop() + } + closeUnused := time.NewTicker(w.conf.ChunkUnloadInterval) + defer closeUnused.Stop() + + for { + select { + case <-closeUnused.C: + <-w.Exec(w.closeUnusedChunks) + case <-save.C: + w.Save() + case <-w.closing: + w.running.Done() + return + } + } +} + +// closeUnusedChunk closes all chunks currently not in use by any viewer. +func (w *World) closeUnusedChunks(tx *Tx) { + for pos, c := range w.chunks { + if len(c.viewers) == 0 { + w.closeChunk(tx, pos, c) + } + } +} + +// Column represents the data of a chunk including the (block) entities and +// viewers and loaders. +type Column struct { + modified bool + + *chunk.Chunk + Entities []*EntityHandle + BlockEntities map[cube.Pos]Block + + viewers []Viewer + loaders []*Loader +} + +// newColumn returns a new Column wrapper around the chunk.Chunk passed. +func newColumn(c *chunk.Chunk) *Column { + return &Column{Chunk: c, BlockEntities: map[cube.Pos]Block{}} +} + +// columnTo converts a Column to a chunk.Column so that it can be written to +// a provider. +func (w *World) columnTo(col *Column, pos ChunkPos) *chunk.Column { + scheduled := w.scheduledUpdates.fromChunk(pos) + c := &chunk.Column{ + Chunk: col.Chunk, + Entities: make([]chunk.Entity, 0, len(col.Entities)), + BlockEntities: make([]chunk.BlockEntity, 0, len(col.BlockEntities)), + ScheduledBlocks: make([]chunk.ScheduledBlockUpdate, 0, len(scheduled)), + Tick: w.scheduledUpdates.currentTick, + } + for _, e := range col.Entities { + data := e.encodeNBT() + maps.Copy(data, e.t.EncodeNBT(&e.data)) + data["identifier"] = e.t.EncodeEntity() + c.Entities = append(c.Entities, chunk.Entity{ID: int64(binary.LittleEndian.Uint64(e.id[8:])), Data: data}) + } + for pos, be := range col.BlockEntities { + c.BlockEntities = append(c.BlockEntities, chunk.BlockEntity{Pos: pos, Data: be.(NBTer).EncodeNBT()}) + } + for _, t := range scheduled { + c.ScheduledBlocks = append(c.ScheduledBlocks, chunk.ScheduledBlockUpdate{Pos: t.pos, Block: w.conf.Blocks.BlockRuntimeID(t.b), Tick: t.t}) + } + return c +} + +// columnFrom converts a chunk.Column to a Column after reading it from a +// provider. +func (w *World) columnFrom(c *chunk.Column, _ ChunkPos) *Column { + col := &Column{ + Chunk: c.Chunk, + Entities: make([]*EntityHandle, 0, len(c.Entities)), + BlockEntities: make(map[cube.Pos]Block, len(c.BlockEntities)), + } + for _, e := range c.Entities { + eid, ok := e.Data["identifier"].(string) + if !ok { + w.conf.Log.Error("read column: entity without identifier field", "ID", e.ID) + continue + } + t, ok := w.conf.Entities.Lookup(eid) + if !ok { + w.conf.Log.Error("read column: unknown entity type", "ID", e.ID, "type", eid) + continue + } + col.Entities = append(col.Entities, entityFromData(t, e.ID, e.Data)) + } + for _, be := range c.BlockEntities { + rid := c.Chunk.Block(uint8(be.Pos[0]), int16(be.Pos[1]), uint8(be.Pos[2]), 0) + b, ok := w.conf.Blocks.BlockByRuntimeID(rid) + if !ok { + w.conf.Log.Error("read column: no block with runtime ID", "ID", rid) + continue + } + nb, ok := b.(NBTer) + if !ok { + w.conf.Log.Error("read column: block with nbt does not implement NBTer", "block", fmt.Sprintf("%#v", b)) + continue + } + col.BlockEntities[be.Pos] = nb.DecodeNBT(be.Data).(Block) + } + scheduled, savedTick := make([]scheduledTick, 0, len(c.ScheduledBlocks)), c.Tick + for _, t := range c.ScheduledBlocks { + bl := w.conf.Blocks.BlockByRuntimeIDOrAir(t.Block) + scheduled = append(scheduled, scheduledTick{ + pos: t.Pos, + b: bl, + bhash: w.conf.Blocks.BlockHash(bl), + t: w.scheduledUpdates.currentTick + (t.Tick - savedTick), + }) + } + w.scheduledUpdates.add(scheduled) + return col +} diff --git a/server/world/world_test.go b/server/world/world_test.go new file mode 100644 index 0000000..6bfbf88 --- /dev/null +++ b/server/world/world_test.go @@ -0,0 +1,193 @@ +package world + +import ( + "testing" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/go-gl/mathgl/mgl64" +) + +// TestSynchronousWorldExec verifies that Exec on a synchronous World runs the +// transaction on the calling goroutine, with the returned channel closed once +// Exec returns. +func TestSynchronousWorldExec(t *testing.T) { + w := Config{Synchronous: true}.New() + defer w.Close() + + var ran bool + c := w.Exec(func(tx *Tx) { ran = true }) + if !ran { + t.Fatal("expected transaction to have run when Exec returned") + } + select { + case <-c: + default: + t.Fatal("expected channel returned by Exec to be closed when Exec returned") + } +} + +// TestSynchronousWorldAdvanceTick verifies that a synchronous World does not +// tick on its own and that AdvanceTick advances the current tick exactly once +// per call, even without any viewers. +func TestSynchronousWorldAdvanceTick(t *testing.T) { + w := Config{Synchronous: true}.New() + defer w.Close() + + current := func() int64 { + w.set.Lock() + defer w.set.Unlock() + return w.set.CurrentTick + } + start := current() + time.Sleep(time.Second / 10) + if got := current(); got != start { + t.Fatalf("expected no automatic ticking, tick advanced from %v to %v", start, got) + } + for range 5 { + w.AdvanceTick() + } + if got := current(); got != start+5 { + t.Fatalf("expected current tick %v after 5 AdvanceTick calls, got %v", start+5, got) + } +} + +func TestSynchronousExecWorldCanRemoveEntity(t *testing.T) { + w := Config{Synchronous: true}.New() + defer w.Close() + + h := EntitySpawnOpts{Position: mgl64.Vec3{0, 4, 0}}.New(testEntityType{}, testEntityConfig{}) + <-w.Exec(func(tx *Tx) { + tx.AddEntity(h) + }) + + done := make(chan struct{}) + go func() { + h.ExecWorld(func(tx *Tx, e Entity) { + tx.RemoveEntity(e) + }) + close(done) + }() + select { + case <-done: + case <-time.After(time.Second * 5): + t.Fatal("ExecWorld self-removal did not complete") + } +} + +func TestSynchronousAdvanceTickTicksViewerlessEntities(t *testing.T) { + w := Config{Synchronous: true}.New() + defer w.Close() + + h := EntitySpawnOpts{Position: mgl64.Vec3{0, 4, 0}}.New(testEntityType{}, testEntityConfig{}) + <-w.Exec(func(tx *Tx) { + tx.AddEntity(h) + }) + + start := h.data.Pos + for range 3 { + w.AdvanceTick() + } + if got := h.data.Pos; got == start { + t.Fatalf("expected entity position to change after ticking, got %v", got) + } +} + +func TestSynchronousAdvanceTickTicksViewerlessBlockEntities(t *testing.T) { + w := Config{Synchronous: true}.New() + defer w.Close() + + pos := cube.Pos{0, 4, 0} + tb := &testTickerBlock{} + <-w.Exec(func(tx *Tx) { + col := tx.World().chunk(chunkPosFromBlockPos(pos)) + chest, ok := tx.World().conf.Blocks.BlockByName("minecraft:chest", map[string]any{"minecraft:cardinal_direction": "north"}) + if !ok { + t.Fatal("expected chest block to be registered") + } + col.SetBlock(uint8(pos[0]), int16(pos[1]), uint8(pos[2]), 0, tx.World().conf.Blocks.BlockRuntimeID(chest)) + col.BlockEntities[pos] = tb + }) + + w.AdvanceTick() + if tb.ticks == 0 { + t.Fatal("expected block entity to tick") + } +} + +type testEntityConfig struct{} + +func (testEntityConfig) Apply(*EntityData) {} + +type testEntityType struct{} + +func (testEntityType) Open(_ *Tx, handle *EntityHandle, data *EntityData) Entity { + return &testEntity{handle: handle, data: data} +} + +func (testEntityType) EncodeEntity() string { + return "dragonfly:test_entity" +} + +func (testEntityType) BBox(Entity) cube.BBox { + return cube.Box(0, 0, 0, 1, 1, 1) +} + +func (testEntityType) DecodeNBT(map[string]any, *EntityData) {} + +func (testEntityType) EncodeNBT(*EntityData) map[string]any { + return nil +} + +type testEntity struct { + handle *EntityHandle + data *EntityData +} + +func (e *testEntity) Close() error { + return nil +} + +func (e *testEntity) H() *EntityHandle { + return e.handle +} + +func (e *testEntity) Position() mgl64.Vec3 { + return e.data.Pos +} + +func (e *testEntity) Rotation() cube.Rotation { + return e.data.Rot +} + +func (e *testEntity) Tick(*Tx, int64) { + e.data.Pos = e.data.Pos.Add(mgl64.Vec3{0, -0.1, 0}) +} + +type testTickerBlock struct { + ticks int +} + +func (*testTickerBlock) EncodeBlock() (string, map[string]any) { + return "dragonfly:test_ticker", nil +} + +func (*testTickerBlock) Hash() (uint64, uint64) { + return 1<<32 - 1, 0 +} + +func (*testTickerBlock) Model() BlockModel { + return unknownModel{} +} + +func (*testTickerBlock) DecodeNBT(map[string]any) any { + return &testTickerBlock{} +} + +func (*testTickerBlock) EncodeNBT() map[string]any { + return nil +} + +func (b *testTickerBlock) Tick(int64, cube.Pos, *Tx) { + b.ticks++ +}