widgets
This commit is contained in:
+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)
|
||||
|
||||
Reference in New Issue
Block a user