Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implementation of NPC Dialogues #666

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
29 changes: 29 additions & 0 deletions server/player/dialogue/button.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package dialogue

import "encoding/json"

// Button represents a Button added to Menu. A button contains Name as well as an activation type and a general type.
type Button struct {
// Name is the name of the button and is displayed to the user.
Name string
// Activation is the specific method of activation required by the button. CLICK = 0, CLOSE = 1, ENTER = 2.
Activation ActivationType
// Type is the type of button / action it takes.
Type ButtonType
}

// NewButton returns a new Button with the name, activationType, and buttonType passed.
func NewButton(name string, activationType ActivationType, buttonType ButtonType) Button {
return Button{Name: name, Activation: activationType, Type: buttonType}
}

// MarshalJSON ...
func (b Button) MarshalJSON() ([]byte, error) {
data := map[string]any{
"button_name": b.Name,
"text": "", // Buttons don't work if this value isn't sent.
"mode": b.Activation.Uint8(),
"type": b.Type.Uint8(),
}
return json.Marshal(data)
}
48 changes: 48 additions & 0 deletions server/player/dialogue/button_type.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package dialogue

type activation uint8

// ActivationType represents a specific type of activation on a Button. The different types are Click, Close, and Enter.
type ActivationType struct {
activation
}

func (a activation) Uint8() uint8 {
return uint8(a)
}

// ActivationClick specifies on activating a button when its clicked.
func ActivationClick() ActivationType {
return ActivationType{0}
}

// ActivationClose is the close activation.
func ActivationClose() ActivationType {
return ActivationType{1}
}

// ActivationEnter is the enter activation type.
func ActivationEnter() ActivationType {
return ActivationType{2}
}

type button uint8

// ButtonType represents the type of Button. URL, COMMAND, and UNKNOWN are all the button types.
abimek marked this conversation as resolved.
Show resolved Hide resolved
type ButtonType struct {
button
}

func (b button) Uint8() uint8 {
return uint8(b)
}

// CommandButton is a button meant to execute a command.
func CommandButton() ButtonType {
return ButtonType{1}
}

// UnknownButton has unknown behaviour as of the moment.
func UnknownButton() ButtonType {
return ButtonType{2}
}
9 changes: 9 additions & 0 deletions server/player/dialogue/dialogue.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package dialogue

// Dialogue represents a singular dialog scene. Scenes are used to within NewMenu to create a new Dialog Menu. Submit is
// called when a button is submitted by a Submitter.
type Dialogue interface {
Menu() Menu
// Submit is called when a Submitter submits a Button on the specified Menu.
Submit(submitter Submitter, pressed Button)
}
75 changes: 75 additions & 0 deletions server/player/dialogue/menu.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package dialogue

import (
"encoding/json"
"fmt"
"github.com/df-mc/dragonfly/server/world"
"strings"
)

// Menu represents a npc dialogue. It contains a title, body, entity, and a number of buttons no more than
// 6.
type Menu struct {
title, body string
// action represents the action this menu is executing. This value is either packet.NPCDialogueActionOpen or
abimek marked this conversation as resolved.
Show resolved Hide resolved
// packet.NPCDialogueActionClose.
npc world.NPC
buttons []Button
}

// NewMenu creates a new Menu with the Dialogue passed. Title is formatted with accordance to the rules of fmt.Sprintln.
func NewMenu(npc world.NPC, title ...any) Menu {
return Menu{
title: format(title),
npc: npc,
}
}

// WithButtons creates a copy of the dialogue Menu and appends the buttons passed to the existing buttons, after
// which the new dialogue Menu is returned. If the count of the buttons passed and the buttons already within the Menu
// pass the threshold of 6, it will return an empty Menu and an error.
func (m Menu) WithButtons(buttons ...Button) (Menu, error) {
if len(m.buttons)+len(buttons) > 6 {
return Menu{}, fmt.Errorf("menu has %v buttons, an addition of %v will pass the 6 buttons threashold", len(m.buttons), len(buttons))
abimek marked this conversation as resolved.
Show resolved Hide resolved
}
m.buttons = append(m.buttons, buttons...)
return m, nil
}

// WithBody creates a copy of the dialogue Menu and replaces the existing body with the body passed, after which the
// new dialogue Menu 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
}

// NPC returns the entity associated with this Menu.
func (m Menu) NPC() world.Entity {
return m.npc
}

// Body returns the formatted body passed to Menu by WithBody()
func (m Menu) Body() string {
return m.body
}

// Buttons will return all the buttons passed to Menu by WithButtons().
func (m Menu) Buttons() []Button {
return m.buttons
}

// Title returns the formatted body passed to Menu from NewMenu()
func (m Menu) Title() string {
return m.title
}

// MarshalJSON ...
func (m Menu) MarshalJSON() ([]byte, error) {
return json.Marshal(m.Buttons())
}

// 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")
}
14 changes: 14 additions & 0 deletions server/player/dialogue/submit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package dialogue

// Submitter is an entity that is able to open a dialog and submit it by clicking a button. The entity can also have
// a dialogue force closed upon it by the server.
type Submitter interface {
SendDialogue(d Dialogue)
CloseDialogue(d Dialogue)
}

// Closer represents a scene that has special logic when the dialogue scene is closed.
type Closer interface {
// Close gets called when a Submitter closes a dialogue.
Close(submitter Submitter)
}
15 changes: 15 additions & 0 deletions server/player/player.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package player

import (
"fmt"
"github.com/df-mc/dragonfly/server/player/dialogue"
"math"
"math/rand"
"net"
Expand Down Expand Up @@ -389,6 +390,20 @@ func (p *Player) SendForm(f form.Form) {
p.session().SendForm(f)
}

// SendDialogue sends a dialogue to the player for the client to fill out. Once the client fills it out, the Submit
// method of the form will be called.
// Note that the client may also close the form instead of filling it out, which will result in the Close method to be
// called on the dialogue if it implements dialogue.Closer.
func (p *Player) SendDialogue(d dialogue.Dialogue) {
p.session().SendDialogue(d)
}

// CloseDialogue closes any dialogue that may be open to a user. Note that when this method is closed, if the dialogue
abimek marked this conversation as resolved.
Show resolved Hide resolved
// implements dialogue.Closer the Close method will still execute.
func (p *Player) CloseDialogue(d dialogue.Dialogue) {
p.session().CloseDialogue(d)
}

// ShowCoordinates enables the vanilla coordinates for the player.
func (p *Player) ShowCoordinates() {
p.session().EnableCoordinates(true)
Expand Down
2 changes: 2 additions & 0 deletions server/session/controllable.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/df-mc/dragonfly/server/item"
"github.com/df-mc/dragonfly/server/item/inventory"
"github.com/df-mc/dragonfly/server/player/chat"
"github.com/df-mc/dragonfly/server/player/dialogue"
"github.com/df-mc/dragonfly/server/player/form"
"github.com/df-mc/dragonfly/server/player/skin"
"github.com/df-mc/dragonfly/server/world"
Expand All @@ -22,6 +23,7 @@ type Controllable interface {
world.Entity
item.User
form.Submitter
dialogue.Submitter
cmd.Source
chat.Subscriber

Expand Down
4 changes: 4 additions & 0 deletions server/session/entity_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ func (s *Session) parseEntityMetadata(e world.Entity) entityMetadata {

m.setFlag(dataKeyFlags, dataFlagAffectedByGravity)
m.setFlag(dataKeyFlags, dataFlagCanClimb)
if npc, ok := e.(world.NPC); ok && npc.NPC() {
m[dataKeyHasNpcComponent] = boolByte(npc.NPC())
}
if sn, ok := e.(sneaker); ok && sn.Sneaking() {
m.setFlag(dataKeyFlags, dataFlagSneaking)
}
Expand Down Expand Up @@ -175,6 +178,7 @@ const (
dataKeyCustomDisplay = 18
dataKeyPotionAuxValue = 36
dataKeyScale = 38
dataKeyHasNpcComponent = 39
dataKeyMaxAir = 42
dataKeyBoundingBoxWidth = 53
dataKeyBoundingBoxHeight = 54
Expand Down
46 changes: 46 additions & 0 deletions server/session/handle_npc_request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package session

import (
"fmt"
"github.com/df-mc/dragonfly/server/player/dialogue"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
"sync"
)

// NpcRequestHandler handles the NpcRequest packet.
type NpcRequestHandler struct {
mu sync.Mutex
dialogues map[string]dialogue.Dialogue
}

// Handle ...
func (h *NpcRequestHandler) Handle(p packet.Packet, s *Session) error {
pk := p.(*packet.NPCRequest)
h.mu.Lock()
d, ok := h.dialogues[s.c.XUID()]
h.mu.Unlock()
if !ok {
return fmt.Errorf("no dialogue menu for player with xuid %v", s.c.XUID())
}
m := d.Menu()
switch pk.RequestType {
case packet.NPCRequestActionExecuteAction:
buttons := m.Buttons()
index := int(pk.ActionType)
if index >= len(buttons) {
return fmt.Errorf("error submitting dialogue, button index points to inexistent button: %v (only %v buttons present)", index, len(buttons))
}
d.Submit(s.Controllable(), buttons[index])
// We close the dialogue because if we don't close it here and the api implementor forgets to close it the
// client permanently stuck in the dialogue UI being unable to close it or submit a button.
s.Controllable().CloseDialogue(d)
case packet.NPCRequestActionExecuteClosingCommands:
if c, ok := d.(dialogue.Closer); ok {
c.Close(s.Controllable())
}
h.mu.Lock()
delete(h.dialogues, s.c.XUID())
h.mu.Unlock()
}
return nil
}
52 changes: 52 additions & 0 deletions server/session/player.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/df-mc/dragonfly/server/item/creative"
"github.com/df-mc/dragonfly/server/item/inventory"
"github.com/df-mc/dragonfly/server/item/recipe"
"github.com/df-mc/dragonfly/server/player/dialogue"
"github.com/df-mc/dragonfly/server/player/form"
"github.com/df-mc/dragonfly/server/player/skin"
"github.com/df-mc/dragonfly/server/world"
Expand Down Expand Up @@ -328,6 +329,57 @@ func (s *Session) SendForm(f form.Form) {
})
}

// SendDialogue shows a npc dialogue to the client of the connection. The Submit method of the dialogue is called when
// the client clicks a button or closes the menu.
func (s *Session) SendDialogue(d dialogue.Dialogue) {
h := s.handlers[packet.IDNPCRequest].(*NpcRequestHandler)

m := d.Menu()
h.mu.Lock()
h.dialogues[s.c.XUID()] = d
h.mu.Unlock()

s.entityMutex.Lock()
RID := s.entityRuntimeIDs[m.NPC()]
abimek marked this conversation as resolved.
Show resolved Hide resolved
s.entityMutex.Unlock()

aj, _ := json.Marshal(m)
s.writePacket(&packet.NPCDialogue{
EntityUniqueID: RID,
ActionType: packet.NPCDialogueActionOpen,
Dialogue: m.Body(),
SceneName: "default",
NPCName: m.Title(),
ActionJSON: string(aj),
})
}

// CloseDialogue forcefully closes the users current dialogue. The Close method of dialogue.Closer is called when the
// form closes on the client.
func (s *Session) CloseDialogue(d dialogue.Dialogue) {
h := s.handlers[packet.IDNPCRequest].(*NpcRequestHandler)

m := d.Menu()
s.entityMutex.Lock()
RID := s.entityRuntimeIDs[m.NPC()]
abimek marked this conversation as resolved.
Show resolved Hide resolved
s.entityMutex.Unlock()

h.mu.Lock()
_, ok := h.dialogues[s.c.XUID()]
h.mu.Unlock()
if !ok {
return
}
s.writePacket(&packet.NPCDialogue{
EntityUniqueID: RID,
ActionType: packet.NPCDialogueActionClose,
Dialogue: m.Body(),
SceneName: "default",
NPCName: m.Title(),
ActionJSON: "",
})
}

// Transfer transfers the player to a server with the IP and port passed.
func (s *Session) Transfer(ip net.IP, port int) {
s.writePacket(&packet.Transfer{
Expand Down
2 changes: 2 additions & 0 deletions server/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/df-mc/dragonfly/server/item/inventory"
"github.com/df-mc/dragonfly/server/item/recipe"
"github.com/df-mc/dragonfly/server/player/chat"
"github.com/df-mc/dragonfly/server/player/dialogue"
"github.com/df-mc/dragonfly/server/player/form"
"github.com/df-mc/dragonfly/server/world"
"github.com/go-gl/mathgl/mgl64"
Expand Down Expand Up @@ -446,6 +447,7 @@ func (s *Session) registerHandlers() {
packet.IDMobEquipment: &MobEquipmentHandler{},
packet.IDModalFormResponse: &ModalFormResponseHandler{forms: make(map[uint32]form.Form)},
packet.IDMovePlayer: nil,
packet.IDNPCRequest: &NpcRequestHandler{dialogues: make(map[string]dialogue.Dialogue)},
packet.IDPlayerAction: &PlayerActionHandler{},
packet.IDPlayerAuthInput: &PlayerAuthInputHandler{},
packet.IDPlayerSkin: &PlayerSkinHandler{},
Expand Down
7 changes: 7 additions & 0 deletions server/world/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ type Entity interface {
World() *World
}

// NPC represents an entity can be seen within Npc Dialogues. Only entities that implement NPC can be used to create
// dialogue forms.
type NPC interface {
Entity
NPC() bool
}

// TickerEntity represents an entity that has a Tick method which should be called every time the entity is
// ticked every 20th of a second.
type TickerEntity interface {
Expand Down