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 }