OpenDiablo2/d2core/d2term/terminal.go

461 lines
9.5 KiB
Go

package d2term
import (
"errors"
"fmt"
"image/color"
"log"
"math"
"strings"
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2enum"
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2interface"
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2math"
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2util"
)
const (
charWidth = 6
charHeight = 16
charDoubleWidth = charWidth * 2
rowCount = 24
rowCountMax = 32
colCountMax = 128
animLength = 0.5
)
const (
darkGrey = 0x2e3436b0
lightGrey = 0x555753b0
lightBlue = 0x3465a4b0
yellow = 0xfce94fb0
red = 0xcc0000b0
)
type visibility int
const (
visHidden visibility = iota
visShowing
visShown
visHiding
)
const (
maxVisAnim = 1.0
minVisAnim = 0.0
)
type historyEntry struct {
text string
category d2enum.TermCategory
}
type commandEntry struct {
description string
arguments []string
fn func([]string) error
}
// Terminal handles the in-game terminal
type Terminal struct {
outputHistory []historyEntry
outputIndex int
command string
commandHistory []string
commandIndex int
lineCount int
visState visibility
visAnim float64
bgColor color.RGBA
fgColor color.RGBA
infoColor color.RGBA
warningColor color.RGBA
errorColor color.RGBA
commands map[string]commandEntry
}
// NewTerminal creates and returns a terminal
func NewTerminal() (*Terminal, error) {
term := &Terminal{
lineCount: rowCount,
bgColor: d2util.Color(darkGrey),
fgColor: d2util.Color(lightGrey),
infoColor: d2util.Color(lightBlue),
warningColor: d2util.Color(yellow),
errorColor: d2util.Color(red),
commands: make(map[string]commandEntry),
}
term.Infof("::: OpenDiablo2 Terminal :::")
term.Infof("type \"ls\" for a list of commands")
if err := term.Bind("ls", "list available commands", nil, term.commandList); err != nil {
return nil, err
}
if err := term.Bind("clear", "clear terminal", nil, term.commandClear); err != nil {
return nil, err
}
return term, nil
}
// Bind binds commands to the terminal
func (t *Terminal) Bind(name, description string, arguments []string, fn func(args []string) error) error {
if name == "" || description == "" {
return fmt.Errorf("missing name or description")
}
if _, ok := t.commands[name]; ok {
t.Warningf("rebinding command with name: %s", name)
}
t.commands[name] = commandEntry{description, arguments, fn}
return nil
}
// Unbind unbinds commands from the terminal
func (t *Terminal) Unbind(names ...string) error {
for _, name := range names {
delete(t.commands, name)
}
return nil
}
// Advance advances the terminal animation
func (t *Terminal) Advance(elapsed float64) error {
switch t.visState {
case visShowing:
t.visAnim = math.Min(maxVisAnim, t.visAnim+elapsed/animLength)
if t.visAnim == maxVisAnim {
t.visState = visShown
}
case visHiding:
t.visAnim = math.Max(minVisAnim, t.visAnim-elapsed/animLength)
if t.visAnim == minVisAnim {
t.visState = visHidden
}
}
if !t.Visible() {
return nil
}
return nil
}
// OnKeyDown handles key down in the terminal
func (t *Terminal) OnKeyDown(event d2interface.KeyEvent) bool {
if event.Key() == d2enum.KeyGraveAccent {
t.toggle()
}
if !t.Visible() {
return false
}
switch event.Key() {
case d2enum.KeyEscape:
t.command = ""
case d2enum.KeyEnd:
t.outputIndex = 0
case d2enum.KeyHome:
t.outputIndex = d2math.MaxInt(0, len(t.outputHistory)-t.lineCount)
case d2enum.KeyPageUp:
maxOutputIndex := d2math.MaxInt(0, len(t.outputHistory)-t.lineCount)
if t.outputIndex += t.lineCount; t.outputIndex >= maxOutputIndex {
t.outputIndex = maxOutputIndex
}
case d2enum.KeyPageDown:
if t.outputIndex -= t.lineCount; t.outputIndex < 0 {
t.outputIndex = 0
}
case d2enum.KeyUp, d2enum.KeyDown:
t.handleControlKey(event.Key(), event.KeyMod())
case d2enum.KeyEnter:
t.processCommand()
case d2enum.KeyBackspace:
if len(t.command) > 0 {
t.command = t.command[:len(t.command)-1]
}
}
return true
}
func (t *Terminal) processCommand() {
if t.command == "" {
return
}
n := 0
for _, command := range t.commandHistory {
if command != t.command {
t.commandHistory[n] = command
n++
}
}
t.commandHistory = t.commandHistory[:n]
t.commandHistory = append(t.commandHistory, t.command)
t.Printf(t.command)
if err := t.Execute(t.command); err != nil {
t.Errorf(err.Error())
}
t.commandIndex = len(t.commandHistory) - 1
t.command = ""
}
func (t *Terminal) handleControlKey(eventKey d2enum.Key, keyMod d2enum.KeyMod) {
switch eventKey {
case d2enum.KeyUp:
if keyMod == d2enum.KeyModControl {
t.lineCount = d2math.MaxInt(0, t.lineCount-1)
} else if len(t.commandHistory) > 0 {
t.command = t.commandHistory[t.commandIndex]
if t.commandIndex == 0 {
t.commandIndex = len(t.commandHistory) - 1
} else {
t.commandIndex--
}
}
case d2enum.KeyDown:
if keyMod == d2enum.KeyModControl {
t.lineCount = d2math.MinInt(t.lineCount+1, rowCountMax)
}
}
}
// OnKeyChars handles char key in terminal
func (t *Terminal) OnKeyChars(event d2interface.KeyCharsEvent) bool {
if !t.Visible() {
return false
}
var handled bool
for _, c := range event.Chars() {
if c != '`' {
t.command += string(c)
handled = true
}
}
return handled
}
// Render renders the terminal
func (t *Terminal) Render(surface d2interface.Surface) error {
if !t.Visible() {
return nil
}
totalWidth, _ := surface.GetSize()
outputHeight := t.lineCount * charHeight
totalHeight := outputHeight + charHeight
offset := -int((1 - easeInOut(t.visAnim)) * float64(totalHeight))
surface.PushTranslation(0, offset)
surface.DrawRect(totalWidth, outputHeight, t.bgColor)
for i := 0; i < t.lineCount; i++ {
historyIndex := len(t.outputHistory) - i - t.outputIndex - 1
if historyIndex < 0 {
break
}
entry := t.outputHistory[historyIndex]
surface.PushTranslation(charDoubleWidth, outputHeight-(i+1)*charHeight)
surface.DrawTextf(entry.text)
surface.PushTranslation(-charDoubleWidth, 0)
switch entry.category {
case d2enum.TermCategoryInfo:
surface.DrawRect(charWidth, charHeight, t.infoColor)
case d2enum.TermCategoryWarning:
surface.DrawRect(charWidth, charHeight, t.warningColor)
case d2enum.TermCategoryError:
surface.DrawRect(charWidth, charHeight, t.errorColor)
}
surface.Pop()
surface.Pop()
}
surface.PushTranslation(0, outputHeight)
surface.DrawRect(totalWidth, charHeight, t.fgColor)
surface.DrawTextf("> " + t.command)
surface.Pop()
surface.Pop()
return nil
}
// Execute executes a command with arguments
func (t *Terminal) Execute(command string) error {
params := parseCommand(command)
if len(params) == 0 {
return errors.New("invalid command")
}
name := params[0]
args := params[1:]
entry, ok := t.commands[name]
if !ok {
return errors.New("command not found")
}
if len(args) != len(entry.arguments) {
return errors.New("command requires different argument count")
}
if err := entry.fn(args); err != nil {
return err
}
return nil
}
// Rawf writes a raw message to the terminal
func (t *Terminal) Rawf(category d2enum.TermCategory, format string, params ...interface{}) {
text := fmt.Sprintf(format, params...)
lines := d2util.SplitIntoLinesWithMaxWidth(text, colCountMax)
for _, line := range lines {
// removes color token (this token ends with [0m )
l := strings.Split(line, "\033[0m")
line = l[len(l)-1]
t.outputHistory = append(t.outputHistory, historyEntry{line, category})
}
}
// Printf writes a message to the terminal
func (t *Terminal) Printf(format string, params ...interface{}) {
t.Rawf(d2enum.TermCategoryNone, format, params...)
}
// Infof writes a warning message to the terminal
func (t *Terminal) Infof(format string, params ...interface{}) {
t.Rawf(d2enum.TermCategoryInfo, format, params...)
}
// Warningf writes a warning message to the terminal
func (t *Terminal) Warningf(format string, params ...interface{}) {
t.Rawf(d2enum.TermCategoryWarning, format, params...)
}
// Errorf writes a error message to the terminal
func (t *Terminal) Errorf(format string, params ...interface{}) {
t.Rawf(d2enum.TermCategoryError, format, params...)
}
// Clear clears the terminal
func (t *Terminal) Clear() {
t.outputHistory = nil
t.outputIndex = 0
}
// Visible returns visible state
func (t *Terminal) Visible() bool {
return t.visState != visHidden
}
// Hide hides the terminal
func (t *Terminal) Hide() {
if t.visState != visHidden {
t.visState = visHiding
}
}
// Show shows the terminal
func (t *Terminal) Show() {
if t.visState != visShown {
t.visState = visShowing
}
}
func (t *Terminal) toggle() {
if t.visState == visHiding || t.visState == visHidden {
t.Show()
return
}
t.Hide()
}
// BindLogger binds a log.Writer to the output
func (t *Terminal) BindLogger() {
log.SetOutput(&terminalLogger{writer: log.Writer(), terminal: t})
}
func easeInOut(t float64) float64 {
t *= 2
if t < 1 {
return 0.5 * t * t * t * t
}
t -= 2
// nolint:gomnd // constant
return -0.5 * (t*t*t*t - 2)
}
func parseCommand(command string) []string {
var (
quoted bool
escape bool
param string
params []string
)
for _, c := range command {
switch c {
case '"':
if escape {
param += string(c)
escape = false
} else {
quoted = !quoted
}
case ' ':
if quoted {
param += string(c)
} else if len(param) > 0 {
params = append(params, param)
param = ""
}
case '\\':
if escape {
param += string(c)
escape = false
} else {
escape = true
}
default:
param += string(c)
}
}
if len(param) > 0 {
params = append(params, param)
}
return params
}