241 lines
5.8 KiB
Go
241 lines
5.8 KiB
Go
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
|
||
|
||
// 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
|
||
|
||
// Labels overrides the rendered verb label per verb Name. Useful when
|
||
// the regular Verb.Label is too long to fit inside the wheel — e.g.
|
||
// {"use": "Tedd", "take": "Vedd"}. Falls back to Verb.Label.
|
||
Labels map[string]string
|
||
|
||
// 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.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
|
||
}
|
||
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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
g := ctx.Game
|
||
th := g.Theme()
|
||
verbs := g.VerbManager.Names()
|
||
n := len(verbs)
|
||
if n == 0 {
|
||
return
|
||
}
|
||
radius := r.Radius
|
||
|
||
cx, cy := float32(r.center.X), float32(r.center.Y)
|
||
|
||
// translucent backdrop disk that defines the wheel area
|
||
vector.DrawFilledCircle(dst, cx, cy, float32(radius), color.RGBA{0, 0, 0, 170}, true)
|
||
|
||
hover := r.hitTest(g, g.Input.Point())
|
||
selected := g.SelectedVerb()
|
||
slice := 2 * math.Pi / float64(n)
|
||
|
||
// Labels live INSIDE the disk, at 0.62×radius from the center, so a
|
||
// short (3–5 char) word stays comfortably within the rim.
|
||
labelDist := radius * 0.62
|
||
for i, name := range verbs {
|
||
a := slice*float64(i) + slice/2
|
||
lx := r.center.X + math.Cos(a)*labelDist
|
||
ly := r.center.Y + math.Sin(a)*labelDist
|
||
|
||
label := r.labelFor(g, name)
|
||
tw := textWidth(label)
|
||
col := th.VerbButtonText
|
||
isSelected := name == selected
|
||
isHover := i == hover
|
||
if isSelected || isHover {
|
||
// Filled halo behind the label. Selected = stronger, hover =
|
||
// lighter overlay on top so a hover over a non-selected verb
|
||
// still shows feedback.
|
||
haloR := float32(radius * 0.28)
|
||
if isSelected {
|
||
vector.DrawFilledCircle(dst, float32(lx), float32(ly+4), haloR, th.VerbButtonSelectedBG, true)
|
||
}
|
||
if isHover && !isSelected {
|
||
vector.DrawFilledCircle(dst, float32(lx), float32(ly+4), haloR, th.VerbButtonBG, true)
|
||
}
|
||
}
|
||
g.DrawText(dst, label, int(lx)-tw/2, int(ly)-7, col)
|
||
}
|
||
|
||
// center dot
|
||
vector.DrawFilledCircle(dst, cx, cy, 2.5, th.CursorColor, true)
|
||
}
|
||
|
||
func (r *RadialVerbs) labelFor(g *Game, verbName string) string {
|
||
if r.Labels != nil {
|
||
if alt, ok := r.Labels[verbName]; ok {
|
||
return alt
|
||
}
|
||
}
|
||
if v, ok := g.VerbManager.Get(verbName); ok {
|
||
return v.Label
|
||
}
|
||
return verbName
|
||
}
|