custom theme with chat widget

This commit is contained in:
2026-05-26 07:30:43 +02:00
parent e893963743
commit 3dccfb5805
3 changed files with 326 additions and 0 deletions

244
lib/widget.gamechat.go Normal file
View File

@@ -0,0 +1,244 @@
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
}