diff --git a/lib/setup.go b/lib/setup.go index 00501e1..027362f 100644 --- a/lib/setup.go +++ b/lib/setup.go @@ -20,6 +20,9 @@ func Run() { func Build() *p.Game { g := p.NewGame("Reggeli Kávé", 320, 200) + g.ThemeManager.Register(MorningCoffeeTheme) + g.UseTheme("morning-coffee") + for _, a := range Assets { g.AssetManager.Register(a) } @@ -45,6 +48,11 @@ func Build() *p.Game { // in the corner just gets in the way. Pass "player" as the second // arg to put it back. p.RegisterRichUI(g, "", "cat") + // Replace the framework's in-game ChatLog with the demo's own + // click-and-type GameChat panel — game-independent chatter that + // does not belong in the pncdsl library. + g.UIManager.Remove("chat") + g.UIManager.Register(Chat) // Swap the rich UI's always-visible verb wheel for a right-click, // hotspot-only verb coin. The default wheel sits over the bedroom // door, blocking that exit — the coin only appears on a right-click diff --git a/lib/theme.go b/lib/theme.go new file mode 100644 index 0000000..b41b83f --- /dev/null +++ b/lib/theme.go @@ -0,0 +1,74 @@ +package lib + +import ( + "image/color" + + p "git.teletypegames.org/games/pncdsl" +) + +// MorningCoffeeTheme is the game-specific palette: warm coffee browns +// over deep espresso black, paired with the GameChat colors below so the +// stand-alone chat panel reads as part of the same visual set. +var MorningCoffeeTheme = p.Theme{ + Name: "morning-coffee", + + PanelBG: color.RGBA{30, 22, 18, 255}, + StatusText: color.RGBA{245, 230, 200, 255}, + FlashText: color.RGBA{255, 200, 110, 255}, + VerbButtonBG: color.RGBA{60, 40, 30, 255}, + VerbButtonSelectedBG: color.RGBA{180, 110, 50, 255}, + VerbButtonText: color.RGBA{255, 240, 215, 255}, + InventorySlotBG: color.RGBA{45, 32, 25, 255}, + InventorySlotSelectedBG: color.RGBA{180, 110, 50, 255}, + SpeechBubbleBG: color.RGBA{20, 14, 10, 215}, + SpeechDefaultText: color.RGBA{255, 240, 215, 255}, + DialogBG: color.RGBA{25, 18, 14, 245}, + DialogBorder: color.RGBA{180, 110, 50, 255}, + DialogChoiceBG: color.RGBA{45, 30, 22, 255}, + DialogChoiceHover: color.RGBA{95, 60, 35, 255}, + DialogSpeaker: color.RGBA{255, 200, 110, 255}, + DialogText: color.RGBA{255, 240, 215, 255}, + EndCardBG: color.RGBA{10, 6, 4, 235}, + EndCardText: color.RGBA{255, 200, 110, 255}, + CursorColor: color.RGBA{255, 230, 180, 255}, + HotspotOutline: color.RGBA{255, 200, 110, 200}, + SceneBackdrop: color.RGBA{20, 14, 10, 255}, + + TopBarBG: color.RGBA{18, 12, 8, 255}, + TopBarText: color.RGBA{255, 230, 200, 255}, + TopBarAccent: color.RGBA{255, 200, 110, 255}, + + // Built-in ChatLog colors stay defined for completeness; the demo + // replaces the widget with GameChat, but other rich-UI widgets may + // still index these slots. + ChatLogBG: color.RGBA{18, 12, 8, 235}, + ChatLogPrompt: color.RGBA{255, 200, 110, 255}, + ChatLogResponse: color.RGBA{255, 240, 215, 255}, + ChatLogSystem: color.RGBA{160, 130, 100, 255}, + + CharacterPanelBG: color.RGBA{15, 10, 6, 180}, + CharacterPanelBorder: color.RGBA{180, 110, 50, 255}, + CharacterPanelTitle: color.RGBA{255, 200, 110, 255}, +} + +// Chat is the GameChat instance used by the demo. Its colors stay in sync +// with MorningCoffeeTheme by reusing the same warm-brown palette. +var Chat = &GameChat{ + Name: "chat", + Bounds: p.Rect(2, 148, 280, 38), + LineHeight: 9, + Padding: 3, + InputHeight: 13, + + BG: color.RGBA{18, 12, 8, 235}, + Border: color.RGBA{120, 80, 40, 255}, + FocusBorder: color.RGBA{255, 200, 110, 255}, + InputBG: color.RGBA{28, 18, 12, 240}, + UserText: color.RGBA{255, 220, 140, 255}, + BotText: color.RGBA{220, 200, 170, 255}, + PromptText: color.RGBA{255, 240, 215, 255}, + CursorColor: color.RGBA{255, 230, 180, 255}, + + UserPrefix: "Te: ", + BotPrefix: "Cirmos: ", +} diff --git a/lib/widget.gamechat.go b/lib/widget.gamechat.go new file mode 100644 index 0000000..e030c22 --- /dev/null +++ b/lib/widget.gamechat.go @@ -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 +}