up3
Build and deploy / Build (push) Has been cancelled
Build and deploy / Update Contributors (push) Has been cancelled
Build and deploy / Deploy (push) Has been cancelled

This commit is contained in:
2026-07-09 08:33:57 +08:00
commit 26ed99fda6
845 changed files with 75419 additions and 0 deletions
+61
View File
@@ -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")
}
+50
View File
@@ -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)
}
+89
View File
@@ -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
}
+51
View File
@@ -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)
}
+130
View File
@@ -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("<yellow>%v</yellow>")
var MessageQuit = Translate(str("%multiplayer.player.left"), 1, `%v left the game`).Enc("<yellow>%v</yellow>")
var MessageServerDisconnect = Translate(str("%disconnect.disconnected"), 0, `Disconnected by Server`).Enc("<yellow>%v</yellow>")
var MessageBedTooFar = Translate(str("%tile.bed.tooFar"), 0, `Bed is too far away`).Enc("<grey>%v</grey>")
var MessageBedObstructed = Translate(str("%tile.bed.obstructed"), 0, `Bed is obstructed`).Enc("<grey>%v</grey>")
var MessageRespawnPointSet = Translate(str("%tile.bed.respawnSet"), 0, `Respawn point set`).Enc("<grey>%v</grey>")
var MessageNoSleep = Translate(str("%tile.bed.noSleep"), 0, `You can only sleep at night and during thunderstorms`).Enc("<grey>%v</grey>")
var MessageBedIsOccupied = Translate(str("%tile.bed.occupied"), 0, `This bed is occupied`).Enc("<grey>%v</grey>")
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()
}
+126
View File
@@ -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
}
+14
View File
@@ -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()
}
+255
View File
@@ -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
}
+21
View File
@@ -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
})
}
+142
View File
@@ -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")
}
+38
View File
@@ -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)
}
+30
View File
@@ -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)
}
+344
View File
@@ -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() {}
+217
View File
@@ -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")
}
+175
View File
@@ -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")
}
}
}
+129
View File
@@ -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")
}
}
+46
View File
@@ -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()
}
+202
View File
@@ -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) {}
+101
View File
@@ -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(),
}
}
+11
View File
@@ -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
}
+139
View File
@@ -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 }
File diff suppressed because it is too large Load Diff
+50
View File
@@ -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
}
+95
View File
@@ -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)
}
+143
View File
@@ -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
}
+61
View File
@@ -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()
}
+33
View File
@@ -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 }
+105
View File
@@ -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
}
+87
View File
@@ -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],
}
}
+52
View File
@@ -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],
}
}
+37
View File
@@ -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
}
+77
View File
@@ -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],
}
}
+106
View File
@@ -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")
}
+63
View File
@@ -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 }