widgets
This commit is contained in:
@@ -224,15 +224,15 @@ func (r *sayRunner) Tick(ctx *Ctx) Status {
|
||||
r.started = true
|
||||
// duration scales with text length, with a 1.2s floor
|
||||
r.duration = 1.2 + float64(len(r.spec.text))*0.05
|
||||
ctx.Game.UI.SetSpeech(r.spec.speaker, r.spec.text)
|
||||
ctx.Game.SetSpeech(r.spec.speaker, r.spec.text)
|
||||
}
|
||||
r.elapsed += ctx.DT
|
||||
// skip on click
|
||||
if ctx.Game.input.consumedClick() {
|
||||
if ctx.Game.Input.consumedClick() {
|
||||
r.elapsed = r.duration
|
||||
}
|
||||
if r.elapsed >= r.duration {
|
||||
ctx.Game.UI.ClearSpeech()
|
||||
ctx.Game.ClearSpeech()
|
||||
return StatusDone
|
||||
}
|
||||
return StatusRunning
|
||||
@@ -279,7 +279,7 @@ func (a *requireItemAction) Tick(ctx *Ctx) Status {
|
||||
if ctx.Game.Inventory.Has(a.item) {
|
||||
return StatusDone
|
||||
}
|
||||
ctx.Game.UI.FlashLine("Ehhez kell egy " + a.item + ".")
|
||||
ctx.Game.FlashLine("Ehhez kell egy " + a.item + ".")
|
||||
return StatusFailed
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -10,7 +10,11 @@ func Run(g *Game) error {
|
||||
if err := g.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
g.UI.rebuildVerbButtons()
|
||||
// If the domain didn't register any widgets, fall back to the SCUMM
|
||||
// preset so the game is still playable.
|
||||
if g.UIManager.Len() == 0 {
|
||||
RegisterDefaultUI(g)
|
||||
}
|
||||
|
||||
// Initial scene goes in directly (no transition) so OnEnter / OnStart
|
||||
// run cleanly as one composed sequence on the first script tick.
|
||||
|
||||
+58
-140
@@ -7,8 +7,10 @@ import (
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
)
|
||||
|
||||
// engine is the ebiten.Game adapter. It owns the per-frame Update/Draw flow
|
||||
// and delegates everything stateful to *Game.
|
||||
// engine is the ebiten.Game adapter. Each frame it polls input, advances
|
||||
// the active script (if any), then lets every registered Widget tick
|
||||
// (reverse registration order, so the top widget claims input first).
|
||||
// Whatever survives is offered to the scene as a hotspot click.
|
||||
type engine struct {
|
||||
g *Game
|
||||
}
|
||||
@@ -20,29 +22,24 @@ func (e *engine) Layout(outsideWidth, outsideHeight int) (int, int) {
|
||||
func (e *engine) Update() error {
|
||||
g := e.g
|
||||
dt := 1.0 / 60.0
|
||||
g.input.poll()
|
||||
g.UI.tick(dt)
|
||||
|
||||
g.Input.poll()
|
||||
g.transition.update(dt)
|
||||
|
||||
if g.transition.active && g.transition.out {
|
||||
// during fade-out, freeze input
|
||||
return nil
|
||||
}
|
||||
|
||||
if g.UI.endCard != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Active script consumes the frame.
|
||||
if g.scriptRunner != nil {
|
||||
ctx := g.scriptCtx
|
||||
ctx.DT = dt
|
||||
// keep Scene reference fresh in case the script changed it
|
||||
if g.currentScene != "" {
|
||||
s := g.SceneManager.MustGet(g.currentScene)
|
||||
ctx.Scene = &s
|
||||
}
|
||||
s := g.scriptRunner.Tick(ctx)
|
||||
if s != StatusRunning {
|
||||
st := g.scriptRunner.Tick(ctx)
|
||||
if st != StatusRunning {
|
||||
g.scriptRunner = nil
|
||||
g.scriptCtx = nil
|
||||
}
|
||||
@@ -50,106 +47,57 @@ func (e *engine) Update() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// dialog click handling
|
||||
if g.dialog != nil {
|
||||
if g.input.LeftClicked() {
|
||||
g.input.ConsumeLeft()
|
||||
ctx := g.makeCtx()
|
||||
picked := g.dialog.handleClick(ctx, g.input.Point())
|
||||
if picked != nil {
|
||||
// record once-tracking
|
||||
if picked.Once {
|
||||
g.State.NoteTalked(g.dialog.node.Name + ":" + picked.Text)
|
||||
}
|
||||
// run the choice's actions as a synthetic script
|
||||
if len(picked.Actions) > 0 {
|
||||
g.queueAction(Seq(picked.Actions...), "choice")
|
||||
}
|
||||
}
|
||||
}
|
||||
g.tickCharacters(dt)
|
||||
return nil
|
||||
uictx := &UICtx{Game: g, DT: dt}
|
||||
|
||||
// Clear per-frame transient state that widgets / scene-resolution write.
|
||||
g.SetHoverLabel("")
|
||||
|
||||
// Top-down input: the widget drawn last (= registered last) gets the
|
||||
// click first, then the next-to-last, etc. A widget signals "I took it"
|
||||
// via g.Input.ConsumeLeft / ConsumeRight.
|
||||
for _, w := range reversedWidgets(g.UIManager) {
|
||||
w.Tick(uictx)
|
||||
}
|
||||
|
||||
// free interaction
|
||||
e.handleFreeInput()
|
||||
// Anything still unconsumed is a scene-world interaction.
|
||||
e.handleSceneInput()
|
||||
g.tickCharacters(dt)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *engine) handleFreeInput() {
|
||||
// handleSceneInput is the legacy "click on hotspot with active verb" flow,
|
||||
// running only when no widget has claimed the input.
|
||||
func (e *engine) handleSceneInput() {
|
||||
g := e.g
|
||||
g.UI.rebuildVerbButtons()
|
||||
mp := g.input.Point()
|
||||
|
||||
// hover label resolution
|
||||
g.UI.hoverLabel = ""
|
||||
if mp.Y < uiPanelY-4 {
|
||||
if h := e.hotspotAt(mp); h != nil {
|
||||
if h.Label != "" {
|
||||
g.UI.hoverLabel = h.Label
|
||||
} else {
|
||||
g.UI.hoverLabel = h.Name
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// hovering UI: show item description when over an inv slot
|
||||
if hit := g.UI.hitTest(mp); len(hit) > 4 && hit[:4] == "inv:" {
|
||||
name := hit[4:]
|
||||
if it, ok := g.ItemManager.Get(name); ok && it.Description != "" {
|
||||
g.UI.hoverLabel = it.Description
|
||||
} else {
|
||||
g.UI.hoverLabel = name
|
||||
}
|
||||
mp := g.Input.Point()
|
||||
// hover label resolution: pointer over a hotspot in the current scene
|
||||
if h := e.hotspotAt(mp); h != nil {
|
||||
if h.Label != "" {
|
||||
g.SetHoverLabel(h.Label)
|
||||
} else {
|
||||
g.SetHoverLabel(h.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if g.input.RightClicked() {
|
||||
g.input.ConsumeRight()
|
||||
// right click: deselect item / reset verb
|
||||
if g.Input.RightClicked() {
|
||||
g.Input.ConsumeRight()
|
||||
if g.Inventory.Selected() != "" {
|
||||
g.Inventory.Select("")
|
||||
} else {
|
||||
g.selectedVerb = "look"
|
||||
g.SetSelectedVerb("look")
|
||||
}
|
||||
}
|
||||
|
||||
if !g.input.LeftClicked() {
|
||||
if !g.Input.LeftClicked() {
|
||||
return
|
||||
}
|
||||
|
||||
// UI hits first
|
||||
if hit := g.UI.hitTest(mp); hit != "" {
|
||||
g.input.ConsumeLeft()
|
||||
switch hit[:4] {
|
||||
case "verb":
|
||||
g.selectedVerb = hit[5:]
|
||||
case "inv:":
|
||||
name := hit[4:]
|
||||
if g.selectedVerb == "look" {
|
||||
if it, ok := g.ItemManager.Get(name); ok && it.Description != "" {
|
||||
g.queueAction(Say("player", it.Description), "inv-look")
|
||||
return
|
||||
}
|
||||
}
|
||||
if g.Inventory.Selected() == name {
|
||||
g.Inventory.Select("")
|
||||
} else {
|
||||
g.Inventory.Select(name)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// game-world click
|
||||
g.input.ConsumeLeft()
|
||||
g.Input.ConsumeLeft()
|
||||
h := e.hotspotAt(mp)
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
sel := g.Inventory.Selected()
|
||||
if sel != "" {
|
||||
// use-with: first check hotspot's OnUseWith, then item's
|
||||
if h.OnUseWith != nil {
|
||||
if a, ok := h.OnUseWith[sel]; ok && a != nil {
|
||||
g.queueAction(a, "useWith hotspot")
|
||||
@@ -164,21 +112,21 @@ func (e *engine) handleFreeInput() {
|
||||
return
|
||||
}
|
||||
}
|
||||
g.UI.FlashLine("Nem ehhez.")
|
||||
g.FlashLine("Nem ehhez.")
|
||||
g.Inventory.Select("")
|
||||
return
|
||||
}
|
||||
a := h.handler(g.selectedVerb)
|
||||
a := h.handler(g.SelectedVerb())
|
||||
if a == nil {
|
||||
if v, ok := g.VerbManager.Get(g.selectedVerb); ok && v.Default != nil {
|
||||
if v, ok := g.VerbManager.Get(g.SelectedVerb()); ok && v.Default != nil {
|
||||
a = v.Default
|
||||
}
|
||||
}
|
||||
if a == nil {
|
||||
g.UI.FlashLine("Semmi említésre méltó.")
|
||||
g.FlashLine("Semmi említésre méltó.")
|
||||
return
|
||||
}
|
||||
g.queueAction(a, "hotspot "+g.selectedVerb+" "+h.Name)
|
||||
g.queueAction(a, "hotspot "+g.SelectedVerb()+" "+h.Name)
|
||||
}
|
||||
|
||||
func (e *engine) hotspotAt(p Point) *Hotspot {
|
||||
@@ -198,24 +146,22 @@ func (e *engine) hotspotAt(p Point) *Hotspot {
|
||||
|
||||
func (e *engine) Draw(screen *ebiten.Image) {
|
||||
g := e.g
|
||||
th := g.Theme()
|
||||
|
||||
// optional backdrop (helps when the background image doesn't fill the screen)
|
||||
if th.SceneBackdrop != nil {
|
||||
screen.Fill(th.SceneBackdrop)
|
||||
}
|
||||
|
||||
// scene background
|
||||
if g.currentScene != "" {
|
||||
s := g.SceneManager.MustGet(g.currentScene)
|
||||
img := g.loaded.image(g.AssetManager, s.Background)
|
||||
op := &ebiten.DrawImageOptions{}
|
||||
bw, bh := img.Bounds().Dx(), img.Bounds().Dy()
|
||||
if bw > 0 && bh > 0 {
|
||||
op := &ebiten.DrawImageOptions{}
|
||||
op.GeoM.Scale(float64(g.Width)/float64(bw), float64(g.Height)/float64(bh))
|
||||
}
|
||||
screen.DrawImage(img, op)
|
||||
|
||||
// hotspot debug outlines
|
||||
if DebugLog {
|
||||
for _, h := range s.Hotspots {
|
||||
if r, ok := h.Area.(Rectangle); ok {
|
||||
vector.StrokeRect(screen, float32(r.X), float32(r.Y), float32(r.W), float32(r.H), 1, color.RGBA{255, 255, 0, 200}, false)
|
||||
}
|
||||
}
|
||||
screen.DrawImage(img, op)
|
||||
}
|
||||
} else {
|
||||
screen.Fill(color.RGBA{0, 0, 0, 255})
|
||||
@@ -226,28 +172,16 @@ func (e *engine) Draw(screen *ebiten.Image) {
|
||||
drawCharacter(screen, g, c)
|
||||
}
|
||||
|
||||
// speech bubble
|
||||
g.UI.speech.draw(screen, g)
|
||||
|
||||
// dialog box (if active)
|
||||
if g.dialog != nil {
|
||||
g.dialog.draw(screen, g)
|
||||
} else {
|
||||
g.UI.drawHUD(screen)
|
||||
// widgets in registration order
|
||||
uictx := &UICtx{Game: g, DT: 1.0 / 60.0}
|
||||
for _, w := range orderedWidgets(g.UIManager) {
|
||||
w.Draw(screen, uictx)
|
||||
}
|
||||
|
||||
// transition overlay
|
||||
// transition overlay on top of everything except cursor (cursor is the
|
||||
// last widget, so it has already drawn above the transition? no — the
|
||||
// fade should cover the cursor too; draw it after the widgets).
|
||||
g.transition.draw(screen, g.Width, g.Height)
|
||||
|
||||
// end card
|
||||
if g.UI.endCard != "" {
|
||||
vector.DrawFilledRect(screen, 0, 0, float32(g.Width), float32(g.Height), color.RGBA{0, 0, 0, 230}, false)
|
||||
w := textWidth(g.UI.endCard)
|
||||
drawText(screen, g.UI.endCard, (g.Width-w)/2, g.Height/2-8, color.White)
|
||||
}
|
||||
|
||||
// cursor on top
|
||||
drawCursor(screen, g)
|
||||
}
|
||||
|
||||
func sortedChars(g *Game) []*runtimeChar {
|
||||
@@ -255,7 +189,6 @@ func sortedChars(g *Game) []*runtimeChar {
|
||||
for _, c := range g.chars {
|
||||
out = append(out, c)
|
||||
}
|
||||
// insertion sort by Y (small N)
|
||||
for i := 1; i < len(out); i++ {
|
||||
for j := i; j > 0 && out[j].pos.Y < out[j-1].pos.Y; j-- {
|
||||
out[j], out[j-1] = out[j-1], out[j]
|
||||
@@ -273,8 +206,6 @@ func drawCharacter(dst *ebiten.Image, g *Game, c *runtimeChar) {
|
||||
if h == 0 {
|
||||
h = 56
|
||||
}
|
||||
// Try the real sprite first. Only fall through to the stylized
|
||||
// placeholder when no actual art is on disk.
|
||||
if c.def.Sprite != "" && !g.loaded.isPlaceholder(c.def.Sprite) {
|
||||
img := g.loaded.image(g.AssetManager, c.def.Sprite)
|
||||
sw, sh := img.Bounds().Dx(), img.Bounds().Dy()
|
||||
@@ -286,7 +217,6 @@ func drawCharacter(dst *ebiten.Image, g *Game, c *runtimeChar) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Touch the asset cache so isPlaceholder is populated for later frames.
|
||||
if c.def.Sprite != "" {
|
||||
_ = g.loaded.image(g.AssetManager, c.def.Sprite)
|
||||
}
|
||||
@@ -311,23 +241,19 @@ func drawHumanoidPlaceholder(dst *ebiten.Image, x, y, w, h float64, body color.R
|
||||
headR := h * 0.14
|
||||
headCY := y + headR + 1
|
||||
|
||||
// pants / legs — two narrow strips
|
||||
pantsTop := y + h*0.62
|
||||
pantsH := h - (pantsTop - y) - 1
|
||||
legW := w * 0.36
|
||||
vector.DrawFilledRect(dst, float32(x+w*0.08), float32(pantsTop), float32(legW), float32(pantsH), pants, false)
|
||||
vector.DrawFilledRect(dst, float32(x+w*0.56), float32(pantsTop), float32(legW), float32(pantsH), pants, false)
|
||||
|
||||
// torso — full-width rect, shirt color
|
||||
torsoTop := headCY + headR
|
||||
torsoH := pantsTop - torsoTop
|
||||
vector.DrawFilledRect(dst, float32(x), float32(torsoTop), float32(w), float32(torsoH), body, false)
|
||||
vector.StrokeRect(dst, float32(x), float32(torsoTop), float32(w), float32(torsoH), 1, outline, false)
|
||||
|
||||
// head — circle in skin tone
|
||||
vector.DrawFilledCircle(dst, float32(x+w/2), float32(headCY), float32(headR), skin, true)
|
||||
|
||||
// feet — short dark blobs at the very bottom
|
||||
footH := 2.0
|
||||
vector.DrawFilledRect(dst, float32(x+w*0.05), float32(y+h-footH), float32(legW+2), float32(footH), outline, false)
|
||||
vector.DrawFilledRect(dst, float32(x+w*0.55), float32(y+h-footH), float32(legW+2), float32(footH), outline, false)
|
||||
@@ -335,34 +261,26 @@ func drawHumanoidPlaceholder(dst *ebiten.Image, x, y, w, h float64, body color.R
|
||||
|
||||
func drawQuadrupedPlaceholder(dst *ebiten.Image, x, y, w, h float64, body color.RGBA) {
|
||||
outline := color.RGBA{20, 20, 30, 255}
|
||||
dark := color.RGBA{
|
||||
R: body.R / 2, G: body.G / 2, B: body.B / 2, A: 255,
|
||||
}
|
||||
dark := color.RGBA{R: body.R / 2, G: body.G / 2, B: body.B / 2, A: 255}
|
||||
|
||||
// body
|
||||
bodyY := y + h*0.30
|
||||
bodyH := h * 0.65
|
||||
vector.DrawFilledRect(dst, float32(x), float32(bodyY), float32(w*0.78), float32(bodyH), body, false)
|
||||
|
||||
// head — circle on the right
|
||||
headR := h * 0.30
|
||||
headCX := x + w - headR
|
||||
headCY := y + h*0.50
|
||||
vector.DrawFilledCircle(dst, float32(headCX), float32(headCY), float32(headR), body, true)
|
||||
|
||||
// ears — two triangles approximated as small rects
|
||||
earW := w * 0.07
|
||||
earH := h * 0.30
|
||||
vector.DrawFilledRect(dst, float32(headCX-headR*0.7), float32(y), float32(earW), float32(earH), body, false)
|
||||
vector.DrawFilledRect(dst, float32(headCX+headR*0.4), float32(y), float32(earW), float32(earH), body, false)
|
||||
|
||||
// eye — dark dot
|
||||
vector.DrawFilledCircle(dst, float32(headCX+headR*0.25), float32(headCY-1), 1.2, outline, true)
|
||||
|
||||
// tail — small dark line stub on the left
|
||||
vector.DrawFilledRect(dst, float32(x-w*0.04), float32(bodyY+1), float32(w*0.08), 2, dark, false)
|
||||
|
||||
// legs — two short bars at the bottom
|
||||
legW := w * 0.07
|
||||
vector.DrawFilledRect(dst, float32(x+w*0.10), float32(y+h-3), float32(legW), 3, dark, false)
|
||||
vector.DrawFilledRect(dst, float32(x+w*0.55), float32(y+h-3), float32(legW), 3, dark, false)
|
||||
|
||||
+103
-19
@@ -2,11 +2,14 @@ package pncdsl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
)
|
||||
|
||||
// Game is the root aggregate. It carries every Manager plus the runtime
|
||||
// state. A domain package builds one via NewGame, registers entities, then
|
||||
// calls Run (or hands it to pncdsl.Run).
|
||||
// state. A domain package builds one via NewGame, registers entities,
|
||||
// optionally calls RegisterDefaultUI / UseTheme, then hands it to Run.
|
||||
type Game struct {
|
||||
Title string
|
||||
Width, Height int
|
||||
@@ -18,30 +21,58 @@ type Game struct {
|
||||
ScriptManager *ScriptManager
|
||||
AssetManager *AssetManager
|
||||
VerbManager *VerbManager
|
||||
UIManager *UIManager
|
||||
ThemeManager *ThemeManager
|
||||
|
||||
State *State
|
||||
Inventory *Inventory
|
||||
UI *UI
|
||||
Audio *AudioPlayer
|
||||
Camera *Camera
|
||||
Input *Input
|
||||
|
||||
startID string
|
||||
onStart Action
|
||||
startID string
|
||||
onStart Action
|
||||
activeTheme string
|
||||
|
||||
// runtime
|
||||
loaded *loadedAssets
|
||||
input *Input
|
||||
currentScene string
|
||||
chars map[string]*runtimeChar
|
||||
scriptRunner Runner
|
||||
scriptCtx *Ctx // for click consumption
|
||||
scriptCtx *Ctx
|
||||
transition *transition
|
||||
dialog *dialogBox
|
||||
activeDialog string
|
||||
selectedVerb string
|
||||
|
||||
// UI runtime state (read by widgets, written by actions / engine)
|
||||
hoverLabel string
|
||||
flash string
|
||||
flashTimer float64
|
||||
endCard string
|
||||
speech speechState
|
||||
dialog *runtimeDialog
|
||||
activeDialog string
|
||||
}
|
||||
|
||||
// NewGame initializes a game with empty managers and the SCUMM-style verb set.
|
||||
// speechState is what the SpeechBubble widget renders. Mutated by Say.
|
||||
type speechState struct {
|
||||
Active bool
|
||||
Speaker string
|
||||
Text string
|
||||
}
|
||||
|
||||
// runtimeDialog holds the in-flight Dialogue state. Mutated by the
|
||||
// startDialogue/gotoDialogueNode/endDialogue plumbing and read by the
|
||||
// DialogBox widget.
|
||||
type runtimeDialog struct {
|
||||
Dialogue *Dialogue
|
||||
Node *DialogueNode
|
||||
LineIdx int
|
||||
ChoiceHits []Rectangle
|
||||
}
|
||||
|
||||
// NewGame initializes a game with empty entity managers, the SCUMM-style
|
||||
// verb set, all preset themes, and "classic-scumm" selected. Widgets are
|
||||
// NOT auto-registered — call RegisterDefaultUI(g) explicitly.
|
||||
func NewGame(title string, w, h int) *Game {
|
||||
g := &Game{
|
||||
Title: title,
|
||||
@@ -54,27 +85,77 @@ func NewGame(title string, w, h int) *Game {
|
||||
ScriptManager: NewManager[Script](),
|
||||
AssetManager: NewManager[Asset](),
|
||||
VerbManager: NewManager[Verb](),
|
||||
UIManager: NewManager[Widget](),
|
||||
ThemeManager: NewManager[Theme](),
|
||||
State: NewState(),
|
||||
Inventory: NewInventory(),
|
||||
Audio: NewAudioPlayer(),
|
||||
Camera: NewCamera(),
|
||||
Input: newInput(),
|
||||
loaded: newLoadedAssets(w, h),
|
||||
input: newInput(),
|
||||
chars: make(map[string]*runtimeChar),
|
||||
transition: &transition{},
|
||||
selectedVerb: "look",
|
||||
}
|
||||
g.UI = newUI(g)
|
||||
for _, v := range defaultVerbs() {
|
||||
g.VerbManager.Register(v)
|
||||
}
|
||||
RegisterPresetThemes(g)
|
||||
g.UseTheme("classic-scumm")
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *Game) StartAt(name string) *Game { g.startID = name; return g }
|
||||
func (g *Game) OnStart(a Action) *Game { g.onStart = a; return g }
|
||||
|
||||
// Validate cross-checks name references between managers.
|
||||
// ----- theme + UI conveniences ------------------------------------------
|
||||
|
||||
func (g *Game) Theme() Theme { return g.ThemeManager.MustGet(g.activeTheme) }
|
||||
func (g *Game) UseTheme(name string) { g.activeTheme = name }
|
||||
func (g *Game) SelectedVerb() string { return g.selectedVerb }
|
||||
func (g *Game) SetSelectedVerb(s string) { g.selectedVerb = s }
|
||||
func (g *Game) HoverLabel() string { return g.hoverLabel }
|
||||
func (g *Game) SetHoverLabel(s string) { g.hoverLabel = s }
|
||||
|
||||
// SetSpeech / ClearSpeech are called by the Say action; widgets render
|
||||
// whatever the current state says.
|
||||
func (g *Game) SetSpeech(speaker, text string) {
|
||||
g.speech = speechState{Active: true, Speaker: speaker, Text: text}
|
||||
}
|
||||
func (g *Game) ClearSpeech() { g.speech = speechState{} }
|
||||
|
||||
// FlashLine shows a status-line message for ~2 seconds.
|
||||
func (g *Game) FlashLine(text string) {
|
||||
g.flash = text
|
||||
g.flashTimer = 2.0
|
||||
}
|
||||
|
||||
// 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) {
|
||||
drawText(dst, s, x, y, c)
|
||||
}
|
||||
|
||||
// HoverHint composes the "verb + target" string used by the StatusLine.
|
||||
func (g *Game) HoverHint() string {
|
||||
verbLabel := ""
|
||||
if v, ok := g.VerbManager.Get(g.selectedVerb); ok {
|
||||
verbLabel = v.Label
|
||||
}
|
||||
if sel := g.Inventory.Selected(); sel != "" {
|
||||
if g.hoverLabel != "" {
|
||||
return verbLabel + " " + sel + " ezen: " + g.hoverLabel
|
||||
}
|
||||
return verbLabel + " " + sel
|
||||
}
|
||||
if g.hoverLabel == "" {
|
||||
return ""
|
||||
}
|
||||
return verbLabel + " " + g.hoverLabel
|
||||
}
|
||||
|
||||
// ----- validation -------------------------------------------------------
|
||||
|
||||
func (g *Game) Validate() error {
|
||||
if !g.SceneManager.Has(g.startID) {
|
||||
return fmt.Errorf("%w: %q", ErrNoStartScene, g.startID)
|
||||
@@ -96,10 +177,13 @@ func (g *Game) Validate() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if g.activeTheme == "" || !g.ThemeManager.Has(g.activeTheme) {
|
||||
return fmt.Errorf("pncdsl: no active theme (got %q)", g.activeTheme)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ----- runtime helpers ---------------------------------------------------
|
||||
// ----- runtime: characters & scenes -------------------------------------
|
||||
|
||||
type runtimeChar struct {
|
||||
def Character
|
||||
@@ -231,7 +315,7 @@ func (g *Game) startDialogue(name string) {
|
||||
return
|
||||
}
|
||||
g.activeDialog = name
|
||||
g.dialog = newDialogBox(&d, &node)
|
||||
g.dialog = &runtimeDialog{Dialogue: &d, Node: &node, LineIdx: 0}
|
||||
}
|
||||
|
||||
func (g *Game) endDialogue() {
|
||||
@@ -249,11 +333,11 @@ func (g *Game) gotoDialogueNode(name string) {
|
||||
logf("gotoNode: unknown %q in %q", name, g.activeDialog)
|
||||
return
|
||||
}
|
||||
g.dialog.node = &node
|
||||
g.dialog.lineIdx = 0
|
||||
g.dialog.choiceHits = nil
|
||||
g.dialog.Node = &node
|
||||
g.dialog.LineIdx = 0
|
||||
g.dialog.ChoiceHits = nil
|
||||
}
|
||||
|
||||
func (g *Game) dialogueActive() bool { return g.dialog != nil }
|
||||
|
||||
func (g *Game) showEndCard(text string) { g.UI.endCard = text }
|
||||
func (g *Game) showEndCard(text string) { g.endCard = text }
|
||||
|
||||
+27
-6
@@ -8,9 +8,11 @@ type Named interface {
|
||||
}
|
||||
|
||||
// Manager is the single registry shape used by every entity type. Each entity
|
||||
// kind gets a named alias (ItemManager = Manager[Item], etc.).
|
||||
// kind gets a named alias (ItemManager = Manager[Item], etc.). Insertion
|
||||
// order is preserved — Names() returns it. SortedNames() returns alphabetical.
|
||||
type Manager[T Named] struct {
|
||||
items map[string]T
|
||||
order []string
|
||||
}
|
||||
|
||||
func NewManager[T Named]() *Manager[T] {
|
||||
@@ -28,6 +30,7 @@ func (m *Manager[T]) Register(v T) {
|
||||
panic("pncdsl: Register: duplicate " + typeName(v) + " name: " + name)
|
||||
}
|
||||
m.items[name] = v
|
||||
m.order = append(m.order, name)
|
||||
}
|
||||
|
||||
func (m *Manager[T]) Get(name string) (T, bool) {
|
||||
@@ -50,21 +53,39 @@ func (m *Manager[T]) Has(name string) bool {
|
||||
|
||||
func (m *Manager[T]) Len() int { return len(m.items) }
|
||||
|
||||
// Names returns the registered names in insertion order. Use SortedNames
|
||||
// when iteration order needs to be stable across runs.
|
||||
func (m *Manager[T]) Names() []string {
|
||||
names := make([]string, 0, len(m.items))
|
||||
for n := range m.items {
|
||||
names = append(names, n)
|
||||
}
|
||||
return append([]string(nil), m.order...)
|
||||
}
|
||||
|
||||
func (m *Manager[T]) SortedNames() []string {
|
||||
names := append([]string(nil), m.order...)
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func (m *Manager[T]) Each(fn func(T)) {
|
||||
for _, n := range m.Names() {
|
||||
for _, n := range m.order {
|
||||
fn(m.items[n])
|
||||
}
|
||||
}
|
||||
|
||||
// Remove drops a registered entry. Used by hot-reload / dynamic UI changes.
|
||||
// Silently no-ops on unknown names.
|
||||
func (m *Manager[T]) Remove(name string) {
|
||||
if _, ok := m.items[name]; !ok {
|
||||
return
|
||||
}
|
||||
delete(m.items, name)
|
||||
for i, n := range m.order {
|
||||
if n == name {
|
||||
m.order = append(m.order[:i], m.order[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func typeName(v any) string {
|
||||
type namer interface{ TypeLabel() string }
|
||||
if n, ok := v.(namer); ok {
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
package pncdsl
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
)
|
||||
|
||||
// dialogBox is the active-conversation UI: it shows the current node's
|
||||
// lines (one at a time, advanced on click) followed by the visible choices.
|
||||
type dialogBox struct {
|
||||
dialogue *Dialogue
|
||||
node *DialogueNode
|
||||
lineIdx int // -1 = lines done, showing choices
|
||||
choiceHits []Rectangle
|
||||
}
|
||||
|
||||
func newDialogBox(d *Dialogue, n *DialogueNode) *dialogBox {
|
||||
return &dialogBox{dialogue: d, node: n, lineIdx: 0}
|
||||
}
|
||||
|
||||
// resolveChoices returns the list of choices currently visible to the player.
|
||||
func (db *dialogBox) resolveChoices(ctx *Ctx) []DialogueChoice {
|
||||
out := make([]DialogueChoice, 0, len(db.node.Choices))
|
||||
for _, c := range db.node.Choices {
|
||||
if c.Show != nil && !c.Show.Eval(ctx) {
|
||||
continue
|
||||
}
|
||||
if c.Once && ctx.Game.State.Talked(db.node.Name+":"+c.Text) > 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleClick advances the dialog state. Returns the picked choice (if any).
|
||||
func (db *dialogBox) handleClick(ctx *Ctx, p Point) (picked *DialogueChoice) {
|
||||
if db.lineIdx >= 0 && db.lineIdx < len(db.node.Lines) {
|
||||
db.lineIdx++
|
||||
if db.lineIdx >= len(db.node.Lines) {
|
||||
db.lineIdx = -1
|
||||
db.choiceHits = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for i, r := range db.choiceHits {
|
||||
if r.Contains(p) {
|
||||
ch := db.resolveChoices(ctx)[i]
|
||||
return &ch
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *dialogBox) currentLine() (DialogueLine, bool) {
|
||||
if db.lineIdx >= 0 && db.lineIdx < len(db.node.Lines) {
|
||||
return db.node.Lines[db.lineIdx], true
|
||||
}
|
||||
return DialogueLine{}, false
|
||||
}
|
||||
|
||||
func (db *dialogBox) draw(dst *ebiten.Image, g *Game) {
|
||||
// box across the bottom 60 px
|
||||
vector.DrawFilledRect(dst, 0, 140, 320, 60, color.RGBA{15, 15, 25, 240}, false)
|
||||
vector.StrokeRect(dst, 0, 140, 320, 60, 1, color.RGBA{80, 80, 110, 255}, false)
|
||||
|
||||
if ln, ok := db.currentLine(); ok {
|
||||
speakerCol := color.RGBA{220, 220, 120, 255}
|
||||
drawText(dst, ln.Speaker+":", 6, 142, speakerCol)
|
||||
lines := wrapText(ln.Text, 300)
|
||||
for i, l := range lines {
|
||||
drawText(dst, l, 6, 158+i*14, color.White)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// choices
|
||||
ctx := g.makeCtx()
|
||||
choices := db.resolveChoices(ctx)
|
||||
db.choiceHits = db.choiceHits[:0]
|
||||
for i, c := range choices {
|
||||
y := 144 + i*14
|
||||
r := Rect(6, float64(y), 308, 13)
|
||||
db.choiceHits = append(db.choiceHits, r)
|
||||
mx, my := g.input.Pos()
|
||||
hover := r.Contains(Point{X: float64(mx), Y: float64(my)})
|
||||
bg := color.RGBA{30, 30, 45, 255}
|
||||
if hover {
|
||||
bg = color.RGBA{70, 70, 100, 255}
|
||||
}
|
||||
vector.DrawFilledRect(dst, float32(r.X), float32(r.Y), float32(r.W), float32(r.H), bg, false)
|
||||
drawText(dst, c.Text, int(r.X)+2, int(r.Y)-1, color.White)
|
||||
_ = i
|
||||
}
|
||||
}
|
||||
+17
-10
@@ -1,21 +1,28 @@
|
||||
package pncdsl
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
)
|
||||
|
||||
// drawCursor renders a simple crosshair at the mouse position. When an
|
||||
// inventory item is selected, the item sprite is drawn instead.
|
||||
func drawCursor(dst *ebiten.Image, g *Game) {
|
||||
x, y := g.input.Pos()
|
||||
// Cursor renders the mouse cursor. When the player has an inventory item
|
||||
// selected, the item's sprite is drawn at the cursor instead of the
|
||||
// default crosshair. Registered last so it draws on top.
|
||||
type Cursor struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (c *Cursor) GetName() string { return c.Name }
|
||||
func (c *Cursor) Tick(ctx *UICtx) {}
|
||||
|
||||
func (c *Cursor) Draw(dst *ebiten.Image, ctx *UICtx) {
|
||||
g := ctx.Game
|
||||
x, y := g.Input.Pos()
|
||||
if sel := g.Inventory.Selected(); sel != "" {
|
||||
if it, ok := g.ItemManager.Get(sel); ok {
|
||||
img := g.loaded.image(g.AssetManager, it.Sprite)
|
||||
sw, sh := img.Bounds().Dx(), img.Bounds().Dy()
|
||||
if sw > 0 && sh > 0 {
|
||||
if sw > 0 && sh > 0 && !g.loaded.isPlaceholder(it.Sprite) {
|
||||
op := &ebiten.DrawImageOptions{}
|
||||
op.GeoM.Scale(16/float64(sw), 16/float64(sh))
|
||||
op.GeoM.Translate(float64(x-8), float64(y-8))
|
||||
@@ -24,7 +31,7 @@ func drawCursor(dst *ebiten.Image, g *Game) {
|
||||
}
|
||||
}
|
||||
}
|
||||
c := color.RGBA{255, 255, 255, 255}
|
||||
vector.StrokeLine(dst, float32(x-3), float32(y), float32(x+4), float32(y), 1, c, false)
|
||||
vector.StrokeLine(dst, float32(x), float32(y-3), float32(x), float32(y+4), 1, c, false)
|
||||
col := g.Theme().CursorColor
|
||||
vector.StrokeLine(dst, float32(x-3), float32(y), float32(x+4), float32(y), 1, col, false)
|
||||
vector.StrokeLine(dst, float32(x), float32(y-3), float32(x), float32(y+4), 1, col, false)
|
||||
}
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
package pncdsl
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
)
|
||||
|
||||
// Layout constants in the internal 320×200 coordinate space.
|
||||
const (
|
||||
uiSceneTop = 0
|
||||
uiSceneBottom = 140
|
||||
uiStatusY = 142
|
||||
uiPanelY = 152
|
||||
uiPanelH = 48
|
||||
uiVerbsX = 4
|
||||
uiVerbsW = 120
|
||||
uiInvX = 132
|
||||
uiInvW = 184
|
||||
uiInvSlotW = 22
|
||||
)
|
||||
|
||||
// UI is the per-game UI state. It's a simple bag of fields the engine
|
||||
// writes into and the renderer reads. No event/signal layer — the engine
|
||||
// drives everything top-down each tick.
|
||||
type UI struct {
|
||||
g *Game
|
||||
|
||||
hoverLabel string
|
||||
flash string
|
||||
flashTimer float64
|
||||
|
||||
speech *speechBubble
|
||||
dialog *dialogBox
|
||||
endCard string
|
||||
|
||||
verbButtons []verbButton
|
||||
}
|
||||
|
||||
func newUI(g *Game) *UI {
|
||||
return &UI{
|
||||
g: g,
|
||||
speech: &speechBubble{},
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UI) rebuildVerbButtons() {
|
||||
verbs := u.g.VerbManager.Names()
|
||||
u.verbButtons = u.verbButtons[:0]
|
||||
cols := 2
|
||||
bw := uiVerbsW / cols
|
||||
bh := 14
|
||||
for i, name := range verbs {
|
||||
col := i % cols
|
||||
row := i / cols
|
||||
x := uiVerbsX + col*bw
|
||||
y := uiPanelY + row*bh
|
||||
u.verbButtons = append(u.verbButtons, verbButton{
|
||||
Name: name,
|
||||
Label: u.g.VerbManager.MustGet(name).Label,
|
||||
Bounds: Rect(float64(x), float64(y), float64(bw-2), float64(bh-2)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type verbButton struct {
|
||||
Name string
|
||||
Label string
|
||||
Bounds Rectangle
|
||||
}
|
||||
|
||||
// SetSpeech / ClearSpeech are called from the Say action.
|
||||
func (u *UI) SetSpeech(speaker, text string) {
|
||||
u.speech.set(u.g, speaker, text)
|
||||
}
|
||||
|
||||
func (u *UI) ClearSpeech() { u.speech.clear() }
|
||||
|
||||
// FlashLine shows a short status string (e.g. "Ehhez kell a kulcs.") for ~2s.
|
||||
func (u *UI) FlashLine(text string) {
|
||||
u.flash = text
|
||||
u.flashTimer = 2.0
|
||||
}
|
||||
|
||||
func (u *UI) tick(dt float64) {
|
||||
if u.flashTimer > 0 {
|
||||
u.flashTimer -= dt
|
||||
if u.flashTimer <= 0 {
|
||||
u.flash = ""
|
||||
}
|
||||
}
|
||||
u.speech.tick(dt)
|
||||
}
|
||||
|
||||
// drawHUD paints the bottom UI panel (status line + verbs + inventory).
|
||||
// Dialog box, if active, is drawn separately by the engine on top.
|
||||
func (u *UI) drawHUD(dst *ebiten.Image) {
|
||||
// status / hover / flash line
|
||||
status := u.flash
|
||||
if status == "" {
|
||||
status = u.composedHoverLabel()
|
||||
}
|
||||
if status != "" {
|
||||
w := textWidth(status)
|
||||
drawText(dst, status, (320-w)/2, uiStatusY, color.White)
|
||||
}
|
||||
|
||||
// panel background
|
||||
vector.DrawFilledRect(dst, 0, float32(uiPanelY-2), 320, float32(uiPanelH+2), color.RGBA{20, 20, 30, 255}, false)
|
||||
|
||||
// verb buttons
|
||||
for _, b := range u.verbButtons {
|
||||
bg := color.RGBA{50, 50, 70, 255}
|
||||
if b.Name == u.g.selectedVerb {
|
||||
bg = color.RGBA{120, 90, 40, 255}
|
||||
}
|
||||
vector.DrawFilledRect(dst, float32(b.Bounds.X), float32(b.Bounds.Y), float32(b.Bounds.W), float32(b.Bounds.H), bg, false)
|
||||
drawText(dst, b.Label, int(b.Bounds.X)+2, int(b.Bounds.Y)-1, color.White)
|
||||
}
|
||||
|
||||
// inventory slots
|
||||
items := u.g.Inventory.Items()
|
||||
for k := 0; k < 8; k++ {
|
||||
x := uiInvX + k*uiInvSlotW
|
||||
y := uiPanelY
|
||||
bg := color.RGBA{40, 40, 50, 255}
|
||||
if k < len(items) && items[k] == u.g.Inventory.Selected() {
|
||||
bg = color.RGBA{120, 90, 40, 255}
|
||||
}
|
||||
vector.DrawFilledRect(dst, float32(x), float32(y), float32(uiInvSlotW-2), float32(uiInvSlotW-2), bg, false)
|
||||
if k < len(items) {
|
||||
it, ok := u.g.ItemManager.Get(items[k])
|
||||
if ok {
|
||||
img := u.g.loaded.image(u.g.AssetManager, it.Sprite)
|
||||
op := &ebiten.DrawImageOptions{}
|
||||
sw, sh := img.Bounds().Dx(), img.Bounds().Dy()
|
||||
if sw == 0 || sh == 0 {
|
||||
continue
|
||||
}
|
||||
slotPx := float64(uiInvSlotW - 4)
|
||||
sx := slotPx / float64(sw)
|
||||
sy := slotPx / float64(sh)
|
||||
op.GeoM.Scale(sx, sy)
|
||||
op.GeoM.Translate(float64(x+1), float64(y+1))
|
||||
dst.DrawImage(img, op)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UI) composedHoverLabel() string {
|
||||
verb := ""
|
||||
if v, ok := u.g.VerbManager.Get(u.g.selectedVerb); ok {
|
||||
verb = v.Label
|
||||
}
|
||||
target := u.hoverLabel
|
||||
if sel := u.g.Inventory.Selected(); sel != "" {
|
||||
if target != "" {
|
||||
return verb + " " + sel + " ezen: " + target
|
||||
}
|
||||
return verb + " " + sel
|
||||
}
|
||||
if target == "" {
|
||||
return ""
|
||||
}
|
||||
return verb + " " + target
|
||||
}
|
||||
|
||||
// hitTestUI returns the click target (verbButton, inventory slot index) or "".
|
||||
// "verb:<name>", "inv:<i>", "" for none.
|
||||
func (u *UI) hitTest(p Point) string {
|
||||
for _, b := range u.verbButtons {
|
||||
if b.Bounds.Contains(p) {
|
||||
return "verb:" + b.Name
|
||||
}
|
||||
}
|
||||
items := u.g.Inventory.Items()
|
||||
for k := 0; k < len(items); k++ {
|
||||
x := uiInvX + k*uiInvSlotW
|
||||
r := Rect(float64(x), float64(uiPanelY), float64(uiInvSlotW-2), float64(uiInvSlotW-2))
|
||||
if r.Contains(p) {
|
||||
return "inv:" + items[k]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package pncdsl
|
||||
|
||||
// RegisterDefaultUI installs the SCUMM-style HUD widgets into g.UIManager
|
||||
// in the conventional Z-order (HotspotDebug at the back, Cursor on top).
|
||||
// Domains that want a different layout call this and then either tweak
|
||||
// the registered widgets in place or replace them entirely.
|
||||
//
|
||||
// Layout assumes the default 320×200 internal resolution.
|
||||
func RegisterDefaultUI(g *Game) {
|
||||
g.UIManager.Register(&HotspotDebug{Name: "hotspot_debug"})
|
||||
g.UIManager.Register(&VerbBar{
|
||||
Name: "verbs",
|
||||
Origin: Point{X: 4, Y: 152},
|
||||
Cols: 2,
|
||||
Button: Size{W: 60, H: 14},
|
||||
})
|
||||
g.UIManager.Register(&InventoryBar{
|
||||
Name: "inventory",
|
||||
Origin: Point{X: 132, Y: 152},
|
||||
Slots: 8,
|
||||
Cols: 8,
|
||||
SlotSize: 22,
|
||||
Gap: 2,
|
||||
})
|
||||
g.UIManager.Register(&StatusLine{Name: "status", Y: 142, Align: AlignCenter})
|
||||
g.UIManager.Register(&SpeechBubble{Name: "speech", MaxWidth: 200, Padding: 3, OffsetY: 4, FallbackY: 14})
|
||||
g.UIManager.Register(&DialogBox{Name: "dialog", Bounds: Rect(0, 140, float64(g.Width), 60), LineHeight: 14, 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.
|
||||
func RegisterRadialVerbUI(g *Game) {
|
||||
g.UIManager.Register(&HotspotDebug{Name: "hotspot_debug"})
|
||||
g.UIManager.Register(&InventoryBar{
|
||||
Name: "inventory",
|
||||
Origin: Point{X: 4, Y: 178},
|
||||
Slots: 14,
|
||||
Cols: 14,
|
||||
SlotSize: 22,
|
||||
Gap: 0,
|
||||
})
|
||||
g.UIManager.Register(&StatusLine{Name: "status", Y: 168, Align: AlignCenter})
|
||||
g.UIManager.Register(&SpeechBubble{Name: "speech", MaxWidth: 200, Padding: 3, OffsetY: 4, FallbackY: 14})
|
||||
g.UIManager.Register(&DialogBox{Name: "dialog", Bounds: Rect(0, 140, float64(g.Width), 60), LineHeight: 14, Padding: 6})
|
||||
g.UIManager.Register(&RadialVerbs{Name: "verbs", Trigger: MouseButtonRight, Radius: 40})
|
||||
g.UIManager.Register(&EndCard{Name: "endcard"})
|
||||
g.UIManager.Register(&Cursor{Name: "cursor"})
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package pncdsl
|
||||
|
||||
import (
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
)
|
||||
|
||||
// DialogBox renders the active conversation: one line at a time (advanced
|
||||
// on click) followed by the player's choices. The widget is dormant
|
||||
// unless ctx.Game.dialogueActive() — and while active it consumes input
|
||||
// so nothing underneath reacts.
|
||||
type DialogBox struct {
|
||||
Name string
|
||||
Bounds Rectangle
|
||||
LineHeight int
|
||||
Padding int
|
||||
}
|
||||
|
||||
func (d *DialogBox) GetName() string { return d.Name }
|
||||
|
||||
func (d *DialogBox) Tick(ctx *UICtx) {
|
||||
g := ctx.Game
|
||||
if !g.dialogueActive() {
|
||||
return
|
||||
}
|
||||
// while a dialog is open, ALL clicks belong to it
|
||||
if g.Input.RightClicked() {
|
||||
g.Input.ConsumeRight()
|
||||
}
|
||||
if !g.Input.LeftClicked() {
|
||||
return
|
||||
}
|
||||
g.Input.ConsumeLeft()
|
||||
mp := g.Input.Point()
|
||||
|
||||
dlg := g.dialog
|
||||
if dlg.LineIdx >= 0 && dlg.LineIdx < len(dlg.Node.Lines) {
|
||||
dlg.LineIdx++
|
||||
if dlg.LineIdx >= len(dlg.Node.Lines) {
|
||||
dlg.LineIdx = -1
|
||||
dlg.ChoiceHits = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
// pick the clicked choice
|
||||
for i, r := range dlg.ChoiceHits {
|
||||
if r.Contains(mp) {
|
||||
choice := d.resolveChoices(ctx)[i]
|
||||
if choice.Once {
|
||||
g.State.NoteTalked(dlg.Node.Name + ":" + choice.Text)
|
||||
}
|
||||
if len(choice.Actions) > 0 {
|
||||
g.queueAction(Seq(choice.Actions...), "choice")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DialogBox) resolveChoices(ctx *UICtx) []DialogueChoice {
|
||||
g := ctx.Game
|
||||
dlg := g.dialog
|
||||
out := make([]DialogueChoice, 0, len(dlg.Node.Choices))
|
||||
for _, c := range dlg.Node.Choices {
|
||||
gctx := g.makeCtx()
|
||||
if c.Show != nil && !c.Show.Eval(gctx) {
|
||||
continue
|
||||
}
|
||||
if c.Once && g.State.Talked(dlg.Node.Name+":"+c.Text) > 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BlocksClickAt — when a dialog is open, every click is ours.
|
||||
func (d *DialogBox) BlocksClickAt(p Point) bool {
|
||||
// without ctx we can't know if a dialog is active; conservatively block
|
||||
// when the cursor is in the dialog bounds (used only by RadialVerbs).
|
||||
b := d.Bounds
|
||||
if b.W == 0 {
|
||||
return false
|
||||
}
|
||||
return b.Contains(p)
|
||||
}
|
||||
|
||||
func (d *DialogBox) Draw(dst *ebiten.Image, ctx *UICtx) {
|
||||
g := ctx.Game
|
||||
if !g.dialogueActive() {
|
||||
return
|
||||
}
|
||||
th := g.Theme()
|
||||
b := d.Bounds
|
||||
if b.W == 0 {
|
||||
b = Rect(0, 140, float64(g.Width), 60)
|
||||
}
|
||||
pad := d.Padding
|
||||
if pad <= 0 {
|
||||
pad = 6
|
||||
}
|
||||
lh := d.LineHeight
|
||||
if lh <= 0 {
|
||||
lh = 14
|
||||
}
|
||||
|
||||
vector.DrawFilledRect(dst, float32(b.X), float32(b.Y), float32(b.W), float32(b.H), th.DialogBG, false)
|
||||
vector.StrokeRect(dst, float32(b.X), float32(b.Y), float32(b.W), float32(b.H), 1, th.DialogBorder, false)
|
||||
|
||||
dlg := g.dialog
|
||||
if dlg.LineIdx >= 0 && dlg.LineIdx < len(dlg.Node.Lines) {
|
||||
ln := dlg.Node.Lines[dlg.LineIdx]
|
||||
g.DrawText(dst, ln.Speaker+":", int(b.X)+pad, int(b.Y)+2, th.DialogSpeaker)
|
||||
lines := wrapText(ln.Text, int(b.W)-pad*2)
|
||||
for i, l := range lines {
|
||||
g.DrawText(dst, l, int(b.X)+pad, int(b.Y)+18+i*lh, th.DialogText)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
choices := d.resolveChoices(ctx)
|
||||
dlg.ChoiceHits = dlg.ChoiceHits[:0]
|
||||
mp := g.Input.Point()
|
||||
for i, c := range choices {
|
||||
r := Rect(b.X+float64(pad), b.Y+4+float64(i*lh), b.W-float64(pad)*2, float64(lh-1))
|
||||
dlg.ChoiceHits = append(dlg.ChoiceHits, r)
|
||||
bg := th.DialogChoiceBG
|
||||
if r.Contains(mp) {
|
||||
bg = th.DialogChoiceHover
|
||||
}
|
||||
vector.DrawFilledRect(dst, float32(r.X), float32(r.Y), float32(r.W), float32(r.H), bg, false)
|
||||
g.DrawText(dst, c.Text, int(r.X)+2, int(r.Y)-1, th.DialogText)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package pncdsl
|
||||
|
||||
import (
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
)
|
||||
|
||||
// EndCard is a fullscreen overlay shown when the ShowEnd action fires.
|
||||
// While active it consumes every input so nothing underneath reacts.
|
||||
type EndCard struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (e *EndCard) GetName() string { return e.Name }
|
||||
|
||||
func (e *EndCard) Tick(ctx *UICtx) {
|
||||
if ctx.Game.endCard == "" {
|
||||
return
|
||||
}
|
||||
// swallow every click while the end card is up
|
||||
if ctx.Game.Input.LeftClicked() {
|
||||
ctx.Game.Input.ConsumeLeft()
|
||||
}
|
||||
if ctx.Game.Input.RightClicked() {
|
||||
ctx.Game.Input.ConsumeRight()
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EndCard) Draw(dst *ebiten.Image, ctx *UICtx) {
|
||||
g := ctx.Game
|
||||
if g.endCard == "" {
|
||||
return
|
||||
}
|
||||
th := g.Theme()
|
||||
vector.DrawFilledRect(dst, 0, 0, float32(g.Width), float32(g.Height), th.EndCardBG, false)
|
||||
w := textWidth(g.endCard)
|
||||
g.DrawText(dst, g.endCard, (g.Width-w)/2, g.Height/2-8, th.EndCardText)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package pncdsl
|
||||
|
||||
import (
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/inpututil"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
)
|
||||
|
||||
// HotspotDebug overlays a colored outline on every hotspot of the current
|
||||
// scene. F1 toggles it at runtime; the default is off.
|
||||
type HotspotDebug struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
// ToggleKey, if non-zero, overrides the default F1 toggle.
|
||||
ToggleKey ebiten.Key
|
||||
}
|
||||
|
||||
func (h *HotspotDebug) GetName() string { return h.Name }
|
||||
|
||||
func (h *HotspotDebug) Tick(ctx *UICtx) {
|
||||
key := h.ToggleKey
|
||||
if key == 0 {
|
||||
key = ebiten.KeyF1
|
||||
}
|
||||
if inpututil.IsKeyJustPressed(key) {
|
||||
h.Enabled = !h.Enabled
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HotspotDebug) Draw(dst *ebiten.Image, ctx *UICtx) {
|
||||
if !h.Enabled {
|
||||
return
|
||||
}
|
||||
g := ctx.Game
|
||||
if g.currentScene == "" {
|
||||
return
|
||||
}
|
||||
col := g.Theme().HotspotOutline
|
||||
s := g.SceneManager.MustGet(g.currentScene)
|
||||
for _, hs := range s.Hotspots {
|
||||
b := hs.Area.Bounds()
|
||||
vector.StrokeRect(dst, float32(b.X), float32(b.Y), float32(b.W), float32(b.H), 1, col, false)
|
||||
if hs.Label != "" {
|
||||
g.DrawText(dst, hs.Label, int(b.X)+1, int(b.Y)+1, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package pncdsl
|
||||
|
||||
import (
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
)
|
||||
|
||||
// InventoryBar shows the player's items as clickable slots. Left-click on
|
||||
// a slot under the "look" verb prints the item's description; under any
|
||||
// other verb, it toggles selection (the cursor "picks up" the item).
|
||||
type InventoryBar struct {
|
||||
Name string
|
||||
Origin Point
|
||||
Slots int
|
||||
Cols int
|
||||
SlotSize int
|
||||
Gap int
|
||||
|
||||
PanelBG bool
|
||||
}
|
||||
|
||||
func (b *InventoryBar) GetName() string { return b.Name }
|
||||
|
||||
func (b *InventoryBar) slotRect(idx int) Rectangle {
|
||||
cols := b.Cols
|
||||
if cols <= 0 {
|
||||
cols = b.Slots
|
||||
}
|
||||
step := b.SlotSize + b.Gap
|
||||
col := idx % cols
|
||||
row := idx / cols
|
||||
x := b.Origin.X + float64(col*step)
|
||||
y := b.Origin.Y + float64(row*step)
|
||||
return Rect(x, y, float64(b.SlotSize), float64(b.SlotSize))
|
||||
}
|
||||
|
||||
func (b *InventoryBar) Tick(ctx *UICtx) {
|
||||
g := ctx.Game
|
||||
mp := g.Input.Point()
|
||||
items := g.Inventory.Items()
|
||||
|
||||
// hover label: when over a slot with an item, show its description
|
||||
for i := 0; i < min(b.Slots, len(items)); i++ {
|
||||
if b.slotRect(i).Contains(mp) {
|
||||
if it, ok := g.ItemManager.Get(items[i]); ok && it.Description != "" {
|
||||
g.SetHoverLabel(it.Description)
|
||||
} else {
|
||||
g.SetHoverLabel(items[i])
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !g.Input.LeftClicked() {
|
||||
return
|
||||
}
|
||||
for i := 0; i < min(b.Slots, len(items)); i++ {
|
||||
if b.slotRect(i).Contains(mp) {
|
||||
g.Input.ConsumeLeft()
|
||||
name := items[i]
|
||||
if g.SelectedVerb() == "look" {
|
||||
if it, ok := g.ItemManager.Get(name); ok && it.Description != "" {
|
||||
g.queueAction(Say("player", it.Description), "inv-look")
|
||||
return
|
||||
}
|
||||
}
|
||||
if g.Inventory.Selected() == name {
|
||||
g.Inventory.Select("")
|
||||
} else {
|
||||
g.Inventory.Select(name)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *InventoryBar) Draw(dst *ebiten.Image, ctx *UICtx) {
|
||||
g := ctx.Game
|
||||
th := g.Theme()
|
||||
items := g.Inventory.Items()
|
||||
|
||||
if b.PanelBG {
|
||||
last := b.slotRect(b.Slots - 1)
|
||||
first := b.slotRect(0)
|
||||
x := first.X - 2
|
||||
y := first.Y - 2
|
||||
w := (last.X + last.W) - first.X + 4
|
||||
h := (last.Y + last.H) - first.Y + 4
|
||||
vector.DrawFilledRect(dst, float32(x), float32(y), float32(w), float32(h), th.PanelBG, false)
|
||||
}
|
||||
|
||||
for i := 0; i < b.Slots; i++ {
|
||||
r := b.slotRect(i)
|
||||
bg := th.InventorySlotBG
|
||||
if i < len(items) && items[i] == g.Inventory.Selected() {
|
||||
bg = th.InventorySlotSelectedBG
|
||||
}
|
||||
vector.DrawFilledRect(dst, float32(r.X), float32(r.Y), float32(r.W), float32(r.H), bg, false)
|
||||
if i < len(items) {
|
||||
it, ok := g.ItemManager.Get(items[i])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
img := g.loaded.image(g.AssetManager, it.Sprite)
|
||||
sw, sh := img.Bounds().Dx(), img.Bounds().Dy()
|
||||
if sw == 0 || sh == 0 {
|
||||
continue
|
||||
}
|
||||
if g.loaded.isPlaceholder(it.Sprite) {
|
||||
// draw a small colored square as a stand-in
|
||||
inset := float32(2)
|
||||
vector.DrawFilledRect(dst,
|
||||
float32(r.X)+inset, float32(r.Y)+inset,
|
||||
float32(r.W)-2*inset, float32(r.H)-2*inset,
|
||||
th.VerbButtonSelectedBG, false)
|
||||
continue
|
||||
}
|
||||
op := &ebiten.DrawImageOptions{}
|
||||
inset := 1.0
|
||||
tgtW := r.W - 2*inset
|
||||
tgtH := r.H - 2*inset
|
||||
op.GeoM.Scale(tgtW/float64(sw), tgtH/float64(sh))
|
||||
op.GeoM.Translate(r.X+inset, r.Y+inset)
|
||||
dst.DrawImage(img, op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// BlocksClickAt — clickBlocker contract for the RadialVerbs widget.
|
||||
func (b *InventoryBar) BlocksClickAt(p Point) bool {
|
||||
if b.Slots <= 0 {
|
||||
return false
|
||||
}
|
||||
first := b.slotRect(0)
|
||||
last := b.slotRect(b.Slots - 1)
|
||||
x := first.X
|
||||
y := first.Y
|
||||
w := last.X + last.W - first.X
|
||||
h := last.Y + last.H - first.Y
|
||||
return Rect(x, y, w, h).Contains(p)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package pncdsl
|
||||
|
||||
// UIManager registers Widget instances. Same shape as every other manager.
|
||||
type UIManager = Manager[Widget]
|
||||
|
||||
// reversedWidgets iterates a manager's contents in reverse registration
|
||||
// order — used by the engine for top-down input dispatch (the widget
|
||||
// drawn on top gets the click first).
|
||||
func reversedWidgets(m *UIManager) []Widget {
|
||||
names := m.Names()
|
||||
out := make([]Widget, len(names))
|
||||
for i, n := range names {
|
||||
out[len(names)-1-i] = m.MustGet(n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// orderedWidgets iterates in registration order — bottom-up draw.
|
||||
func orderedWidgets(m *UIManager) []Widget {
|
||||
names := m.Names()
|
||||
out := make([]Widget, len(names))
|
||||
for i, n := range names {
|
||||
out[i] = m.MustGet(n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package pncdsl
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
)
|
||||
|
||||
// Panel is a generic background widget — a colored rectangle. Useful as a
|
||||
// backdrop for grouping other widgets or as a chat/notebook frame.
|
||||
type Panel struct {
|
||||
Name string
|
||||
Bounds Rectangle
|
||||
// BG, if nil, falls back to the active Theme.PanelBG.
|
||||
BG color.Color
|
||||
// Border thickness in pixels (0 = no border).
|
||||
Border int
|
||||
// BorderColor, if nil, falls back to Theme.DialogBorder.
|
||||
BorderColor color.Color
|
||||
}
|
||||
|
||||
func (p *Panel) GetName() string { return p.Name }
|
||||
func (p *Panel) Tick(ctx *UICtx) {}
|
||||
|
||||
func (p *Panel) Draw(dst *ebiten.Image, ctx *UICtx) {
|
||||
bg := p.BG
|
||||
if bg == nil {
|
||||
bg = ctx.Game.Theme().PanelBG
|
||||
}
|
||||
vector.DrawFilledRect(dst,
|
||||
float32(p.Bounds.X), float32(p.Bounds.Y),
|
||||
float32(p.Bounds.W), float32(p.Bounds.H), bg, false)
|
||||
if p.Border > 0 {
|
||||
bc := p.BorderColor
|
||||
if bc == nil {
|
||||
bc = ctx.Game.Theme().DialogBorder
|
||||
}
|
||||
vector.StrokeRect(dst,
|
||||
float32(p.Bounds.X), float32(p.Bounds.Y),
|
||||
float32(p.Bounds.W), float32(p.Bounds.H),
|
||||
float32(p.Border), bc, false)
|
||||
}
|
||||
}
|
||||
+53
-47
@@ -7,68 +7,74 @@ import (
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
)
|
||||
|
||||
// speechBubble holds the currently-spoken line. The Say action sets it and
|
||||
// the engine draws it above the speaker (or centered at screen top if the
|
||||
// speaker is unknown).
|
||||
type speechBubble struct {
|
||||
speaker string
|
||||
lines []string
|
||||
pos Point
|
||||
active bool
|
||||
// SpeechBubble renders the line set by the Say action above the speaking
|
||||
// character (or at FallbackY if the speaker has no on-screen position).
|
||||
type SpeechBubble struct {
|
||||
Name string
|
||||
MaxWidth int
|
||||
Padding int
|
||||
OffsetY int
|
||||
FallbackY int
|
||||
}
|
||||
|
||||
func (s *speechBubble) set(g *Game, speaker, text string) {
|
||||
s.speaker = speaker
|
||||
s.lines = wrapText(text, 200)
|
||||
s.active = true
|
||||
// position above the speaker, if we know where they are
|
||||
if c, ok := g.runtimeChar(speaker); ok {
|
||||
s.pos = Point{X: c.pos.X, Y: c.pos.Y - c.def.H - 4}
|
||||
} else {
|
||||
s.pos = Point{X: 160, Y: 14}
|
||||
}
|
||||
}
|
||||
func (s *SpeechBubble) GetName() string { return s.Name }
|
||||
func (s *SpeechBubble) Tick(ctx *UICtx) {}
|
||||
|
||||
func (s *speechBubble) clear() {
|
||||
s.active = false
|
||||
s.speaker = ""
|
||||
s.lines = nil
|
||||
}
|
||||
|
||||
func (s *speechBubble) tick(dt float64) { _ = dt }
|
||||
|
||||
func (s *speechBubble) draw(dst *ebiten.Image, g *Game) {
|
||||
if !s.active || len(s.lines) == 0 {
|
||||
func (s *SpeechBubble) Draw(dst *ebiten.Image, ctx *UICtx) {
|
||||
g := ctx.Game
|
||||
sp := g.speech
|
||||
if !sp.Active {
|
||||
return
|
||||
}
|
||||
maxW := 0
|
||||
for _, ln := range s.lines {
|
||||
if w := textWidth(ln); w > maxW {
|
||||
maxW = w
|
||||
maxW := s.MaxWidth
|
||||
if maxW <= 0 {
|
||||
maxW = 200
|
||||
}
|
||||
pad := s.Padding
|
||||
if pad <= 0 {
|
||||
pad = 3
|
||||
}
|
||||
lines := wrapText(sp.Text, maxW)
|
||||
w := 0
|
||||
for _, ln := range lines {
|
||||
if t := textWidth(ln); t > w {
|
||||
w = t
|
||||
}
|
||||
}
|
||||
pad := 3
|
||||
w := maxW + pad*2
|
||||
h := len(s.lines)*16 + pad*2
|
||||
x := int(s.pos.X) - w/2
|
||||
y := int(s.pos.Y) - h
|
||||
w += pad * 2
|
||||
h := len(lines)*16 + pad*2
|
||||
|
||||
// position above speaker if known
|
||||
cx, cy := g.Width/2, s.FallbackY
|
||||
if cy == 0 {
|
||||
cy = 14
|
||||
}
|
||||
if c, ok := g.runtimeChar(sp.Speaker); ok {
|
||||
cx = int(c.pos.X)
|
||||
cy = int(c.pos.Y-c.def.H) - s.OffsetY
|
||||
}
|
||||
x := cx - w/2
|
||||
y := cy - h
|
||||
if x < 2 {
|
||||
x = 2
|
||||
}
|
||||
if x+w > 318 {
|
||||
x = 318 - w
|
||||
if x+w > g.Width-2 {
|
||||
x = g.Width - 2 - w
|
||||
}
|
||||
if y < 2 {
|
||||
y = 2
|
||||
}
|
||||
vector.DrawFilledRect(dst, float32(x), float32(y), float32(w), float32(h), color.RGBA{0, 0, 0, 200}, false)
|
||||
speechColor := color.RGBA{255, 255, 255, 255}
|
||||
if c, ok := g.CharacterManager.Get(s.speaker); ok {
|
||||
if sc, ok := c.SpeechColor.(color.RGBA); ok {
|
||||
speechColor = sc
|
||||
|
||||
th := g.Theme()
|
||||
vector.DrawFilledRect(dst, float32(x), float32(y), float32(w), float32(h), th.SpeechBubbleBG, false)
|
||||
|
||||
textCol := th.SpeechDefaultText
|
||||
if c, ok := g.CharacterManager.Get(sp.Speaker); ok {
|
||||
if rgba, ok := c.SpeechColor.(color.RGBA); ok && rgba.A > 0 {
|
||||
textCol = rgba
|
||||
}
|
||||
}
|
||||
for k, ln := range s.lines {
|
||||
drawText(dst, ln, x+pad, y+pad+k*16-2, speechColor)
|
||||
for i, ln := range lines {
|
||||
g.DrawText(dst, ln, x+pad, y+pad+i*16-2, textCol)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package pncdsl
|
||||
|
||||
import "github.com/hajimehoshi/ebiten/v2"
|
||||
|
||||
// StatusLine renders a single line: either the active flash message (set
|
||||
// by FlashLine) or the hover-hint (verb + target under the cursor).
|
||||
type StatusLine struct {
|
||||
Name string
|
||||
Y int
|
||||
Align Align
|
||||
// ScreenWidth, if 0, uses Game.Width.
|
||||
ScreenWidth int
|
||||
}
|
||||
|
||||
func (s *StatusLine) GetName() string { return s.Name }
|
||||
|
||||
func (s *StatusLine) Tick(ctx *UICtx) {
|
||||
g := ctx.Game
|
||||
if g.flashTimer > 0 {
|
||||
g.flashTimer -= ctx.DT
|
||||
if g.flashTimer <= 0 {
|
||||
g.flash = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StatusLine) Draw(dst *ebiten.Image, ctx *UICtx) {
|
||||
g := ctx.Game
|
||||
th := g.Theme()
|
||||
w := s.ScreenWidth
|
||||
if w == 0 {
|
||||
w = g.Width
|
||||
}
|
||||
text := g.flash
|
||||
col := th.FlashText
|
||||
if text == "" {
|
||||
text = g.HoverHint()
|
||||
col = th.StatusText
|
||||
}
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
tw := textWidth(text)
|
||||
var x int
|
||||
switch s.Align {
|
||||
case AlignLeft:
|
||||
x = 2
|
||||
case AlignRight:
|
||||
x = w - tw - 2
|
||||
default:
|
||||
x = (w - tw) / 2
|
||||
}
|
||||
g.DrawText(dst, text, x, s.Y, col)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package pncdsl
|
||||
|
||||
import "image/color"
|
||||
|
||||
// Theme is a registry-stored entity carrying every color the built-in
|
||||
// widgets read. Multiple themes can be registered; the active one is
|
||||
// addressed by name on the Game.
|
||||
type Theme struct {
|
||||
Name string
|
||||
|
||||
PanelBG color.Color
|
||||
StatusText color.Color
|
||||
FlashText color.Color
|
||||
|
||||
VerbButtonBG color.Color
|
||||
VerbButtonSelectedBG color.Color
|
||||
VerbButtonText color.Color
|
||||
|
||||
InventorySlotBG color.Color
|
||||
InventorySlotSelectedBG color.Color
|
||||
|
||||
SpeechBubbleBG color.Color
|
||||
SpeechDefaultText color.Color
|
||||
|
||||
DialogBG color.Color
|
||||
DialogBorder color.Color
|
||||
DialogChoiceBG color.Color
|
||||
DialogChoiceHover color.Color
|
||||
DialogSpeaker color.Color
|
||||
DialogText color.Color
|
||||
|
||||
EndCardBG color.Color
|
||||
EndCardText color.Color
|
||||
|
||||
CursorColor color.Color
|
||||
HotspotOutline color.Color
|
||||
|
||||
// SceneBackdrop is painted before the scene background. Useful for
|
||||
// letterboxing or filling areas not covered by a smaller backdrop.
|
||||
SceneBackdrop color.Color
|
||||
}
|
||||
|
||||
func (t Theme) GetName() string { return t.Name }
|
||||
func (t Theme) TypeLabel() string { return "theme" }
|
||||
|
||||
type ThemeManager = Manager[Theme]
|
||||
@@ -0,0 +1,125 @@
|
||||
package pncdsl
|
||||
|
||||
import "image/color"
|
||||
|
||||
// RegisterPresetThemes installs the four built-in themes. NewGame calls
|
||||
// this automatically and selects "classic-scumm".
|
||||
func RegisterPresetThemes(g *Game) {
|
||||
g.ThemeManager.Register(classicScummTheme())
|
||||
g.ThemeManager.Register(sierraCoinTheme())
|
||||
g.ThemeManager.Register(paperNotebookTheme())
|
||||
g.ThemeManager.Register(terminalGreenTheme())
|
||||
}
|
||||
|
||||
func classicScummTheme() Theme {
|
||||
return Theme{
|
||||
Name: "classic-scumm",
|
||||
PanelBG: color.RGBA{20, 20, 30, 255},
|
||||
StatusText: color.White,
|
||||
FlashText: color.RGBA{255, 220, 120, 255},
|
||||
VerbButtonBG: color.RGBA{50, 50, 70, 255},
|
||||
VerbButtonSelectedBG: color.RGBA{120, 90, 40, 255},
|
||||
VerbButtonText: color.White,
|
||||
InventorySlotBG: color.RGBA{40, 40, 50, 255},
|
||||
InventorySlotSelectedBG: color.RGBA{120, 90, 40, 255},
|
||||
SpeechBubbleBG: color.RGBA{0, 0, 0, 200},
|
||||
SpeechDefaultText: color.White,
|
||||
DialogBG: color.RGBA{15, 15, 25, 240},
|
||||
DialogBorder: color.RGBA{80, 80, 110, 255},
|
||||
DialogChoiceBG: color.RGBA{30, 30, 45, 255},
|
||||
DialogChoiceHover: color.RGBA{70, 70, 100, 255},
|
||||
DialogSpeaker: color.RGBA{220, 220, 120, 255},
|
||||
DialogText: color.White,
|
||||
EndCardBG: color.RGBA{0, 0, 0, 230},
|
||||
EndCardText: color.White,
|
||||
CursorColor: color.RGBA{255, 255, 255, 255},
|
||||
HotspotOutline: color.RGBA{255, 255, 0, 200},
|
||||
SceneBackdrop: color.RGBA{0, 0, 0, 255},
|
||||
}
|
||||
}
|
||||
|
||||
func sierraCoinTheme() Theme {
|
||||
return Theme{
|
||||
Name: "sierra-coin",
|
||||
PanelBG: color.RGBA{15, 10, 25, 255},
|
||||
StatusText: color.RGBA{220, 220, 240, 255},
|
||||
FlashText: color.RGBA{255, 200, 100, 255},
|
||||
VerbButtonBG: color.RGBA{40, 30, 70, 255},
|
||||
VerbButtonSelectedBG: color.RGBA{170, 110, 60, 255},
|
||||
VerbButtonText: color.RGBA{240, 240, 255, 255},
|
||||
InventorySlotBG: color.RGBA{30, 20, 50, 255},
|
||||
InventorySlotSelectedBG: color.RGBA{170, 110, 60, 255},
|
||||
SpeechBubbleBG: color.RGBA{10, 10, 20, 220},
|
||||
SpeechDefaultText: color.RGBA{240, 240, 255, 255},
|
||||
DialogBG: color.RGBA{10, 5, 20, 245},
|
||||
DialogBorder: color.RGBA{120, 60, 180, 255},
|
||||
DialogChoiceBG: color.RGBA{25, 15, 45, 255},
|
||||
DialogChoiceHover: color.RGBA{60, 40, 100, 255},
|
||||
DialogSpeaker: color.RGBA{200, 180, 240, 255},
|
||||
DialogText: color.RGBA{240, 240, 255, 255},
|
||||
EndCardBG: color.RGBA{0, 0, 0, 240},
|
||||
EndCardText: color.RGBA{220, 200, 255, 255},
|
||||
CursorColor: color.RGBA{255, 200, 140, 255},
|
||||
HotspotOutline: color.RGBA{200, 140, 255, 180},
|
||||
SceneBackdrop: color.RGBA{0, 0, 0, 255},
|
||||
}
|
||||
}
|
||||
|
||||
func paperNotebookTheme() Theme {
|
||||
cream := color.RGBA{245, 235, 210, 255}
|
||||
ink := color.RGBA{50, 40, 30, 255}
|
||||
return Theme{
|
||||
Name: "paper-notebook",
|
||||
PanelBG: cream,
|
||||
StatusText: ink,
|
||||
FlashText: color.RGBA{180, 50, 50, 255},
|
||||
VerbButtonBG: color.RGBA{225, 215, 190, 255},
|
||||
VerbButtonSelectedBG: color.RGBA{180, 150, 80, 255},
|
||||
VerbButtonText: ink,
|
||||
InventorySlotBG: color.RGBA{230, 220, 195, 255},
|
||||
InventorySlotSelectedBG: color.RGBA{180, 150, 80, 255},
|
||||
SpeechBubbleBG: color.RGBA{255, 255, 245, 235},
|
||||
SpeechDefaultText: ink,
|
||||
DialogBG: color.RGBA{250, 240, 215, 250},
|
||||
DialogBorder: ink,
|
||||
DialogChoiceBG: color.RGBA{235, 225, 200, 255},
|
||||
DialogChoiceHover: color.RGBA{220, 200, 160, 255},
|
||||
DialogSpeaker: color.RGBA{120, 70, 30, 255},
|
||||
DialogText: ink,
|
||||
EndCardBG: color.RGBA{245, 235, 210, 240},
|
||||
EndCardText: ink,
|
||||
CursorColor: ink,
|
||||
HotspotOutline: color.RGBA{120, 70, 30, 200},
|
||||
SceneBackdrop: cream,
|
||||
}
|
||||
}
|
||||
|
||||
func terminalGreenTheme() Theme {
|
||||
black := color.RGBA{8, 12, 8, 255}
|
||||
green := color.RGBA{120, 220, 120, 255}
|
||||
dim := color.RGBA{60, 120, 60, 255}
|
||||
return Theme{
|
||||
Name: "terminal-green",
|
||||
PanelBG: black,
|
||||
StatusText: green,
|
||||
FlashText: color.RGBA{220, 220, 100, 255},
|
||||
VerbButtonBG: color.RGBA{12, 22, 12, 255},
|
||||
VerbButtonSelectedBG: dim,
|
||||
VerbButtonText: green,
|
||||
InventorySlotBG: color.RGBA{12, 22, 12, 255},
|
||||
InventorySlotSelectedBG: dim,
|
||||
SpeechBubbleBG: color.RGBA{0, 16, 0, 220},
|
||||
SpeechDefaultText: green,
|
||||
DialogBG: color.RGBA{0, 16, 0, 240},
|
||||
DialogBorder: green,
|
||||
DialogChoiceBG: color.RGBA{12, 22, 12, 255},
|
||||
DialogChoiceHover: dim,
|
||||
DialogSpeaker: color.RGBA{180, 255, 180, 255},
|
||||
DialogText: green,
|
||||
EndCardBG: color.RGBA{0, 0, 0, 250},
|
||||
EndCardText: green,
|
||||
CursorColor: green,
|
||||
HotspotOutline: dim,
|
||||
SceneBackdrop: black,
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -1,5 +1,8 @@
|
||||
package pncdsl
|
||||
|
||||
// Verb is a registered "action word" (Look, Use, Talk, Take, …). The
|
||||
// VerbBar / RadialVerbs widgets read the VerbManager to know which verbs
|
||||
// to render; Hotspot.handler(name) looks up the bound action by Verb.Name.
|
||||
type Verb struct {
|
||||
Name string
|
||||
Label string
|
||||
@@ -9,8 +12,10 @@ type Verb struct {
|
||||
func (v Verb) GetName() string { return v.Name }
|
||||
func (v Verb) TypeLabel() string { return "verb" }
|
||||
|
||||
// defaultVerbs returns the built-in SCUMM-style verb set. A game can replace
|
||||
// or extend these by registering its own Verbs after NewGame.
|
||||
type VerbManager = Manager[Verb]
|
||||
|
||||
// defaultVerbs returns the built-in SCUMM-style verb set. NewGame
|
||||
// registers them so the demo works out of the box.
|
||||
func defaultVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "look", Label: "Nézd"},
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package pncdsl
|
||||
|
||||
import (
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
)
|
||||
|
||||
// VerbBar is the SCUMM-style permanent verb panel. It reads the
|
||||
// VerbManager every frame so newly-registered verbs show up automatically.
|
||||
type VerbBar struct {
|
||||
Name string
|
||||
Origin Point
|
||||
Cols int
|
||||
Button Size
|
||||
Gap Point
|
||||
|
||||
// PanelBG, if non-nil, paints a backdrop behind the buttons. nil = use Theme.PanelBG.
|
||||
PanelBG bool
|
||||
}
|
||||
|
||||
func (v *VerbBar) GetName() string { return v.Name }
|
||||
|
||||
func (v *VerbBar) buttons(ctx *UICtx) []verbButton {
|
||||
cols := v.Cols
|
||||
if cols <= 0 {
|
||||
cols = 2
|
||||
}
|
||||
names := ctx.Game.VerbManager.Names()
|
||||
out := make([]verbButton, 0, len(names))
|
||||
for i, n := range names {
|
||||
col := i % cols
|
||||
row := i / cols
|
||||
x := int(v.Origin.X) + col*(v.Button.W+int(v.Gap.X))
|
||||
y := int(v.Origin.Y) + row*(v.Button.H+int(v.Gap.Y))
|
||||
verb := ctx.Game.VerbManager.MustGet(n)
|
||||
out = append(out, verbButton{
|
||||
Name: n,
|
||||
Label: verb.Label,
|
||||
Bounds: Rect(float64(x), float64(y), float64(v.Button.W), float64(v.Button.H)),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (v *VerbBar) Tick(ctx *UICtx) {
|
||||
g := ctx.Game
|
||||
if !g.Input.LeftClicked() {
|
||||
return
|
||||
}
|
||||
mp := g.Input.Point()
|
||||
for _, b := range v.buttons(ctx) {
|
||||
if b.Bounds.Contains(mp) {
|
||||
g.SetSelectedVerb(b.Name)
|
||||
g.Input.ConsumeLeft()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (v *VerbBar) Draw(dst *ebiten.Image, ctx *UICtx) {
|
||||
g := ctx.Game
|
||||
th := g.Theme()
|
||||
if v.PanelBG {
|
||||
// rough bounding box of the buttons
|
||||
buttons := v.buttons(ctx)
|
||||
if len(buttons) > 0 {
|
||||
x, y := buttons[0].Bounds.X, buttons[0].Bounds.Y
|
||||
rx, ry := x, y
|
||||
for _, b := range buttons {
|
||||
if b.Bounds.X+b.Bounds.W > rx {
|
||||
rx = b.Bounds.X + b.Bounds.W
|
||||
}
|
||||
if b.Bounds.Y+b.Bounds.H > ry {
|
||||
ry = b.Bounds.Y + b.Bounds.H
|
||||
}
|
||||
}
|
||||
vector.DrawFilledRect(dst, float32(x-2), float32(y-2), float32(rx-x+4), float32(ry-y+4), th.PanelBG, false)
|
||||
}
|
||||
}
|
||||
for _, b := range v.buttons(ctx) {
|
||||
bg := th.VerbButtonBG
|
||||
if b.Name == g.SelectedVerb() {
|
||||
bg = th.VerbButtonSelectedBG
|
||||
}
|
||||
vector.DrawFilledRect(dst, float32(b.Bounds.X), float32(b.Bounds.Y), float32(b.Bounds.W), float32(b.Bounds.H), bg, false)
|
||||
g.DrawText(dst, b.Label, int(b.Bounds.X)+2, int(b.Bounds.Y)-1, th.VerbButtonText)
|
||||
}
|
||||
}
|
||||
|
||||
type verbButton struct {
|
||||
Name string
|
||||
Label string
|
||||
Bounds Rectangle
|
||||
}
|
||||
|
||||
// BlocksClickAt is part of the clickBlocker contract — keeps the
|
||||
// RadialVerbs widget from popping up over the verb bar.
|
||||
func (v *VerbBar) BlocksClickAt(p Point) bool {
|
||||
cols := v.Cols
|
||||
if cols <= 0 {
|
||||
cols = 2
|
||||
}
|
||||
// rough bounding box: rows = ceil(verbCount/cols), but without a Game
|
||||
// reference we approximate by assuming the bar is small (at most 2 rows
|
||||
// in the default SCUMM layout).
|
||||
x := v.Origin.X
|
||||
y := v.Origin.Y
|
||||
w := float64(cols*(v.Button.W+int(v.Gap.X)) - int(v.Gap.X))
|
||||
h := float64(2*(v.Button.H+int(v.Gap.Y)) - int(v.Gap.Y))
|
||||
return Rect(x, y, w, h).Contains(p)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package pncdsl
|
||||
|
||||
type VerbManager = Manager[Verb]
|
||||
@@ -0,0 +1,168 @@
|
||||
package pncdsl
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"math"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
)
|
||||
|
||||
// RadialVerbs is the verb-coin alternative to VerbBar: secondary-click on
|
||||
// the scene opens a radial menu of every registered verb, click on a
|
||||
// slice (or close to its center) picks that verb.
|
||||
//
|
||||
// To use it instead of a VerbBar, register RadialVerbs and skip VerbBar.
|
||||
// Both can coexist if you really want.
|
||||
type RadialVerbs struct {
|
||||
Name string
|
||||
Trigger MouseButton
|
||||
Radius float64
|
||||
|
||||
// runtime
|
||||
visible bool
|
||||
center Point
|
||||
}
|
||||
|
||||
func (r *RadialVerbs) GetName() string { return r.Name }
|
||||
|
||||
func (r *RadialVerbs) Tick(ctx *UICtx) {
|
||||
g := ctx.Game
|
||||
if r.Radius <= 0 {
|
||||
r.Radius = 40
|
||||
}
|
||||
|
||||
if !r.visible {
|
||||
if !r.triggerPressed(g) {
|
||||
return
|
||||
}
|
||||
mp := g.Input.Point()
|
||||
// Don't open over a widget that would also claim this click —
|
||||
// e.g. an InventoryBar. Each widget can declare its dead-zone
|
||||
// via the optional clickBlocker interface.
|
||||
if r.blockedAt(ctx, mp) {
|
||||
return
|
||||
}
|
||||
r.consumeTrigger(g)
|
||||
r.center = mp
|
||||
r.visible = true
|
||||
return
|
||||
}
|
||||
// modal: swallow secondary clicks (close without picking)
|
||||
if r.triggerPressed(g) {
|
||||
r.consumeTrigger(g)
|
||||
r.visible = false
|
||||
return
|
||||
}
|
||||
if !g.Input.LeftClicked() {
|
||||
return
|
||||
}
|
||||
g.Input.ConsumeLeft()
|
||||
idx := r.hitTest(g, g.Input.Point())
|
||||
if idx >= 0 {
|
||||
names := g.VerbManager.Names()
|
||||
g.SetSelectedVerb(names[idx])
|
||||
}
|
||||
r.visible = false
|
||||
}
|
||||
|
||||
func (r *RadialVerbs) triggerPressed(g *Game) bool {
|
||||
if r.Trigger == MouseButtonLeft {
|
||||
return g.Input.LeftClicked()
|
||||
}
|
||||
return g.Input.RightClicked()
|
||||
}
|
||||
|
||||
func (r *RadialVerbs) consumeTrigger(g *Game) {
|
||||
if r.Trigger == MouseButtonLeft {
|
||||
g.Input.ConsumeLeft()
|
||||
return
|
||||
}
|
||||
g.Input.ConsumeRight()
|
||||
}
|
||||
|
||||
// blockedAt walks the registered widgets and asks any clickBlocker
|
||||
// whether its area covers the cursor — so the radial menu doesn't open
|
||||
// over an InventoryBar that would also want this click.
|
||||
func (r *RadialVerbs) blockedAt(ctx *UICtx, p Point) bool {
|
||||
for _, name := range ctx.Game.UIManager.Names() {
|
||||
if name == r.Name {
|
||||
continue
|
||||
}
|
||||
w := ctx.Game.UIManager.MustGet(name)
|
||||
if b, ok := w.(clickBlocker); ok && b.BlocksClickAt(p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// clickBlocker is the optional widget contract for "if the cursor is on
|
||||
// me, claim the click — don't let modal pop-ups open over me." Implemented
|
||||
// by VerbBar, InventoryBar, DialogBox by default.
|
||||
type clickBlocker interface {
|
||||
BlocksClickAt(p Point) bool
|
||||
}
|
||||
|
||||
func (r *RadialVerbs) hitTest(g *Game, p Point) int {
|
||||
verbs := g.VerbManager.Names()
|
||||
n := len(verbs)
|
||||
if n == 0 {
|
||||
return -1
|
||||
}
|
||||
dx := p.X - r.center.X
|
||||
dy := p.Y - r.center.Y
|
||||
dist := math.Hypot(dx, dy)
|
||||
if dist < r.Radius*0.25 || dist > r.Radius*1.4 {
|
||||
return -1
|
||||
}
|
||||
// angle 0 = right, going counter-clockwise; map to slice index
|
||||
a := math.Atan2(dy, dx)
|
||||
if a < 0 {
|
||||
a += 2 * math.Pi
|
||||
}
|
||||
slice := 2 * math.Pi / float64(n)
|
||||
idx := int(a / slice)
|
||||
if idx >= n {
|
||||
idx = n - 1
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
func (r *RadialVerbs) Draw(dst *ebiten.Image, ctx *UICtx) {
|
||||
if !r.visible {
|
||||
return
|
||||
}
|
||||
g := ctx.Game
|
||||
th := g.Theme()
|
||||
verbs := g.VerbManager.Names()
|
||||
n := len(verbs)
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
radius := r.Radius
|
||||
// translucent backdrop disk
|
||||
vector.DrawFilledCircle(dst, float32(r.center.X), float32(r.center.Y), float32(radius*1.15), color.RGBA{0, 0, 0, 140}, true)
|
||||
|
||||
hover := r.hitTest(g, g.Input.Point())
|
||||
slice := 2 * math.Pi / float64(n)
|
||||
for i, name := range verbs {
|
||||
a := slice*float64(i) + slice/2
|
||||
x := r.center.X + math.Cos(a)*radius
|
||||
y := r.center.Y + math.Sin(a)*radius
|
||||
bg := th.VerbButtonBG
|
||||
if i == hover {
|
||||
bg = th.VerbButtonSelectedBG
|
||||
}
|
||||
bw, bh := 34, 14
|
||||
bx := int(x) - bw/2
|
||||
by := int(y) - bh/2
|
||||
vector.DrawFilledRect(dst, float32(bx), float32(by), float32(bw), float32(bh), bg, false)
|
||||
verb := g.VerbManager.MustGet(name)
|
||||
// center label as best we can
|
||||
tw := textWidth(verb.Label)
|
||||
g.DrawText(dst, verb.Label, bx+(bw-tw)/2, by-1, th.VerbButtonText)
|
||||
}
|
||||
// center dot
|
||||
vector.DrawFilledCircle(dst, float32(r.center.X), float32(r.center.Y), 2, th.CursorColor, true)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package pncdsl
|
||||
|
||||
import "github.com/hajimehoshi/ebiten/v2"
|
||||
|
||||
// Widget is the minimal surface a HUD element must implement to participate
|
||||
// in the per-frame loop. The library ships several built-in widgets
|
||||
// (VerbBar, InventoryBar, DialogBox, etc.) and the domain can register
|
||||
// arbitrary new ones — chat panels, minimaps, hotbars — as long as they
|
||||
// satisfy this interface.
|
||||
//
|
||||
// Lifecycle:
|
||||
// - Tick runs once per frame in REVERSE registration order so the
|
||||
// top-most widget gets a chance to consume input first via
|
||||
// ctx.Game.Input.ConsumeLeft / ConsumeRight.
|
||||
// - Draw runs once per frame in REGISTRATION order, so widgets
|
||||
// registered later are painted on top.
|
||||
type Widget interface {
|
||||
Named
|
||||
Tick(ctx *UICtx)
|
||||
Draw(dst *ebiten.Image, ctx *UICtx)
|
||||
}
|
||||
|
||||
// UICtx is the per-tick context handed to widgets. Game is the root
|
||||
// aggregate; DT is seconds since the previous frame.
|
||||
type UICtx struct {
|
||||
Game *Game
|
||||
DT float64
|
||||
}
|
||||
|
||||
// MouseButton identifies which mouse button a widget binds to (e.g. the
|
||||
// RadialVerbs widget uses MouseButtonRight by default).
|
||||
type MouseButton int
|
||||
|
||||
const (
|
||||
MouseButtonLeft MouseButton = iota
|
||||
MouseButtonRight
|
||||
)
|
||||
|
||||
// Size is a {Width, Height} pair in screen-space pixels.
|
||||
type Size struct {
|
||||
W, H int
|
||||
}
|
||||
|
||||
// Align controls horizontal text alignment for widgets that draw a single line.
|
||||
type Align int
|
||||
|
||||
const (
|
||||
AlignLeft Align = iota
|
||||
AlignCenter
|
||||
AlignRight
|
||||
)
|
||||
Reference in New Issue
Block a user