Files
inkwell-demo/lib/widget.gamechat.go
T
mr.zeroandClaude Opus 5 d2daa3f02a
ci/woodpecker/push/ebitengine Pipeline was successful
ci/woodpecker/manual/ebitengine Pipeline was successful
Consume the published inkwell module instead of a sibling checkout
inkwell is now released as engines/inkwell v0.1.0, so the `replace
=> ../inkwell` directive can go. That directive is why this pipeline
was failing: it requires a sibling working copy that only exists on a
developer's machine, never in a CI checkout.

Module paths follow the org move as well — this repo is demos/, inkwell
is engines/ — since Gitea's redirect does not apply to Go module paths.

GOPRIVATE is exported from the Makefile: our own modules are not on
proxy.golang.org and not in the public checksum database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:35:01 +02:00

245 lines
5.4 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/engines/inkwell"
)
// 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 inkwell 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 inkwell 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
}