245 lines
5.3 KiB
Go
245 lines
5.3 KiB
Go
package lib
|
|
|
|
import (
|
|
"image/color"
|
|
"math/rand"
|
|
"strings"
|
|
|
|
"github.com/hajimehoshi/ebiten/v2"
|
|
"github.com/hajimehoshi/ebiten/v2/inpututil"
|
|
"github.com/hajimehoshi/ebiten/v2/vector"
|
|
|
|
p "git.teletypegames.org/games/pncdsl"
|
|
)
|
|
|
|
// GameChat is a stand-alone chat panel: click to focus, type a message,
|
|
// press Enter to send, and the widget answers with a random canned line.
|
|
// It does not talk to any game system — the in-game action log stays
|
|
// decoupled from this gameplay-flavoured chatter, so the widget can be
|
|
// reused across games without dragging the demo's content with it.
|
|
type GameChat struct {
|
|
Name string
|
|
Bounds p.Rectangle
|
|
LineHeight int
|
|
Padding int
|
|
InputHeight int
|
|
|
|
BG color.Color
|
|
Border color.Color
|
|
FocusBorder color.Color
|
|
InputBG color.Color
|
|
UserText color.Color
|
|
BotText color.Color
|
|
PromptText color.Color
|
|
CursorColor color.Color
|
|
|
|
UserPrefix string
|
|
BotPrefix string
|
|
Replies []string
|
|
|
|
history []chatEntry
|
|
buffer []rune
|
|
focused bool
|
|
blink float64
|
|
scratch []rune
|
|
}
|
|
|
|
type chatEntry struct {
|
|
text string
|
|
self bool
|
|
}
|
|
|
|
func (c *GameChat) GetName() string { return c.Name }
|
|
|
|
func (c *GameChat) Tick(ctx *p.UICtx) {
|
|
in := ctx.Game.Input
|
|
c.blink += ctx.DT
|
|
|
|
if in.LeftClicked() {
|
|
if c.Bounds.Contains(in.Point()) {
|
|
c.focused = true
|
|
in.ConsumeLeft()
|
|
} else {
|
|
c.focused = false
|
|
}
|
|
}
|
|
if !c.focused {
|
|
return
|
|
}
|
|
|
|
c.scratch = ebiten.AppendInputChars(c.scratch[:0])
|
|
if len(c.scratch) > 0 {
|
|
c.buffer = append(c.buffer, c.scratch...)
|
|
}
|
|
if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) && len(c.buffer) > 0 {
|
|
c.buffer = c.buffer[:len(c.buffer)-1]
|
|
}
|
|
if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
|
|
c.focused = false
|
|
}
|
|
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) || inpututil.IsKeyJustPressed(ebiten.KeyNumpadEnter) {
|
|
text := strings.TrimSpace(string(c.buffer))
|
|
c.buffer = c.buffer[:0]
|
|
if text == "" {
|
|
return
|
|
}
|
|
c.history = append(c.history, chatEntry{text: text, self: true})
|
|
c.history = append(c.history, chatEntry{text: c.randomReply(), self: false})
|
|
}
|
|
}
|
|
|
|
func (c *GameChat) randomReply() string {
|
|
src := c.Replies
|
|
if len(src) == 0 {
|
|
src = defaultChatReplies
|
|
}
|
|
return src[rand.Intn(len(src))]
|
|
}
|
|
|
|
var defaultChatReplies = []string{
|
|
"Hmm…",
|
|
"Aha.",
|
|
"Mióóóu.",
|
|
"Komolyan?",
|
|
"Talán.",
|
|
"Igen, igen.",
|
|
"Hagyj békén, álmos vagyok.",
|
|
"Most főzöm a kávét.",
|
|
"Persze, persze.",
|
|
}
|
|
|
|
func (c *GameChat) Draw(dst *ebiten.Image, ctx *p.UICtx) {
|
|
g := ctx.Game
|
|
b := c.Bounds
|
|
if b.W <= 0 || b.H <= 0 {
|
|
return
|
|
}
|
|
lh := c.LineHeight
|
|
if lh <= 0 {
|
|
lh = 10
|
|
}
|
|
pad := c.Padding
|
|
if pad <= 0 {
|
|
pad = 3
|
|
}
|
|
inputH := c.InputHeight
|
|
if inputH <= 0 {
|
|
inputH = lh + 4
|
|
}
|
|
userPrefix := c.UserPrefix
|
|
if userPrefix == "" {
|
|
userPrefix = "Te: "
|
|
}
|
|
botPrefix := c.BotPrefix
|
|
if botPrefix == "" {
|
|
botPrefix = "Cirmos: "
|
|
}
|
|
|
|
historyH := int(b.H) - inputH
|
|
if historyH < lh {
|
|
historyH = lh
|
|
}
|
|
|
|
vector.DrawFilledRect(dst, float32(b.X), float32(b.Y), float32(b.W), float32(b.H), c.BG, false)
|
|
|
|
wrapW := int(b.W) - pad*2
|
|
type line struct {
|
|
text string
|
|
col color.Color
|
|
}
|
|
var rendered []line
|
|
for _, m := range c.history {
|
|
col := c.BotText
|
|
prefix := botPrefix
|
|
if m.self {
|
|
col = c.UserText
|
|
prefix = userPrefix
|
|
}
|
|
for _, w := range chatWrap(prefix+m.text, wrapW) {
|
|
rendered = append(rendered, line{text: w, col: col})
|
|
}
|
|
}
|
|
maxLines := (historyH - pad*2) / lh
|
|
if maxLines < 0 {
|
|
maxLines = 0
|
|
}
|
|
start := len(rendered) - maxLines
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
for i, ln := range rendered[start:] {
|
|
y := int(b.Y) + pad + i*lh
|
|
g.DrawText(dst, ln.text, int(b.X)+pad, y-1, ln.col)
|
|
}
|
|
|
|
inY := int(b.Y) + historyH
|
|
vector.DrawFilledRect(dst, float32(b.X)+1, float32(inY)+1, float32(int(b.W)-2), float32(inputH-2), c.InputBG, false)
|
|
txt := "> " + string(c.buffer)
|
|
g.DrawText(dst, txt, int(b.X)+pad, inY+pad-1, c.PromptText)
|
|
if c.focused && int(c.blink*2)%2 == 0 {
|
|
cx := int(b.X) + pad + chatTextWidth(txt)
|
|
cy := inY + pad - 1
|
|
vector.DrawFilledRect(dst, float32(cx), float32(cy), 1, float32(lh-1), c.CursorColor, false)
|
|
}
|
|
|
|
border := c.Border
|
|
if c.focused {
|
|
border = c.FocusBorder
|
|
}
|
|
vector.StrokeRect(dst, float32(b.X), float32(b.Y), float32(b.W), float32(b.H), 1, border, false)
|
|
vector.DrawFilledRect(dst, float32(b.X), float32(inY), float32(b.W), 1, border, false)
|
|
}
|
|
|
|
// BlocksClickAt implements the pncdsl clickBlocker contract so the
|
|
// verb-coin doesn't pop up over the chat panel.
|
|
func (c *GameChat) BlocksClickAt(pt p.Point) bool { return c.Bounds.Contains(pt) }
|
|
|
|
// chatTextWidth mirrors the pncdsl text width estimate for the bundled
|
|
// debug font (~6 px / glyph). Kept private to the demo.
|
|
func chatTextWidth(s string) int { return len(s) * 6 }
|
|
|
|
func chatWrap(s string, maxPx int) []string {
|
|
if maxPx <= 0 || chatTextWidth(s) <= maxPx {
|
|
return []string{s}
|
|
}
|
|
var out []string
|
|
var line string
|
|
flush := func() {
|
|
if line != "" {
|
|
out = append(out, line)
|
|
line = ""
|
|
}
|
|
}
|
|
word := ""
|
|
for _, r := range s {
|
|
if r == ' ' || r == '\n' {
|
|
if line == "" {
|
|
line = word
|
|
} else if chatTextWidth(line+" "+word) <= maxPx {
|
|
line += " " + word
|
|
} else {
|
|
flush()
|
|
line = word
|
|
}
|
|
word = ""
|
|
if r == '\n' {
|
|
flush()
|
|
}
|
|
continue
|
|
}
|
|
word += string(r)
|
|
}
|
|
if word != "" {
|
|
if line == "" {
|
|
line = word
|
|
} else if chatTextWidth(line+" "+word) <= maxPx {
|
|
line += " " + word
|
|
} else {
|
|
flush()
|
|
line = word
|
|
}
|
|
}
|
|
flush()
|
|
return out
|
|
}
|