new theme
This commit is contained in:
@@ -25,10 +25,20 @@ func Build() *p.Game {
|
|||||||
defineBedroom(g)
|
defineBedroom(g)
|
||||||
defineKitchen(g)
|
defineKitchen(g)
|
||||||
|
|
||||||
// SCUMM-style HUD. Swap for p.RegisterRadialVerbUI(g) to try the
|
// Story-rich HUD: top bar + character panels + chat log + permanent
|
||||||
// verb-coin variant, or build a fully custom widget set by calling
|
// verb wheel + small inventory. Swap for p.RegisterDefaultUI(g) to
|
||||||
// g.UIManager.Register(...) directly.
|
// get the simpler SCUMM-style layout, or p.RegisterRadialVerbUI(g)
|
||||||
p.RegisterDefaultUI(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"))
|
g.StartAt("bedroom").OnStart(p.RunScript("intro"))
|
||||||
return g
|
return g
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import p "pncdsl/pncdsl"
|
|||||||
func defineBedroom(g *p.Game) {
|
func defineBedroom(g *p.Game) {
|
||||||
g.SceneManager.Register(p.Scene{
|
g.SceneManager.Register(p.Scene{
|
||||||
Name: "bedroom",
|
Name: "bedroom",
|
||||||
|
Title: "Hálószoba",
|
||||||
Background: "bg/bedroom",
|
Background: "bg/bedroom",
|
||||||
Music: "mus/wakeup",
|
Music: "mus/wakeup",
|
||||||
Actors: []p.SceneActor{
|
Actors: []p.SceneActor{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import p "pncdsl/pncdsl"
|
|||||||
func defineKitchen(g *p.Game) {
|
func defineKitchen(g *p.Game) {
|
||||||
g.SceneManager.Register(p.Scene{
|
g.SceneManager.Register(p.Scene{
|
||||||
Name: "kitchen",
|
Name: "kitchen",
|
||||||
|
Title: "Konyha",
|
||||||
Background: "bg/kitchen",
|
Background: "bg/kitchen",
|
||||||
Music: "mus/calm",
|
Music: "mus/calm",
|
||||||
Actors: []p.SceneActor{
|
Actors: []p.SceneActor{
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ func defineIntroScript(g *p.Game) {
|
|||||||
p.Say("player", "Brr, hideg van."),
|
p.Say("player", "Brr, hideg van."),
|
||||||
p.Say("player", "Egy kávé kéne, mielőtt szétszakad a fejem."),
|
p.Say("player", "Egy kávé kéne, mielőtt szétszakad a fejem."),
|
||||||
p.Walk("player", p.Point{X: 200, Y: 145}),
|
p.Walk("player", p.Point{X: 200, Y: 145}),
|
||||||
|
p.SetVar("player.mood", "Eltökélt"),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ func defineVictoryScript(g *p.Game) {
|
|||||||
p.Wait(0.6),
|
p.Wait(0.6),
|
||||||
p.Say("cat", "Mióóóóu. *boldog macska*"),
|
p.Say("cat", "Mióóóóu. *boldog macska*"),
|
||||||
p.Say("player", "Reggeli kávé: megmentve."),
|
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!"),
|
p.ShowEnd("Vége — kösz, hogy játszottál!"),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ func (r *sayRunner) Tick(ctx *Ctx) Status {
|
|||||||
// duration scales with text length, with a 1.2s floor
|
// duration scales with text length, with a 1.2s floor
|
||||||
r.duration = 1.2 + float64(len(r.spec.text))*0.05
|
r.duration = 1.2 + float64(len(r.spec.text))*0.05
|
||||||
ctx.Game.SetSpeech(r.spec.speaker, r.spec.text)
|
ctx.Game.SetSpeech(r.spec.speaker, r.spec.text)
|
||||||
|
ctx.Game.LogResponse(r.spec.speaker, r.spec.text)
|
||||||
}
|
}
|
||||||
r.elapsed += ctx.DT
|
r.elapsed += ctx.DT
|
||||||
// skip on click
|
// skip on click
|
||||||
|
|||||||
@@ -97,9 +97,14 @@ func (e *engine) handleSceneInput() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
sel := g.Inventory.Selected()
|
sel := g.Inventory.Selected()
|
||||||
|
targetLabel := h.Label
|
||||||
|
if targetLabel == "" {
|
||||||
|
targetLabel = h.Name
|
||||||
|
}
|
||||||
if sel != "" {
|
if sel != "" {
|
||||||
if h.OnUseWith != nil {
|
if h.OnUseWith != nil {
|
||||||
if a, ok := h.OnUseWith[sel]; ok && a != nil {
|
if a, ok := h.OnUseWith[sel]; ok && a != nil {
|
||||||
|
g.LogAction("> " + verbLabel(g, "use") + " " + sel + " ezen: " + targetLabel)
|
||||||
g.queueAction(a, "useWith hotspot")
|
g.queueAction(a, "useWith hotspot")
|
||||||
g.Inventory.Select("")
|
g.Inventory.Select("")
|
||||||
return
|
return
|
||||||
@@ -107,6 +112,7 @@ func (e *engine) handleSceneInput() {
|
|||||||
}
|
}
|
||||||
if it, ok := g.ItemManager.Get(sel); ok && it.OnUseWith != nil {
|
if it, ok := g.ItemManager.Get(sel); ok && it.OnUseWith != nil {
|
||||||
if a, ok := it.OnUseWith[h.Name]; ok && a != 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.queueAction(a, "useWith item")
|
||||||
g.Inventory.Select("")
|
g.Inventory.Select("")
|
||||||
return
|
return
|
||||||
@@ -126,9 +132,17 @@ func (e *engine) handleSceneInput() {
|
|||||||
g.FlashLine("Semmi említésre méltó.")
|
g.FlashLine("Semmi említésre méltó.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
g.LogAction("> " + verbLabel(g, g.SelectedVerb()) + " " + targetLabel)
|
||||||
g.queueAction(a, "hotspot "+g.SelectedVerb()+" "+h.Name)
|
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 {
|
func (e *engine) hotspotAt(p Point) *Hotspot {
|
||||||
g := e.g
|
g := e.g
|
||||||
if g.currentScene == "" {
|
if g.currentScene == "" {
|
||||||
|
|||||||
@@ -51,6 +51,26 @@ type Game struct {
|
|||||||
speech speechState
|
speech speechState
|
||||||
dialog *runtimeDialog
|
dialog *runtimeDialog
|
||||||
activeDialog string
|
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.
|
// speechState is what the SpeechBubble widget renders. Mutated by Say.
|
||||||
@@ -130,6 +150,48 @@ func (g *Game) FlashLine(text string) {
|
|||||||
g.flashTimer = 2.0
|
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
|
// DrawText is a thin wrapper that lets widgets accept a Theme-supplied color
|
||||||
// today and switch to text/v2 later without changing call sites.
|
// 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) {
|
func (g *Game) DrawText(dst *ebiten.Image, s string, x, y int, c color.Color) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package pncdsl
|
|||||||
|
|
||||||
type Scene struct {
|
type Scene struct {
|
||||||
Name string
|
Name string
|
||||||
|
Title string // optional human-readable title for the TopBar widget
|
||||||
Background string // Asset.Name
|
Background string // Asset.Name
|
||||||
Music string // Asset.Name (optional)
|
Music string // Asset.Name (optional)
|
||||||
Hotspots []Hotspot
|
Hotspots []Hotspot
|
||||||
|
|||||||
105
pncdsl/ui.character_panel.go
Normal file
105
pncdsl/ui.character_panel.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
91
pncdsl/ui.chat_log.go
Normal file
91
pncdsl/ui.chat_log.go
Normal file
@@ -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) }
|
||||||
@@ -29,6 +29,74 @@ func RegisterDefaultUI(g *Game) {
|
|||||||
g.UIManager.Register(&Cursor{Name: "cursor"})
|
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
|
// RegisterRadialVerbUI installs an alternative HUD that swaps the
|
||||||
// permanent verb-bar for a verb-coin (right-click radial menu). Inventory,
|
// permanent verb-bar for a verb-coin (right-click radial menu). Inventory,
|
||||||
// dialog, speech and cursor stay the same.
|
// dialog, speech and cursor stay the same.
|
||||||
|
|||||||
@@ -38,6 +38,20 @@ type Theme struct {
|
|||||||
// SceneBackdrop is painted before the scene background. Useful for
|
// SceneBackdrop is painted before the scene background. Useful for
|
||||||
// letterboxing or filling areas not covered by a smaller backdrop.
|
// letterboxing or filling areas not covered by a smaller backdrop.
|
||||||
SceneBackdrop color.Color
|
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 }
|
func (t Theme) GetName() string { return t.Name }
|
||||||
|
|||||||
@@ -35,6 +35,17 @@ func classicScummTheme() Theme {
|
|||||||
CursorColor: color.RGBA{255, 255, 255, 255},
|
CursorColor: color.RGBA{255, 255, 255, 255},
|
||||||
HotspotOutline: color.RGBA{255, 255, 0, 200},
|
HotspotOutline: color.RGBA{255, 255, 0, 200},
|
||||||
SceneBackdrop: color.RGBA{0, 0, 0, 255},
|
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},
|
CursorColor: color.RGBA{255, 200, 140, 255},
|
||||||
HotspotOutline: color.RGBA{200, 140, 255, 180},
|
HotspotOutline: color.RGBA{200, 140, 255, 180},
|
||||||
SceneBackdrop: color.RGBA{0, 0, 0, 255},
|
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,
|
CursorColor: ink,
|
||||||
HotspotOutline: color.RGBA{120, 70, 30, 200},
|
HotspotOutline: color.RGBA{120, 70, 30, 200},
|
||||||
SceneBackdrop: cream,
|
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,
|
CursorColor: green,
|
||||||
HotspotOutline: dim,
|
HotspotOutline: dim,
|
||||||
SceneBackdrop: black,
|
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},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
81
pncdsl/ui.top_bar.go
Normal file
81
pncdsl/ui.top_bar.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -19,6 +19,12 @@ type RadialVerbs struct {
|
|||||||
Trigger MouseButton
|
Trigger MouseButton
|
||||||
Radius float64
|
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
|
// runtime
|
||||||
visible bool
|
visible bool
|
||||||
center Point
|
center Point
|
||||||
@@ -32,6 +38,22 @@ func (r *RadialVerbs) Tick(ctx *UICtx) {
|
|||||||
r.Radius = 40
|
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.visible {
|
||||||
if !r.triggerPressed(g) {
|
if !r.triggerPressed(g) {
|
||||||
return
|
return
|
||||||
@@ -129,6 +151,23 @@ func (r *RadialVerbs) hitTest(g *Game, p Point) int {
|
|||||||
return idx
|
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) {
|
func (r *RadialVerbs) Draw(dst *ebiten.Image, ctx *UICtx) {
|
||||||
if !r.visible {
|
if !r.visible {
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user