new theme

This commit is contained in:
2026-05-25 20:49:41 +02:00
parent ec6761693c
commit 2eef22a76b
16 changed files with 540 additions and 4 deletions

View File

@@ -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

View File

@@ -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{

View File

@@ -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{

View File

@@ -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"),
),
})
}

View File

@@ -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!"),
),
})

View File

@@ -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

View File

@@ -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 == "" {

View File

@@ -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) {

View File

@@ -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

View 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
View 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) }

View File

@@ -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.

View File

@@ -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 }

View File

@@ -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},
}
}

81
pncdsl/ui.top_bar.go Normal file
View 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)
}

View File

@@ -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