diff --git a/domain/game.go b/domain/game.go index cb449a2..7b7043c 100644 --- a/domain/game.go +++ b/domain/game.go @@ -25,10 +25,20 @@ func Build() *p.Game { defineBedroom(g) defineKitchen(g) - // SCUMM-style HUD. Swap for p.RegisterRadialVerbUI(g) to try the - // verb-coin variant, or build a fully custom widget set by calling - // g.UIManager.Register(...) directly. - p.RegisterDefaultUI(g) + // Story-rich HUD: top bar + character panels + chat log + permanent + // verb wheel + small inventory. Swap for p.RegisterDefaultUI(g) to + // get the simpler SCUMM-style layout, or p.RegisterRadialVerbUI(g) + // for a verb-coin (right-click) layout. + p.RegisterRichUI(g, "player", "cat") + g.MaxLogLines = 64 + + // initial HUD vars + g.State.SetVar("score", 0) + g.State.SetVar("time", "Nap 1 - 07:23") + g.State.SetVar("player.state", "Ébren") + g.State.SetVar("player.mood", "Álmos") + g.State.SetVar("cat.state", "Várakozik") + g.State.SetVar("cat.mood", "Türelmetlen") g.StartAt("bedroom").OnStart(p.RunScript("intro")) return g diff --git a/domain/scene.bedroom.go b/domain/scene.bedroom.go index 58a1329..d8e96b5 100644 --- a/domain/scene.bedroom.go +++ b/domain/scene.bedroom.go @@ -5,6 +5,7 @@ import p "pncdsl/pncdsl" func defineBedroom(g *p.Game) { g.SceneManager.Register(p.Scene{ Name: "bedroom", + Title: "Hálószoba", Background: "bg/bedroom", Music: "mus/wakeup", Actors: []p.SceneActor{ diff --git a/domain/scene.kitchen.go b/domain/scene.kitchen.go index d249437..70a03aa 100644 --- a/domain/scene.kitchen.go +++ b/domain/scene.kitchen.go @@ -5,6 +5,7 @@ import p "pncdsl/pncdsl" func defineKitchen(g *p.Game) { g.SceneManager.Register(p.Scene{ Name: "kitchen", + Title: "Konyha", Background: "bg/kitchen", Music: "mus/calm", Actors: []p.SceneActor{ diff --git a/domain/script.intro.go b/domain/script.intro.go index 944e7bc..5eece53 100644 --- a/domain/script.intro.go +++ b/domain/script.intro.go @@ -10,6 +10,7 @@ func defineIntroScript(g *p.Game) { p.Say("player", "Brr, hideg van."), p.Say("player", "Egy kávé kéne, mielőtt szétszakad a fejem."), p.Walk("player", p.Point{X: 200, Y: 145}), + p.SetVar("player.mood", "Eltökélt"), ), }) } diff --git a/domain/script.victory.go b/domain/script.victory.go index ab6f5e7..8f87391 100644 --- a/domain/script.victory.go +++ b/domain/script.victory.go @@ -11,6 +11,9 @@ func defineVictoryScript(g *p.Game) { p.Wait(0.6), p.Say("cat", "Mióóóóu. *boldog macska*"), p.Say("player", "Reggeli kávé: megmentve."), + p.SetVar("score", 100), + p.SetVar("player.mood", "Boldog"), + p.SetVar("cat.mood", "Boldog"), p.ShowEnd("Vége — kösz, hogy játszottál!"), ), }) diff --git a/pncdsl/action.def.go b/pncdsl/action.def.go index b9054a9..a8687eb 100644 --- a/pncdsl/action.def.go +++ b/pncdsl/action.def.go @@ -225,6 +225,7 @@ func (r *sayRunner) Tick(ctx *Ctx) Status { // duration scales with text length, with a 1.2s floor r.duration = 1.2 + float64(len(r.spec.text))*0.05 ctx.Game.SetSpeech(r.spec.speaker, r.spec.text) + ctx.Game.LogResponse(r.spec.speaker, r.spec.text) } r.elapsed += ctx.DT // skip on click diff --git a/pncdsl/core.engine.go b/pncdsl/core.engine.go index d5e56f4..16af78d 100644 --- a/pncdsl/core.engine.go +++ b/pncdsl/core.engine.go @@ -97,9 +97,14 @@ func (e *engine) handleSceneInput() { return } sel := g.Inventory.Selected() + targetLabel := h.Label + if targetLabel == "" { + targetLabel = h.Name + } if sel != "" { if h.OnUseWith != nil { if a, ok := h.OnUseWith[sel]; ok && a != nil { + g.LogAction("> " + verbLabel(g, "use") + " " + sel + " ezen: " + targetLabel) g.queueAction(a, "useWith hotspot") g.Inventory.Select("") return @@ -107,6 +112,7 @@ func (e *engine) handleSceneInput() { } if it, ok := g.ItemManager.Get(sel); ok && it.OnUseWith != nil { if a, ok := it.OnUseWith[h.Name]; ok && a != nil { + g.LogAction("> " + verbLabel(g, "use") + " " + sel + " ezen: " + targetLabel) g.queueAction(a, "useWith item") g.Inventory.Select("") return @@ -126,9 +132,17 @@ func (e *engine) handleSceneInput() { g.FlashLine("Semmi említésre méltó.") return } + g.LogAction("> " + verbLabel(g, g.SelectedVerb()) + " " + targetLabel) g.queueAction(a, "hotspot "+g.SelectedVerb()+" "+h.Name) } +func verbLabel(g *Game, name string) string { + if v, ok := g.VerbManager.Get(name); ok { + return v.Label + } + return name +} + func (e *engine) hotspotAt(p Point) *Hotspot { g := e.g if g.currentScene == "" { diff --git a/pncdsl/core.game.go b/pncdsl/core.game.go index 0360ee0..fce7cfd 100644 --- a/pncdsl/core.game.go +++ b/pncdsl/core.game.go @@ -51,6 +51,26 @@ type Game struct { speech speechState dialog *runtimeDialog activeDialog string + + // in-game message log for ChatLog widgets; ring buffer behavior. + messages []LogMessage + MaxLogLines int // 0 = unlimited (memory grows); set per-game. +} + +// LogKind classifies a chat-log entry — UI widgets style them differently. +type LogKind int + +const ( + LogAction LogKind = iota // player command ("> look at door") + LogResponse // in-world reply / Say output + LogSystem // game/system message +) + +// LogMessage is a single line in the chat-log buffer. +type LogMessage struct { + Speaker string // empty for actions / system + Text string + Kind LogKind } // speechState is what the SpeechBubble widget renders. Mutated by Say. @@ -130,6 +150,48 @@ func (g *Game) FlashLine(text string) { g.flashTimer = 2.0 } +// LogAction appends a player-command line (rendered with the prompt color +// by ChatLog widgets). +func (g *Game) LogAction(text string) { + g.appendLog(LogMessage{Text: text, Kind: LogAction}) +} + +// LogResponse appends a reply line (rendered with the response color). +func (g *Game) LogResponse(speaker, text string) { + g.appendLog(LogMessage{Speaker: speaker, Text: text, Kind: LogResponse}) +} + +// LogSystem appends a system/meta line (rendered with the response color). +func (g *Game) LogSystem(text string) { + g.appendLog(LogMessage{Text: text, Kind: LogSystem}) +} + +func (g *Game) appendLog(m LogMessage) { + g.messages = append(g.messages, m) + if g.MaxLogLines > 0 && len(g.messages) > g.MaxLogLines { + g.messages = g.messages[len(g.messages)-g.MaxLogLines:] + } +} + +// Messages returns the current log buffer (read-only). +func (g *Game) Messages() []LogMessage { return g.messages } + +// CharacterInScene reports whether a registered character is listed as +// an actor in the current scene. Used by the CharacterPanel widget so +// it auto-hides when the character isn't in view. +func (g *Game) CharacterInScene(name string) bool { + if g.currentScene == "" { + return false + } + s := g.SceneManager.MustGet(g.currentScene) + for _, a := range s.Actors { + if a.CharacterName == name { + return true + } + } + return false +} + // DrawText is a thin wrapper that lets widgets accept a Theme-supplied color // today and switch to text/v2 later without changing call sites. func (g *Game) DrawText(dst *ebiten.Image, s string, x, y int, c color.Color) { diff --git a/pncdsl/scene.def.go b/pncdsl/scene.def.go index 688c532..757a89a 100644 --- a/pncdsl/scene.def.go +++ b/pncdsl/scene.def.go @@ -2,6 +2,7 @@ package pncdsl type Scene struct { Name string + Title string // optional human-readable title for the TopBar widget Background string // Asset.Name Music string // Asset.Name (optional) Hotspots []Hotspot diff --git a/pncdsl/ui.character_panel.go b/pncdsl/ui.character_panel.go new file mode 100644 index 0000000..54be89b --- /dev/null +++ b/pncdsl/ui.character_panel.go @@ -0,0 +1,105 @@ +package pncdsl + +import ( + "fmt" + "image/color" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" +) + +// CharStat is one (Label, VarKey) row rendered inside a CharacterPanel. +// VarKey is read from g.State.Var; the value is fmt.Sprint'd verbatim. +type CharStat struct { + Label string + VarKey string +} + +// CharacterPanel is a small floating panel that shows portrait + stats for +// one registered character. The panel auto-hides when the character isn't +// listed as an actor in the current scene, so the same panel registration +// works across scenes. +type CharacterPanel struct { + Name string + Bounds Rectangle + Character string // Character.Name to display + Title string // "PLAYER", "NPC", role badge + Stats []CharStat +} + +func (c *CharacterPanel) GetName() string { return c.Name } +func (c *CharacterPanel) Tick(ctx *UICtx) {} + +func (c *CharacterPanel) Draw(dst *ebiten.Image, ctx *UICtx) { + g := ctx.Game + if c.Character == "" || !g.CharacterInScene(c.Character) { + return + } + th := g.Theme() + b := c.Bounds + + vector.DrawFilledRect(dst, float32(b.X), float32(b.Y), float32(b.W), float32(b.H), th.CharacterPanelBG, false) + vector.StrokeRect(dst, float32(b.X), float32(b.Y), float32(b.W), float32(b.H), 1, th.CharacterPanelBorder, false) + + // portrait box on the left + portraitW := 22.0 + px := b.X + 2 + py := b.Y + 2 + drawCharacterPortrait(dst, g, c.Character, px, py, portraitW, b.H-4) + + // text column + tx := int(b.X + portraitW + 6) + ty := int(b.Y) + 1 + + if c.Title != "" { + g.DrawText(dst, c.Title, tx, ty, th.CharacterPanelTitle) + ty += 10 + } + + if ch, ok := g.CharacterManager.Get(c.Character); ok && ch.Name != "" { + g.DrawText(dst, ch.Name, tx, ty, th.StatusText) + ty += 10 + } + + for _, st := range c.Stats { + v := g.State.Var(st.VarKey) + line := fmt.Sprintf("%s: %v", st.Label, v) + g.DrawText(dst, line, tx, ty, th.StatusText) + ty += 10 + } +} + +// BlocksClickAt — the panel sits on top of the scene; clicks inside it +// shouldn't trigger hotspots underneath. +func (c *CharacterPanel) BlocksClickAt(p Point) bool { + return c.Bounds.Contains(p) +} + +// drawCharacterPortrait renders a tiny version of the character's +// placeholder shape (or sprite if available) inside the panel. +func drawCharacterPortrait(dst *ebiten.Image, g *Game, name string, x, y, w, h float64) { + c, ok := g.CharacterManager.Get(name) + if !ok { + return + } + if c.Sprite != "" && !g.loaded.isPlaceholder(c.Sprite) { + img := g.loaded.image(g.AssetManager, c.Sprite) + sw, sh := img.Bounds().Dx(), img.Bounds().Dy() + if sw > 0 && sh > 0 { + op := &ebiten.DrawImageOptions{} + op.GeoM.Scale(w/float64(sw), h/float64(sh)) + op.GeoM.Translate(x, y) + dst.DrawImage(img, op) + return + } + } + bodyCol := color.RGBA{200, 200, 200, 255} + if rgba, ok := c.SpeechColor.(color.RGBA); ok { + bodyCol = rgba + } + if c.W > c.H && c.W > 0 { + drawQuadrupedPlaceholder(dst, x, y, w, h, bodyCol) + } else { + drawHumanoidPlaceholder(dst, x, y, w, h, bodyCol) + } +} diff --git a/pncdsl/ui.chat_log.go b/pncdsl/ui.chat_log.go new file mode 100644 index 0000000..52f15e7 --- /dev/null +++ b/pncdsl/ui.chat_log.go @@ -0,0 +1,91 @@ +package pncdsl + +import ( + "image/color" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" +) + +// ChatLog renders the in-game message log (player commands + in-world +// replies) as a scrolling box. The newest message is at the bottom; older +// messages scroll up out of view as the buffer fills. +type ChatLog struct { + Name string + Bounds Rectangle + LineHeight int + Padding int + // ShowBorder draws a thin border around the box. + ShowBorder bool +} + +func (c *ChatLog) GetName() string { return c.Name } +func (c *ChatLog) Tick(ctx *UICtx) {} + +func (c *ChatLog) Draw(dst *ebiten.Image, ctx *UICtx) { + g := ctx.Game + th := g.Theme() + 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 + } + + vector.DrawFilledRect(dst, float32(b.X), float32(b.Y), float32(b.W), float32(b.H), th.ChatLogBG, false) + if c.ShowBorder { + vector.StrokeRect(dst, float32(b.X), float32(b.Y), float32(b.W), float32(b.H), 1, th.CharacterPanelBorder, false) + } + + msgs := g.Messages() + maxLines := int((b.H - float64(2*pad)) / float64(lh)) + if maxLines <= 0 { + return + } + + // Wrap each message; collect rendered lines (color + text). + type line struct { + text string + col color.Color + } + wrapW := int(b.W) - pad*2 + var rendered []line + for _, m := range msgs { + var col color.Color + switch m.Kind { + case LogAction: + col = th.ChatLogPrompt + case LogResponse: + col = th.ChatLogResponse + default: + col = th.ChatLogSystem + } + txt := m.Text + if m.Kind == LogResponse && m.Speaker != "" { + txt = m.Speaker + ": " + m.Text + } + for _, w := range wrapText(txt, wrapW) { + rendered = append(rendered, line{text: w, col: col}) + } + } + + // Show the LAST maxLines wrapped lines. + 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) + } +} + +// BlocksClickAt — the log is non-interactive, but it sits over the bottom +// of the scene; treat it as opaque so RadialVerbs doesn't pop up over it. +func (c *ChatLog) BlocksClickAt(p Point) bool { return c.Bounds.Contains(p) } diff --git a/pncdsl/ui.defaults.go b/pncdsl/ui.defaults.go index 7f508f6..fd10e76 100644 --- a/pncdsl/ui.defaults.go +++ b/pncdsl/ui.defaults.go @@ -29,6 +29,74 @@ func RegisterDefaultUI(g *Game) { g.UIManager.Register(&Cursor{Name: "cursor"}) } +// RegisterRichUI installs a "story-rich" HUD that matches the layout of +// the screenshot used as the reference: top bar with title/score/time, +// floating character panels for the player and a single NPC, a chat-log +// strip at the bottom-left, a tiny inventory at the bottom-right and a +// permanent verb-wheel on the right edge. +// +// The HUD assumes the default 320×200 internal resolution and that +// PlayerName / NPCName correspond to registered characters. Pass "" for +// NPCName to skip the NPC panel. +func RegisterRichUI(g *Game, playerName, npcName string) { + g.UIManager.Register(&TopBar{ + Name: "topbar", + Height: 12, + ScoreVar: "score", ScoreMax: 100, + TimeVar: "time", + }) + g.UIManager.Register(&HotspotDebug{Name: "hotspot_debug"}) + if playerName != "" { + g.UIManager.Register(&CharacterPanel{ + Name: "panel_player", + Bounds: Rect(2, 14, 92, 36), + Character: playerName, + Title: "PLAYER", + Stats: []CharStat{ + {Label: "State", VarKey: playerName + ".state"}, + {Label: "Mood", VarKey: playerName + ".mood"}, + }, + }) + } + if npcName != "" { + g.UIManager.Register(&CharacterPanel{ + Name: "panel_npc", + Bounds: Rect(180, 14, 92, 36), + Character: npcName, + Title: "NPC", + Stats: []CharStat{ + {Label: "State", VarKey: npcName + ".state"}, + {Label: "Mood", VarKey: npcName + ".mood"}, + }, + }) + } + g.UIManager.Register(&InventoryBar{ + Name: "inventory", + Origin: Point{X: 4, Y: 188}, + Slots: 8, + Cols: 8, + SlotSize: 10, + Gap: 1, + }) + g.UIManager.Register(&ChatLog{ + Name: "chat", + Bounds: Rect(2, 148, 280, 38), + LineHeight: 9, + Padding: 3, + ShowBorder: true, + }) + g.UIManager.Register(&RadialVerbs{ + Name: "verbs", + AlwaysVisible: true, + Center: Point{X: 290, Y: 90}, + Radius: 28, + }) + g.UIManager.Register(&SpeechBubble{Name: "speech", MaxWidth: 200, Padding: 3, OffsetY: 4, FallbackY: 18}) + g.UIManager.Register(&DialogBox{Name: "dialog", Bounds: Rect(0, 90, float64(g.Width), 56), LineHeight: 12, Padding: 6}) + g.UIManager.Register(&EndCard{Name: "endcard"}) + g.UIManager.Register(&Cursor{Name: "cursor"}) +} + // RegisterRadialVerbUI installs an alternative HUD that swaps the // permanent verb-bar for a verb-coin (right-click radial menu). Inventory, // dialog, speech and cursor stay the same. diff --git a/pncdsl/ui.theme.go b/pncdsl/ui.theme.go index 5dee324..cadd7f3 100644 --- a/pncdsl/ui.theme.go +++ b/pncdsl/ui.theme.go @@ -38,6 +38,20 @@ type Theme struct { // SceneBackdrop is painted before the scene background. Useful for // letterboxing or filling areas not covered by a smaller backdrop. SceneBackdrop color.Color + + // Rich-UI widgets (TopBar, CharacterPanel, ChatLog). + TopBarBG color.Color + TopBarText color.Color + TopBarAccent color.Color // score / highlighted number color + + ChatLogBG color.Color + ChatLogPrompt color.Color // "> look at door" lines + ChatLogResponse color.Color // in-world reply lines + ChatLogSystem color.Color // system / meta lines + + CharacterPanelBG color.Color + CharacterPanelBorder color.Color + CharacterPanelTitle color.Color // "PLAYER" / "NPC" badge color } func (t Theme) GetName() string { return t.Name } diff --git a/pncdsl/ui.theme_presets.go b/pncdsl/ui.theme_presets.go index dc11934..2a7e889 100644 --- a/pncdsl/ui.theme_presets.go +++ b/pncdsl/ui.theme_presets.go @@ -35,6 +35,17 @@ func classicScummTheme() Theme { CursorColor: color.RGBA{255, 255, 255, 255}, HotspotOutline: color.RGBA{255, 255, 0, 200}, SceneBackdrop: color.RGBA{0, 0, 0, 255}, + + TopBarBG: color.RGBA{15, 12, 22, 255}, + TopBarText: color.RGBA{220, 220, 230, 255}, + TopBarAccent: color.RGBA{255, 210, 110, 255}, + ChatLogBG: color.RGBA{12, 10, 18, 230}, + ChatLogPrompt: color.RGBA{255, 210, 110, 255}, + ChatLogResponse: color.RGBA{220, 220, 230, 255}, + ChatLogSystem: color.RGBA{160, 160, 180, 255}, + CharacterPanelBG: color.RGBA{10, 8, 14, 170}, + CharacterPanelBorder: color.RGBA{120, 100, 60, 255}, + CharacterPanelTitle: color.RGBA{255, 210, 110, 255}, } } @@ -62,6 +73,17 @@ func sierraCoinTheme() Theme { CursorColor: color.RGBA{255, 200, 140, 255}, HotspotOutline: color.RGBA{200, 140, 255, 180}, SceneBackdrop: color.RGBA{0, 0, 0, 255}, + + TopBarBG: color.RGBA{12, 8, 24, 255}, + TopBarText: color.RGBA{210, 200, 240, 255}, + TopBarAccent: color.RGBA{255, 180, 110, 255}, + ChatLogBG: color.RGBA{10, 8, 20, 235}, + ChatLogPrompt: color.RGBA{255, 180, 110, 255}, + ChatLogResponse: color.RGBA{210, 200, 240, 255}, + ChatLogSystem: color.RGBA{150, 130, 190, 255}, + CharacterPanelBG: color.RGBA{8, 6, 18, 180}, + CharacterPanelBorder: color.RGBA{120, 60, 180, 255}, + CharacterPanelTitle: color.RGBA{255, 180, 110, 255}, } } @@ -91,6 +113,17 @@ func paperNotebookTheme() Theme { CursorColor: ink, HotspotOutline: color.RGBA{120, 70, 30, 200}, SceneBackdrop: cream, + + TopBarBG: color.RGBA{220, 205, 170, 255}, + TopBarText: ink, + TopBarAccent: color.RGBA{120, 70, 30, 255}, + ChatLogBG: color.RGBA{250, 240, 215, 230}, + ChatLogPrompt: color.RGBA{120, 70, 30, 255}, + ChatLogResponse: ink, + ChatLogSystem: color.RGBA{120, 110, 90, 255}, + CharacterPanelBG: color.RGBA{250, 240, 215, 200}, + CharacterPanelBorder: ink, + CharacterPanelTitle: color.RGBA{120, 70, 30, 255}, } } @@ -121,5 +154,16 @@ func terminalGreenTheme() Theme { CursorColor: green, HotspotOutline: dim, SceneBackdrop: black, + + TopBarBG: color.RGBA{0, 18, 0, 255}, + TopBarText: green, + TopBarAccent: color.RGBA{220, 220, 100, 255}, + ChatLogBG: color.RGBA{0, 12, 0, 230}, + ChatLogPrompt: color.RGBA{220, 220, 100, 255}, + ChatLogResponse: green, + ChatLogSystem: dim, + CharacterPanelBG: color.RGBA{0, 10, 0, 200}, + CharacterPanelBorder: green, + CharacterPanelTitle: color.RGBA{220, 220, 100, 255}, } } diff --git a/pncdsl/ui.top_bar.go b/pncdsl/ui.top_bar.go new file mode 100644 index 0000000..3e9bbfa --- /dev/null +++ b/pncdsl/ui.top_bar.go @@ -0,0 +1,81 @@ +package pncdsl + +import ( + "fmt" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" +) + +// TopBar is a thin strip across the top of the screen with three sections: +// scene title on the left, a score number in the center, and a day/time +// string on the right. Empty fields are skipped. +type TopBar struct { + Name string + Height int + + // LeftText overrides the auto-discovered scene Title/Name. + LeftText string + // ScoreVar is a State Var key whose value is fmt-printed as the + // center score; empty = no score shown. + ScoreVar string + ScoreMax int // when >0, rendered as "Score: X/MAX" + + // TimeVar is a State Var key whose value is printed at the right + // edge (e.g. "Day 2 - 14:32"); empty = nothing on the right. + TimeVar string +} + +func (t *TopBar) GetName() string { return t.Name } +func (t *TopBar) Tick(ctx *UICtx) {} + +func (t *TopBar) Draw(dst *ebiten.Image, ctx *UICtx) { + g := ctx.Game + th := g.Theme() + h := t.Height + if h <= 0 { + h = 12 + } + vector.DrawFilledRect(dst, 0, 0, float32(g.Width), float32(h), th.TopBarBG, false) + + left := t.LeftText + if left == "" && g.currentScene != "" { + s := g.SceneManager.MustGet(g.currentScene) + if s.Title != "" { + left = s.Title + } else { + left = s.Name + } + } + if left != "" { + g.DrawText(dst, left, 4, -1, th.TopBarText) + } + + if t.ScoreVar != "" { + var sc string + v := g.State.Var(t.ScoreVar) + if t.ScoreMax > 0 { + sc = fmt.Sprintf("Score: %v/%d", v, t.ScoreMax) + } else { + sc = fmt.Sprintf("Score: %v", v) + } + x := (g.Width - textWidth(sc)) / 2 + g.DrawText(dst, sc, x, -1, th.TopBarAccent) + } + + if t.TimeVar != "" { + tm := fmt.Sprint(g.State.Var(t.TimeVar)) + x := g.Width - textWidth(tm) - 4 + g.DrawText(dst, tm, x, -1, th.TopBarText) + } +} + +// BlocksClickAt — the TopBar swallows clicks so the radial menu doesn't +// pop up under it. +func (t *TopBar) BlocksClickAt(p Point) bool { + h := t.Height + if h <= 0 { + h = 12 + } + return p.Y < float64(h) +} diff --git a/pncdsl/ui.verb_radial.go b/pncdsl/ui.verb_radial.go index c8f891e..abd5779 100644 --- a/pncdsl/ui.verb_radial.go +++ b/pncdsl/ui.verb_radial.go @@ -19,6 +19,12 @@ type RadialVerbs struct { Trigger MouseButton Radius float64 + // AlwaysVisible turns the widget into a permanent verb-wheel fixed at + // Center. The trigger button is ignored in this mode; left-click picks + // the verb slice under the cursor. + AlwaysVisible bool + Center Point + // runtime visible bool center Point @@ -32,6 +38,22 @@ func (r *RadialVerbs) Tick(ctx *UICtx) { r.Radius = 40 } + if r.AlwaysVisible { + r.visible = true + r.center = r.Center + if !g.Input.LeftClicked() { + return + } + idx := r.hitTest(g, g.Input.Point()) + if idx < 0 { + return + } + g.Input.ConsumeLeft() + names := g.VerbManager.Names() + g.SetSelectedVerb(names[idx]) + return + } + if !r.visible { if !r.triggerPressed(g) { return @@ -129,6 +151,23 @@ func (r *RadialVerbs) hitTest(g *Game, p Point) int { return idx } +// BlocksClickAt — when the wheel is open (or always visible), clicks on +// it should not pass through to the scene underneath. +func (r *RadialVerbs) BlocksClickAt(p Point) bool { + if !r.visible && !r.AlwaysVisible { + return false + } + c := r.center + if r.AlwaysVisible { + c = r.Center + } + dx := p.X - c.X + dy := p.Y - c.Y + d := dx*dx + dy*dy + radius := r.Radius * 1.4 + return d <= radius*radius +} + func (r *RadialVerbs) Draw(dst *ebiten.Image, ctx *UICtx) { if !r.visible { return