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

player/dialogue: Basic implementation of NPC dialogues #937

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions server/player/dialogue/button.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
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
})
}
122 changes: 122 additions & 0 deletions server/player/dialogue/dialogue.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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
}

// 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
}

// 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
}

// Buttons returns a list of all 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 not valid.
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")
}
26 changes: 26 additions & 0 deletions server/player/dialogue/submit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package dialogue

import "github.com/df-mc/dragonfly/server/world"

// Submittable is a structure which may be submitted by sending it as a form 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.
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)
}
16 changes: 16 additions & 0 deletions server/player/player.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/df-mc/dragonfly/server/item/inventory"
"github.com/df-mc/dragonfly/server/player/bossbar"
"github.com/df-mc/dragonfly/server/player/chat"
"github.com/df-mc/dragonfly/server/player/dialogue"
"github.com/df-mc/dragonfly/server/player/form"
"github.com/df-mc/dragonfly/server/player/scoreboard"
"github.com/df-mc/dragonfly/server/player/skin"
Expand Down Expand Up @@ -352,6 +353,21 @@ func (p *Player) SendCommandOutput(output *cmd.Output) {
p.session().SendCommandOutput(output)
}

// SendDialogue sends an NPC dialogue to the player, using the entity passed as the entity that the dialogue
// is shown for. Dialogues can be sent on top of each other without the other closing, making it possible
// to have non-flashing transitions between menus compared to forms. The player can either press one of the
// buttons or close the dialogue. It is impossible for a dialogue to have any more than 6 buttons.
func (p *Player) SendDialogue(d dialogue.Dialogue, e world.Entity) {
p.session().SendDialogue(d, e)
}

// CloseDialogue closes the player's currently open dialogue, if any. If the dialogue's Submittable implements
// dialogue.Closer, the Close method of the Submittable is called after the client acknowledges the closing
// of the dialogue.
func (p *Player) CloseDialogue() {
p.session().CloseDialogue()
}

// SendForm sends a form to the player for the client to fill out. Once the client fills it out, the Submit
// method of the form will be called.
// Note that the client may also close the form instead of filling it out, which will result in the form not
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 {
Name() string
world.Entity
item.User
dialogue.Submitter
form.Submitter
cmd.Source
chat.Subscriber
Expand Down
1 change: 1 addition & 0 deletions server/session/entity_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func (s *Session) parseEntityMetadata(e world.Entity) protocol.EntityMetadata {
m[protocol.EntityDataKeyEffectColor] = int32(0)
m[protocol.EntityDataKeyEffectAmbience] = byte(0)
m[protocol.EntityDataKeyColorIndex] = byte(0)
m[protocol.EntityDataKeyHasNPC] = uint8(1)

m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagHasGravity)
m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagClimb)
Expand Down
27 changes: 27 additions & 0 deletions server/session/handler_npc_request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package session

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

// NPCRequestHandler handles the NPCRequest packet.
type NPCRequestHandler struct {
dialogue dialogue.Dialogue
entityRuntimeID uint64
}

// Handle ...
func (h *NPCRequestHandler) Handle(p packet.Packet, s *Session, tx *world.Tx, c Controllable) error {
pk := p.(*packet.NPCRequest)
if pk.RequestType == packet.NPCRequestActionExecuteAction {
if err := h.dialogue.Submit(uint(pk.ActionType), c, tx); err != nil {
return fmt.Errorf("error submitting dialogue: %w", err)
}
} else if pk.RequestType == packet.NPCRequestActionExecuteClosingCommands {
h.dialogue.Close(c, tx)
}
return nil
}
33 changes: 33 additions & 0 deletions server/session/player.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,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 @@ -364,6 +365,38 @@ func (s *Session) SendFood(food int, saturation, exhaustion float64) {
})
}

// SendDialogue sends an NPC dialogue to the client of the connection. The Submit method of the dialogue is
// called when the client interacts with a button in the dialogue.
func (s *Session) SendDialogue(d dialogue.Dialogue, e world.Entity) {
b, _ := json.Marshal(d)

h := s.handlers[packet.IDNPCRequest].(*NPCRequestHandler)
h.dialogue = d
h.entityRuntimeID = s.entityRuntimeID(e)

s.writePacket(&packet.NPCDialogue{
EntityUniqueID: h.entityRuntimeID,
ActionType: packet.NPCDialogueActionOpen,
Dialogue: d.Body(),
SceneName: "default",
NPCName: d.Title(),
ActionJSON: string(b),
})
}

func (s *Session) CloseDialogue() {
h := s.handlers[packet.IDNPCRequest].(*NPCRequestHandler)
if h.entityRuntimeID == 0 {
return
}

s.writePacket(&packet.NPCDialogue{
EntityUniqueID: h.entityRuntimeID,
ActionType: packet.NPCDialogueActionClose,
})
h.entityRuntimeID = 0
}

// SendForm sends a form to the client of the connection. The Submit method of the form is called when the
// client submits the form.
func (s *Session) SendForm(f form.Form) {
Expand Down
1 change: 1 addition & 0 deletions server/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,7 @@ func (s *Session) registerHandlers() {
packet.IDMobEquipment: &MobEquipmentHandler{},
packet.IDModalFormResponse: &ModalFormResponseHandler{forms: make(map[uint32]form.Form)},
packet.IDMovePlayer: nil,
packet.IDNPCRequest: &NPCRequestHandler{},
packet.IDPlayerAction: &PlayerActionHandler{},
packet.IDPlayerAuthInput: &PlayerAuthInputHandler{},
packet.IDPlayerSkin: &PlayerSkinHandler{},
Expand Down
Loading