aboutsummaryrefslogtreecommitdiff
path: root/go/ui
diff options
context:
space:
mode:
authorFlorian Fischer <florian.fischer@muhq.space>2022-12-27 14:29:39 +0100
committerFlorian Fischer <florian.fischer@muhq.space>2025-08-20 15:46:38 +0200
commitd6b5eeaa5e639c5ab89b5e1c93075a1b765794dc (patch)
treef87422bb91c2197f88aa8d29092592858764049d /go/ui
parent3c2ee4ee48480a28dc606878503f314ec8862d45 (diff)
downloadmuhqs-game-d6b5eeaa5e639c5ab89b5e1c93075a1b765794dc.tar.gz
muhqs-game-d6b5eeaa5e639c5ab89b5e1c93075a1b765794dc.zip
intermediate commit
Diffstat (limited to 'go/ui')
-rw-r--r--go/ui/buffer.go66
-rw-r--r--go/ui/button.go60
-rw-r--r--go/ui/cardView.go46
-rw-r--r--go/ui/choice.go72
-rw-r--r--go/ui/colors.go7
-rw-r--r--go/ui/hand.go113
-rw-r--r--go/ui/mapView.go271
-rw-r--r--go/ui/prompt.go78
-rw-r--r--go/ui/scrollable.go5
-rw-r--r--go/ui/stateBar.go65
-rw-r--r--go/ui/textBox.go161
-rw-r--r--go/ui/widget.go47
12 files changed, 991 insertions, 0 deletions
diff --git a/go/ui/buffer.go b/go/ui/buffer.go
new file mode 100644
index 00000000..c451d7fc
--- /dev/null
+++ b/go/ui/buffer.go
@@ -0,0 +1,66 @@
+package ui
+
+import (
+ "image/color"
+
+ "github.com/hajimehoshi/ebiten/v2"
+ "github.com/hajimehoshi/ebiten/v2/text"
+
+ "muhq.space/muhqs-game/go/font"
+)
+
+var (
+ BUFFER_BACKGROUND = gray
+ BUFFER_FOREGROUND = color.White
+)
+
+type Buffer struct {
+ WidgetBase
+ lines []string
+ pos int
+}
+
+func NewBuffer(x, y int, width, height int) *Buffer {
+ b := &Buffer{NewWidgetBase(x, y, width, height), []string{}, 0}
+ b.renderImpl = func() *ebiten.Image { return b.render() }
+ return b
+}
+
+func (b *Buffer) render() *ebiten.Image {
+ img := ebiten.NewImage(b.Width, b.Height)
+ img.Fill(BUFFER_BACKGROUND)
+ y := 0
+ for _, line := range b.lines[b.pos:] {
+ bounds := text.BoundString(font.Font18, line)
+ y += bounds.Dy()
+ if y > b.Height {
+ break
+ }
+ text.Draw(img, line, font.Font18, 0, y, BUFFER_FOREGROUND)
+ }
+ return img
+}
+
+func (b *Buffer) AddLines(lines []string) {
+ b.lines = append(b.lines, lines...)
+ b.ForceRedraw()
+}
+
+func (b *Buffer) AddLine(line string) {
+ b.AddLines([]string{line})
+}
+
+func (b *Buffer) ClearLines() {
+ b.lines = []string{}
+ b.ForceRedraw()
+}
+
+func (b *Buffer) RemoveLast() {
+ b.lines = b.lines[:len(b.lines)-1]
+ b.ForceRedraw()
+}
+
+func (b *Buffer) Remove(idx int) {
+ b.lines = append(b.lines[:idx], b.lines[idx+1:]...)
+ b.ForceRedraw()
+}
diff --git a/go/ui/button.go b/go/ui/button.go
new file mode 100644
index 00000000..6454ccac
--- /dev/null
+++ b/go/ui/button.go
@@ -0,0 +1,60 @@
+package ui
+
+import (
+ "image/color"
+
+ "github.com/hajimehoshi/ebiten/v2"
+ "github.com/hajimehoshi/ebiten/v2/ebitenutil"
+ "github.com/hajimehoshi/ebiten/v2/text"
+
+ "muhq.space/muhqs-game/go/font"
+)
+
+type Button interface {
+ Widget
+ Click(x, y int)
+}
+
+type SimpleButton struct {
+ WidgetBase
+ label string
+ OnClick func(b *SimpleButton)
+ BG color.Color
+ FG color.Color
+}
+
+func NewSimpleButton(x, y, w, h int, label string, onClick func(b *SimpleButton)) *SimpleButton {
+ sb := &SimpleButton{NewWidgetBase(x, y, w, h), label, onClick, gray, color.White}
+ sb.renderImpl = func() *ebiten.Image { return sb.render() }
+ return sb
+}
+
+func (b *SimpleButton) render() *ebiten.Image {
+ img := ebiten.NewImage(b.Width, b.Height)
+
+ ebitenutil.DrawRect(img, float64(0), float64(0), float64(b.Width), float64(b.Height), b.BG)
+
+ bounds := text.BoundString(font.Font24, b.label)
+ x := (b.Width - bounds.Dx()) / 2
+ if x < 0 {
+ x = 0
+ }
+
+ y := b.Height - bounds.Dy()/2
+ if y < 0 {
+ y = 0
+ }
+ text.Draw(img, b.label, font.Font24, x, y, b.FG)
+ return img
+}
+
+func (b *SimpleButton) FindObjectAt(x, y int) interface{} {
+ if b.Contains(x, y) {
+ return b
+ }
+ return nil
+}
+
+func (b *SimpleButton) Click(x, y int) {
+ b.OnClick(b)
+}
diff --git a/go/ui/cardView.go b/go/ui/cardView.go
new file mode 100644
index 00000000..a1c004c5
--- /dev/null
+++ b/go/ui/cardView.go
@@ -0,0 +1,46 @@
+package ui
+
+import (
+ "github.com/hajimehoshi/ebiten/v2"
+ "muhq.space/muhqs-game/go/assets"
+)
+
+type CardView struct {
+ WidgetBase
+ cardPath string
+}
+
+func NewScaledCardView(x, y int, width, height int, cardPath string) *CardView {
+ cv := &CardView{NewWidgetBase(x, y, width, height), cardPath}
+ cv.renderImpl = func() *ebiten.Image { return cv.render() }
+ return cv
+}
+
+func NewAutoCardView(x, y int, cardPath string) *CardView {
+ cv := &CardView{NewWidgetBase(x, y, -1, -1), cardPath}
+ cv.renderImpl = func() *ebiten.Image { return cv.render() }
+ return cv
+}
+
+func (cv *CardView) render() *ebiten.Image {
+ card := assets.GetCard(cv.cardPath, "en")
+ op := &ebiten.DrawImageOptions{}
+
+ b := card.Bounds()
+ if cv.Width == -1 && cv.Height == -1 {
+ cv.Width = b.Dx()
+ cv.Height = b.Dy()
+ } else {
+ x_scale := float64(cv.Width) / float64(b.Dx())
+ y_scale := float64(cv.Height) / float64(b.Dy())
+ scale := x_scale
+ if y_scale < x_scale {
+ scale = y_scale
+ }
+ op.GeoM.Scale(scale, scale)
+ }
+
+ img := ebiten.NewImage(cv.Width, cv.Height)
+ img.DrawImage(card, op)
+ return img
+}
diff --git a/go/ui/choice.go b/go/ui/choice.go
new file mode 100644
index 00000000..003c2a6a
--- /dev/null
+++ b/go/ui/choice.go
@@ -0,0 +1,72 @@
+package ui
+
+import (
+ "github.com/hajimehoshi/ebiten/v2"
+ "muhq.space/muhqs-game/go/game"
+)
+
+type Choice struct {
+ X, Y int
+ Width, choiceHeight int
+ choices []string
+ onClick func(*Choice, int, int)
+ buttons []Button
+}
+
+func choiceButtonOnClickDummy(b *SimpleButton) {}
+
+func NewChoice(x, y int, width, choiceHeight int, choices []string,
+ onClick func(*Choice, int, int),
+) *Choice {
+ c := &Choice{x, y, width, choiceHeight, choices, onClick, []Button{}}
+
+ for i, choice := range c.choices {
+ b := NewSimpleButton(c.X, c.Y+i*c.choiceHeight, c.Width, c.choiceHeight, choice,
+ choiceButtonOnClickDummy)
+
+ c.buttons = append(c.buttons, b)
+ }
+ return c
+}
+
+func (c *Choice) Draw(screen *ebiten.Image) {
+ for _, b := range c.buttons {
+ b.Draw(screen)
+ }
+}
+
+func (c *Choice) GetChoosen(x, y int) int {
+ return (y - c.Y) / c.choiceHeight
+}
+
+func (c *Choice) Click(x, y int) {
+ c.onClick(c, x, y)
+}
+
+func (c *Choice) Contains(x, y int) bool {
+ height := len(c.choices) * c.choiceHeight
+ return x >= c.X && x <= c.X+c.Width && y >= c.Y && y <= c.Y+height
+}
+
+func (c *Choice) FindObjectAt(x, y int) interface{} {
+ if c.Contains(x, y) {
+ return c
+ }
+ return nil
+}
+
+func (c *Choice) Render() *ebiten.Image {
+ return nil
+}
+
+func NewActionChoice(x, y int, actions []game.Action, onClick func(*Choice, int, int)) *Choice {
+ // TODO: use dynamic with width and height
+ width := 200
+ choiceHeight := 40
+ labels := make([]string, 0, len(actions))
+ for _, action := range actions {
+ labels = append(labels, action.String())
+ }
+
+ return NewChoice(x, y, width, choiceHeight, labels, onClick)
+}
diff --git a/go/ui/colors.go b/go/ui/colors.go
new file mode 100644
index 00000000..a45aa16b
--- /dev/null
+++ b/go/ui/colors.go
@@ -0,0 +1,7 @@
+package ui
+
+import (
+ "image/color"
+)
+
+var gray = color.RGBA{0x80, 0x80, 0x80, 0xff}
diff --git a/go/ui/hand.go b/go/ui/hand.go
new file mode 100644
index 00000000..f7157232
--- /dev/null
+++ b/go/ui/hand.go
@@ -0,0 +1,113 @@
+package ui
+
+import (
+ "log"
+
+ "github.com/hajimehoshi/ebiten/v2"
+ "muhq.space/muhqs-game/go/assets"
+ "muhq.space/muhqs-game/go/game"
+
+ "golang.org/x/exp/slices"
+)
+
+type HandCard struct {
+ C *game.Card
+}
+
+type HandView struct {
+ WidgetBase
+ hand *game.Hand
+ cardWidth float64
+ cardHeight float64
+ scale float64
+ bottomAligned bool
+ highlights []*game.Card
+}
+
+func NewHandView(x, y, width, height int, h *game.Hand, bottomAligned bool) *HandView {
+ hv := &HandView{
+ WidgetBase: NewWidgetBase(x, y, width, height),
+ hand: h,
+ cardWidth: 0,
+ cardHeight: 0,
+ scale: 0,
+ bottomAligned: bottomAligned,
+ highlights: []*game.Card{},
+ }
+ hv.renderImpl = func() *ebiten.Image { return hv.render() }
+ return hv
+}
+
+func (hv *HandView) render() *ebiten.Image {
+ img := ebiten.NewImage(hv.Width, hv.Height)
+ handCards := hv.hand.Cards()
+ nHandCards := len(handCards)
+ for i, card := range handCards {
+ cardImg := assets.GetCard(card.Path(), "en")
+ if cardImg == nil {
+ log.Fatalf("No image found for %s", card.Path())
+ }
+
+ if hv.scale == 0 {
+ b := cardImg.Bounds()
+ x_scale := (float64(hv.Width) / float64(nHandCards)) / float64(b.Dx())
+ y_scale := float64(hv.Height) / float64(b.Dy())
+ hv.scale = x_scale
+ if y_scale < x_scale {
+ hv.scale = y_scale
+ }
+ hv.cardWidth = float64(b.Dx()) * hv.scale
+ hv.cardHeight = float64(b.Dy()) * hv.scale
+ }
+
+ yOffset := 0.0
+ if hv.bottomAligned {
+ yOffset = float64(hv.Height) - hv.cardHeight
+ }
+
+ op := &ebiten.DrawImageOptions{}
+ op.GeoM.Scale(hv.scale, hv.scale)
+ op.GeoM.Translate(float64(i)*hv.cardWidth, yOffset)
+
+ if slices.Contains(hv.highlights, card) {
+ op.ColorM.Scale(1, 0, 0, 0.5)
+ }
+
+ img.DrawImage(cardImg, op)
+ }
+ return img
+}
+
+func (hv *HandView) FindObjectAt(x, y int) interface{} {
+ if !hv.Contains(x, y) {
+ return nil
+ }
+
+ if hv.bottomAligned && float64(y-hv.Y) < float64(hv.Height)-hv.cardHeight {
+ return nil
+ }
+
+ i := int(float64(x) / hv.cardWidth)
+ if i >= hv.hand.Size() {
+ return nil
+ }
+
+ return HandCard{hv.hand.Cards()[i]}
+}
+
+func (hv *HandView) setHighlights(cards []*game.Card) {
+ hv.highlights = cards
+ hv.ForceRedraw()
+}
+
+func (hv *HandView) HighlightCard(card *game.Card) {
+ hv.setHighlights([]*game.Card{card})
+}
+
+func (hv *HandView) AddHighlightCard(card *game.Card) {
+ hv.setHighlights(append(hv.highlights, card))
+}
+
+func (hv *HandView) ClearHighlights() {
+ hv.setHighlights([]*game.Card{})
+}
diff --git a/go/ui/mapView.go b/go/ui/mapView.go
new file mode 100644
index 00000000..8c83bd41
--- /dev/null
+++ b/go/ui/mapView.go
@@ -0,0 +1,271 @@
+package ui
+
+import (
+ "fmt"
+ "log"
+ "math"
+ "strings"
+ "unicode"
+
+ "golang.org/x/exp/slices"
+
+ "github.com/hajimehoshi/ebiten/v2"
+ "muhq.space/muhqs-game/go/assets"
+ "muhq.space/muhqs-game/go/game"
+)
+
+const (
+ PERMANENT_WIDTH int = 40
+ PERMANENT_HEIGHT int = 40
+
+ TILE_WIDTH int = 50
+ TILE_HEIGHT int = 50
+)
+
+type MapView struct {
+ gameState *game.State
+ mapLayer *ebiten.Image
+ permanentsLayer *ebiten.Image
+ tileHighlights []game.Position
+ permanentsHighlights []game.Permanent
+}
+
+func NewMapView(g *game.State) *MapView {
+ vw := MapView{g, nil, nil, []game.Position{}, []game.Permanent{}}
+ return &vw
+}
+
+func (mv *MapView) Height() int {
+ return len(mv.gameState.Map.Tiles) * TILE_HEIGHT
+}
+
+func rotateStreetImg(x, y int, radian float64, op *ebiten.DrawImageOptions) {
+ // Move the image's center to the screen's upper-left corner.
+ // This is a preparation for rotating. When geometry matrices are applied,
+ // the origin point is the upper-left corner.
+ op.GeoM.Translate(-float64(TILE_WIDTH)/2, -float64(TILE_HEIGHT)/2)
+
+ // Rotate the image. As a result, the anchor point of this rotate is
+ // the center of the image.
+ op.GeoM.Rotate(radian)
+
+ // Reset translation
+ op.GeoM.Translate(float64(TILE_WIDTH)/2, float64(TILE_HEIGHT)/2)
+}
+
+func (vw *MapView) rotateStreet(x, y int, op *ebiten.DrawImageOptions) {
+ connections, left, right, above, below := vw.gameState.Map.FindStreetConnections(x, y)
+
+ if connections == 0 {
+ // This street is not connected to another street ->
+ // check any other non neutral tiles
+ connections, left, right, above, below = vw.gameState.Map.FindAnyConnections(x, y)
+ }
+
+ // This street is not connected to anything. Seams odd!
+ if connections == 0 {
+ log.Printf("Street at (%d, %d) is not connected", x, y)
+ } else if (connections == 1 ||
+ (connections == 2 && ((left && right) || (above && below)))) &&
+ (above || below) {
+ rotateStreetImg(x, y, math.Pi/2, op)
+ } else if connections == 2 {
+ // normal orientation above and right
+ if right && below {
+ rotateStreetImg(x, y, 3*math.Pi/2, op)
+ } else if left && below {
+ rotateStreetImg(x, y, math.Pi, op)
+ } else if above && left {
+ rotateStreetImg(x, y, math.Pi/2, op)
+ }
+ } else if connections == 3 {
+ // normal orientation left above right
+ if above && right && below {
+ rotateStreetImg(x, y, 3*math.Pi/2, op)
+ } else if left && below && right {
+ rotateStreetImg(x, y, math.Pi, op)
+ } else if below && left && above {
+ rotateStreetImg(x, y, math.Pi/2, op)
+ }
+ }
+}
+
+func (vw *MapView) newLayerImage() *ebiten.Image {
+ // TODO: support non symetric maps
+ maxWidth := len(vw.gameState.Map.Tiles[0]) * TILE_WIDTH
+ maxHeight := len(vw.gameState.Map.Tiles) * TILE_HEIGHT
+ return ebiten.NewImage(maxWidth, maxHeight)
+}
+
+func (vw *MapView) drawMapLayer(screen *ebiten.Image) {
+ if vw.mapLayer == nil {
+ vw.mapLayer = vw.newLayerImage()
+
+ x_px, y_px := 0.0, 0.0
+ for y := 0; y < len(vw.gameState.Map.Tiles); y++ {
+ for x := 0; x < len(vw.gameState.Map.Tiles[y]); x++ {
+ pos := game.Position{X: x, Y: y}
+ tile := vw.gameState.Map.TileAt(pos)
+ tileImg := assets.GetTile(tile.Raw)
+
+ op := &ebiten.DrawImageOptions{}
+ // Rotate Street
+ if tile.Type == game.TileTypes.Street {
+ vw.rotateStreet(x, y, op)
+ }
+
+ op.GeoM.Translate(x_px, y_px)
+
+ if slices.Contains(vw.tileHighlights, pos) {
+ op.ColorM.Scale(1, 0, 0, 0.5)
+ }
+
+ vw.mapLayer.DrawImage(tileImg, op)
+
+ x_px += float64(TILE_WIDTH)
+ }
+ x_px = 0
+ y_px += float64(TILE_HEIGHT)
+ }
+ }
+
+ screen.DrawImage(vw.mapLayer, &ebiten.DrawImageOptions{})
+}
+
+func getPermanentSymbol(p game.Permanent, i int) *ebiten.Image {
+ permanentSymbol := assets.GetSymbol(strings.ToLower(p.Card().Name))
+ if permanentSymbol == nil {
+ symbol := fmt.Sprintf("%c%d", unicode.ToUpper(rune(p.Card().Type.String()[0])), i)
+ permanentSymbol = assets.GetGenericSymbol(symbol)
+ if permanentSymbol == nil {
+ log.Fatalf("Failed to generate generic symbol %s", symbol)
+ }
+ }
+
+ return permanentSymbol
+}
+
+func (vw *MapView) drawPermanentsLayer(screen *ebiten.Image) {
+ if vw.permanentsLayer == nil {
+ vw.permanentsLayer = vw.newLayerImage()
+
+ for i, p := range vw.gameState.Permanents {
+ permanentSymbol := getPermanentSymbol(p, i)
+ t := p.Tile()
+ x_px := t.Position.X*TILE_WIDTH + (TILE_WIDTH-PERMANENT_WIDTH)/2
+ y_px := t.Position.Y*TILE_HEIGHT + (TILE_HEIGHT-PERMANENT_HEIGHT)/2
+
+ op := &ebiten.DrawImageOptions{}
+ op.GeoM.Translate(float64(x_px), float64(y_px))
+ op.ColorM.ScaleWithColor(p.Controller().Color)
+
+ for _, h := range vw.permanentsHighlights {
+ if p == h {
+ op.ColorM.Scale(255, 0.5, 0.5, 1)
+ break
+ }
+ }
+
+ vw.permanentsLayer.DrawImage(permanentSymbol, op)
+ }
+ }
+
+ screen.DrawImage(vw.permanentsLayer, &ebiten.DrawImageOptions{})
+}
+
+func (vw *MapView) Draw(screen *ebiten.Image) {
+ vw.drawMapLayer(screen)
+ vw.drawPermanentsLayer(screen)
+}
+
+func (vw *MapView) ForceRedraw() {
+ vw.mapLayer = nil
+ vw.permanentsLayer = nil
+}
+
+func isSymbolTransparentAt(p game.Permanent, relativeX, relativeY int) bool {
+ permanentSymbol := getPermanentSymbol(p, 0)
+ _, _, _, alpha := permanentSymbol.At(relativeX, relativeY).RGBA()
+ return alpha == 0
+}
+
+func (vw *MapView) FindObjectAt(screenX, screenY int) interface{} {
+ x := screenX / TILE_WIDTH
+ y := screenY / TILE_HEIGHT
+
+ if x < 0 || y < 0 {
+ return nil
+ }
+
+ relativeX := screenX % TILE_WIDTH
+ relativeY := screenY % TILE_HEIGHT
+ xMargin := (TILE_WIDTH - PERMANENT_WIDTH) / 2
+ yMargin := (TILE_HEIGHT - PERMANENT_HEIGHT) / 2
+ // detect if a permanent or the containing tile was selected
+ if relativeX >= xMargin && relativeX <= TILE_WIDTH-xMargin &&
+ relativeY >= yMargin && relativeY <= TILE_HEIGHT-yMargin {
+
+ for _, p := range vw.gameState.Permanents {
+ pos := p.Tile().Position
+ if pos.X == x && pos.Y == y {
+ if isSymbolTransparentAt(p, relativeX, relativeY) {
+ break
+ }
+
+ return p
+ }
+ }
+ }
+
+ if y < len(vw.gameState.Map.Tiles) && x < len(vw.gameState.Map.Tiles[y]) {
+ return &vw.gameState.Map.Tiles[y][x]
+ }
+
+ return nil
+}
+
+func (vw *MapView) HighlightPositions(pos []game.Position) {
+ vw.tileHighlights = pos
+ vw.ForceRedraw()
+}
+
+func (vw *MapView) AddHighlightPosition(pos game.Position) {
+ vw.HighlightPositions(append(vw.tileHighlights, pos))
+}
+
+func (vw *MapView) HighlightTiles(tiles []*game.Tile) {
+ pos := make([]game.Position, 0, len(tiles))
+ for _, t := range tiles {
+ pos = append(pos, t.Position)
+ }
+ vw.HighlightPositions(pos)
+}
+
+func (vw *MapView) HighlightTile(t *game.Tile) {
+ vw.HighlightTiles([]*game.Tile{t})
+}
+
+func (vw *MapView) AddHighlightTile(t *game.Tile) {
+ vw.AddHighlightPosition(t.Position)
+}
+
+func (vw *MapView) ClearTileHighlights() {
+ vw.HighlightTiles([]*game.Tile{})
+}
+
+func (vw *MapView) HighlightPermanents(permanents []game.Permanent) {
+ vw.permanentsHighlights = permanents
+ vw.ForceRedraw()
+}
+
+func (vw *MapView) HighlightPermanent(p game.Permanent) {
+ vw.HighlightPermanents([]game.Permanent{p})
+}
+
+func (vw *MapView) ClearPermanentsHighlights() {
+ vw.HighlightPermanents([]game.Permanent{})
+}
+
+func (*MapView) Render(Widget) *ebiten.Image {
+ return nil
+}
diff --git a/go/ui/prompt.go b/go/ui/prompt.go
new file mode 100644
index 00000000..bfcd5d14
--- /dev/null
+++ b/go/ui/prompt.go
@@ -0,0 +1,78 @@
+package ui
+
+import (
+ "log"
+
+ "github.com/hajimehoshi/ebiten/v2"
+
+ "muhq.space/muhqs-game/go/game"
+)
+
+type promptType int
+
+const (
+ handCard promptType = iota
+
+ PROMPT_HEIGHT int = 50
+)
+
+type Prompt struct {
+ promptType promptType
+ y, width int
+ sel []interface{}
+ components []Widget
+}
+
+func NewHandCardPrompt(y, width int, promptText string) *Prompt {
+ p := &Prompt{
+ y: y,
+ width: width,
+ promptType: handCard,
+ components: []Widget{NewCenteringFixedTextBox(0, y, width, PROMPT_HEIGHT, promptText)},
+ }
+ return p
+}
+
+func (p *Prompt) Add(obj interface{}) {
+ var ok bool
+ switch p.promptType {
+ case handCard:
+ _, ok = obj.(HandCard)
+ }
+
+ if !ok {
+ log.Fatalf("Invalid type %T for prompt %d", obj, p.promptType)
+ }
+
+ p.sel = append(p.sel, obj)
+}
+
+func (p *Prompt) Confirm() game.Action {
+ switch p.promptType {
+ case handCard:
+ cards := make([]*game.Card, 0, len(p.sel))
+ for _, c := range p.sel {
+ cards = append(cards, c.(HandCard).C)
+ }
+ return game.NewHandCardSelection(cards)
+ }
+
+ return nil
+}
+
+func (p *Prompt) Height() int {
+ return PROMPT_HEIGHT
+}
+
+func (p *Prompt) Draw(screen *ebiten.Image) {
+ for _, comp := range p.components {
+ comp.Draw(screen)
+ }
+}
+
+func (p *Prompt) Contains(x, y int) bool {
+ return x >= 0 && y >= 0 && x <= p.width && y >= p.y && y <= p.y+PROMPT_HEIGHT
+}
+
+func (p *Prompt) FindObjectAt(screenX, screenY int) interface{} { return nil }
+func (*Prompt) Render(Widget) *ebiten.Image { return nil }
diff --git a/go/ui/scrollable.go b/go/ui/scrollable.go
new file mode 100644
index 00000000..90e0a3a5
--- /dev/null
+++ b/go/ui/scrollable.go
@@ -0,0 +1,5 @@
+package ui
+
+type Scrollable interface {
+ Scroll(x, y int)
+}
diff --git a/go/ui/stateBar.go b/go/ui/stateBar.go
new file mode 100644
index 00000000..6914de6e
--- /dev/null
+++ b/go/ui/stateBar.go
@@ -0,0 +1,65 @@
+package ui
+
+import (
+ "fmt"
+
+ "github.com/hajimehoshi/ebiten/v2"
+ "muhq.space/muhqs-game/go/game"
+)
+
+type StateBar struct {
+ X, Y int
+ Width, Height int
+ gameState *game.State
+ activePlayer *game.Player
+ resourceView *TextBox
+ phaseView *TextBox
+}
+
+func NewStateBar(x, y int, width, height int, s *game.State, p *game.Player) *StateBar {
+ sb := &StateBar{x, y, width, height, s, p, nil, nil}
+ return sb
+}
+
+func (sb *StateBar) resourceLabel() string {
+ return fmt.Sprintf("%d", sb.activePlayer.Resource)
+}
+
+func (sb *StateBar) phaseLabel() string {
+ return fmt.Sprintf("%s: %s", sb.gameState.ActivePlayer.Name,
+ sb.gameState.ActivePhase.String())
+}
+
+func (sb *StateBar) Draw(screen *ebiten.Image) {
+ if sb.resourceView == nil {
+ sb.resourceView = NewCenteringFixedTextBox(sb.X, sb.Y, sb.Height, sb.Height,
+ sb.resourceLabel())
+ }
+ sb.resourceView.Draw(screen)
+
+ if sb.phaseView == nil {
+ sb.phaseView = NewFixedTextBox(sb.X+sb.resourceView.Width, sb.Y,
+ sb.Width-sb.resourceView.Width, sb.Height, sb.phaseLabel(), false)
+ }
+ sb.phaseView.Draw(screen)
+}
+
+func (sb *StateBar) FindObjectAt(x, y int) interface{} {
+ return nil
+}
+
+func (sb *StateBar) ForceRedraw() {
+ if sb.resourceView != nil {
+ sb.resourceView.Text = sb.resourceLabel()
+ sb.resourceView.ForceRedraw()
+ }
+
+ if sb.phaseView != nil {
+ sb.phaseView.Text = sb.phaseLabel()
+ sb.phaseView.ForceRedraw()
+ }
+}
+
+func (sb *StateBar) Contains(x, y int) bool {
+ return sb.resourceView.Contains(x, y) || sb.phaseView.Contains(x, y)
+}
diff --git a/go/ui/textBox.go b/go/ui/textBox.go
new file mode 100644
index 00000000..78199410
--- /dev/null
+++ b/go/ui/textBox.go
@@ -0,0 +1,161 @@
+package ui
+
+import (
+ "fmt"
+ "image/color"
+
+ "github.com/hajimehoshi/ebiten/v2"
+ "github.com/hajimehoshi/ebiten/v2/text"
+ "muhq.space/muhqs-game/go/font"
+ "muhq.space/muhqs-game/go/game"
+)
+
+var (
+ TEXTBOX_DEFAULT_BACKGROUND = gray
+ TEXTBOX_DEFAULT_FOREGROUND = color.White
+
+ TEXTBOX_DEFAULT_MARGIN = 3
+)
+
+type TextBox struct {
+ WidgetBase
+ Text string
+ Fg color.Color
+ Bg color.Color
+ XMargin int
+ YMargin int
+ Center bool
+}
+
+func NewFixedTextBox(x, y int, width, height int, t string, center bool) *TextBox {
+ tb := &TextBox{
+ WidgetBase: NewWidgetBase(x, y, width, height),
+ Text: t,
+ Fg: TEXTBOX_DEFAULT_FOREGROUND,
+ Bg: TEXTBOX_DEFAULT_BACKGROUND,
+ XMargin: TEXTBOX_DEFAULT_MARGIN,
+ YMargin: TEXTBOX_DEFAULT_MARGIN,
+ Center: center,
+ }
+ tb.renderImpl = func() *ebiten.Image { return tb.render() }
+ return tb
+}
+
+func NewCenteringFixedTextBox(x, y int, width, height int, t string) *TextBox {
+ return NewFixedTextBox(x, y, width, height, t, true)
+}
+
+func NewAutoTextBox(x, y int, t string) *TextBox {
+ return NewFixedTextBox(x, y, -1, -1, t, false)
+}
+
+func NewCenteringAutoTextBox(x, y int, t string) *TextBox {
+ return NewFixedTextBox(x, y, -1, -1, t, true)
+}
+
+func NewUnitInfo(x, y int, u *game.Unit) *TextBox {
+ info := fmt.Sprintf("%s\nDamage: %d\nHealth: %d\nUpkeep: %d",
+ u.String(), u.Damage(), u.Health, u.Upkeep)
+
+ if u.Movement != game.INVALID_MOVEMENT() {
+ info = fmt.Sprintf("%s\nMovement: %s", info, u.Movement.String())
+ }
+
+ if u.Attack != game.INVALID_ATTACK() {
+ info = fmt.Sprintf("%s\nAttack: %s", info, u.Attack.String())
+ }
+
+ return NewAutoTextBox(x, y, info)
+}
+
+func NewPermInfo(x, y int, p game.Permanent) *TextBox {
+ info := fmt.Sprintf("%s\nDamage: %d", p.String(), p.Damage())
+ return NewAutoTextBox(x, y, info)
+}
+
+func (tb *TextBox) render() *ebiten.Image {
+ var x, y int
+ b := text.BoundString(font.Font24, tb.Text)
+ if tb.Width == -1 || tb.Height == -1 {
+ tb.Width = b.Dx() + 2*tb.XMargin
+ tb.Height = b.Dy() + 2*tb.YMargin
+ x = tb.XMargin + b.Min.X
+ y = tb.YMargin - b.Min.Y
+ } else {
+ if !tb.Center {
+ x = tb.XMargin + b.Min.X
+ } else {
+ x = (tb.Width - b.Dx()) / 2
+ if x < 0 {
+ x = 0
+ }
+ }
+
+ y = tb.Height - b.Dy()/2
+ if y < 0 {
+ y = 0
+ }
+ }
+
+ img := ebiten.NewImage(tb.Width, tb.Height)
+ img.Fill(tb.Bg)
+ text.Draw(img, tb.Text, font.Font24, x, y, tb.Fg)
+ return img
+}
+
+type PocList struct {
+ TextBox
+ centerX int
+ bottomY int
+ poc game.PileOfCards
+}
+
+func NewPocList(centerX, bottomY int, poc game.PileOfCards) *PocList {
+ w := &PocList{
+ TextBox: TextBox{
+ WidgetBase: NewWidgetBase(-1, -1, -1, -1),
+ Text: "",
+ Fg: TEXTBOX_DEFAULT_FOREGROUND,
+ Bg: TEXTBOX_DEFAULT_BACKGROUND,
+ XMargin: TEXTBOX_DEFAULT_MARGIN,
+ YMargin: TEXTBOX_DEFAULT_MARGIN,
+ Center: true,
+ },
+ centerX: centerX,
+ bottomY: bottomY,
+ poc: poc,
+ }
+ w.renderImpl = func() *ebiten.Image {
+ w.setText()
+ return w.render()
+ }
+ return w
+}
+
+func (w *PocList) setText() {
+ t := ""
+ for _, c := range w.poc.Cards() {
+ t = fmt.Sprintf("%s%s\n", t, c.Name)
+ }
+
+ if t != "" {
+ w.Text = t[:len(t)-1]
+ } else {
+ w.Text = "No cards"
+ }
+
+ b := text.BoundString(font.Font24, w.Text)
+ w.X = w.centerX - b.Dx()/2 - w.XMargin
+ w.Y = w.bottomY - b.Dy() - w.YMargin
+}
+
+func (w *PocList) FindObjectAt(x, y int) interface{} {
+ cards := w.poc.Cards()
+ if len(cards) == 0 {
+ return nil
+ }
+
+ // b := text.BoundString(font.Font, w.Text)
+ // i := b.Dy() / w.poc.Size()
+ return cards[0]
+}
diff --git a/go/ui/widget.go b/go/ui/widget.go
new file mode 100644
index 00000000..cfcdd766
--- /dev/null
+++ b/go/ui/widget.go
@@ -0,0 +1,47 @@
+package ui
+
+import (
+ "github.com/hajimehoshi/ebiten/v2"
+)
+
+type Widget interface {
+ Draw(*ebiten.Image)
+ FindObjectAt(int, int) interface{}
+ Contains(int, int) bool
+}
+
+type WidgetBase struct {
+ X, Y int
+ Width, Height int
+ img *ebiten.Image
+ renderImpl func() *ebiten.Image
+}
+
+func renderDummy() *ebiten.Image {
+ return nil
+}
+
+func NewWidgetBase(x, y, width, height int) WidgetBase {
+ return WidgetBase{x, y, width, height, nil, renderDummy}
+}
+
+func (w *WidgetBase) Draw(screen *ebiten.Image) {
+ if w.img == nil {
+ w.img = w.renderImpl()
+ }
+ op := &ebiten.DrawImageOptions{}
+ op.GeoM.Translate(float64(w.X), float64(w.Y))
+ screen.DrawImage(w.img, op)
+}
+
+func (w *WidgetBase) ForceRedraw() {
+ w.img = nil
+}
+
+func (w *WidgetBase) Contains(x, y int) bool {
+ return x >= 0 && y >= 0 && x >= w.X && x <= w.X+w.Width && y >= w.Y && y <= w.Y+w.Height
+}
+
+func (w *WidgetBase) FindObjectAt(int, int) interface{} {
+ return nil
+}