aboutsummaryrefslogtreecommitdiff
path: root/go
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
parent3c2ee4ee48480a28dc606878503f314ec8862d45 (diff)
downloadmuhqs-game-d6b5eeaa5e639c5ab89b5e1c93075a1b765794dc.tar.gz
muhqs-game-d6b5eeaa5e639c5ab89b5e1c93075a1b765794dc.zip
intermediate commit
Diffstat (limited to 'go')
-rw-r--r--go/.gitignore4
-rw-r--r--go/Makefile43
-rw-r--r--go/assets/retrieve.go100
-rw-r--r--go/client/game.go531
-rw-r--r--go/client/main.go95
-rw-r--r--go/client/startMenu.go92
-rw-r--r--go/font/font.go38
-rw-r--r--go/game/action.go351
-rw-r--r--go/game/ai.go343
-rw-r--r--go/game/ai_test.go66
-rw-r--r--go/game/artifact.go51
-rw-r--r--go/game/attack.go50
-rw-r--r--go/game/card.go316
-rw-r--r--go/game/cardImplementations.go56
-rw-r--r--go/game/cardParsing.go179
-rw-r--r--go/game/deck.go77
-rw-r--r--go/game/discardPiles.go10
-rw-r--r--go/game/equipment.go5
-rw-r--r--go/game/hand.go10
-rw-r--r--go/game/kraken.go179
-rw-r--r--go/game/map.go303
-rw-r--r--go/game/map_test.go85
-rw-r--r--go/game/movement.go52
-rw-r--r--go/game/permanent.go131
-rw-r--r--go/game/phases.go42
-rw-r--r--go/game/pileOfCards.go63
-rw-r--r--go/game/player.go140
-rw-r--r--go/game/playerControl.go92
-rw-r--r--go/game/pos.go22
-rw-r--r--go/game/range.go58
-rw-r--r--go/game/range_test.go61
-rw-r--r--go/game/set.go78
-rw-r--r--go/game/stack.go46
-rw-r--r--go/game/state.go453
-rw-r--r--go/game/store.go10
-rw-r--r--go/game/targets.go307
-rw-r--r--go/game/tile.go123
-rw-r--r--go/game/unit.go211
-rw-r--r--go/game/winCondition.go36
-rw-r--r--go/go.mod22
-rw-r--r--go/go.sum102
-rw-r--r--go/server/main.go13
-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
-rw-r--r--go/utils/slices.go9
-rw-r--r--go/webtools/main.go48
56 files changed, 6094 insertions, 0 deletions
diff --git a/go/.gitignore b/go/.gitignore
new file mode 100644
index 00000000..6e729d48
--- /dev/null
+++ b/go/.gitignore
@@ -0,0 +1,4 @@
+client/client
+client/client.wasm
+server/server
+webtools/webtools.wasm
diff --git a/go/Makefile b/go/Makefile
new file mode 100644
index 00000000..88c32ebe
--- /dev/null
+++ b/go/Makefile
@@ -0,0 +1,43 @@
+PACKAGES := assets game server client ui
+
+APPS := server client
+OBJS := $(foreach app,$(APPS),$(app)/$(app))
+WASM := client/client.wasm webtools/webtools.wasm
+
+GOFMT := gofumpt -l -w .
+
+.PHONY: client server
+client: client/client
+server: server/server
+
+.PHONY: all fmt test check $(OBJS) $(WASM)
+all: $(OBJS) $(WASM)
+
+
+$(OBJS):
+ @echo Building $(@F)
+ @pushd $(@D) >/dev/null && go build && popd >/dev/null
+
+*/*.wasm:
+ @echo Building $(@F) ...
+ @pushd $(@D) >/dev/null && env GOOS=js GOARCH=wasm go build -o $(@F) && popd >/dev/null
+
+fmt:
+ @set -e; for package in $(PACKAGES) webtools; do \
+ pushd $$package >/dev/null && $(GOFMT); popd >/dev/null; \
+ done
+
+test:
+ @STATUS=0; \
+ for package in $(PACKAGES); do \
+ if pushd "$$package" >/dev/null; then \
+ go test; \
+ status="$$?"; \
+ if [ "$$status" -ne 0 ]; then STATUS="$$status"; fi; \
+ popd >/dev/null; \
+ fi; \
+ done; \
+ exit "$$STATUS"
+
+check:
+ golangci-lint run --skip-dirs webtools
diff --git a/go/assets/retrieve.go b/go/assets/retrieve.go
new file mode 100644
index 00000000..d9b40643
--- /dev/null
+++ b/go/assets/retrieve.go
@@ -0,0 +1,100 @@
+package assets
+
+import (
+ "image/color"
+ "image/png"
+ "log"
+ "net/http"
+ "path"
+ "strings"
+
+ "github.com/hajimehoshi/ebiten/v2"
+ "github.com/hajimehoshi/ebiten/v2/text"
+
+ "muhq.space/muhqs-game/go/font"
+)
+
+const (
+ PROTOCOL string = "http://"
+ BASE_URL string = "localhost:8000/"
+ IMAGE_URL_PART string = "html/build/latex-build"
+ TILE_URL_PART string = "assets/tiles"
+ SYMBOL_URL_PART string = "assets/symbols/40x40"
+)
+
+func retrieveAsset(path string) *ebiten.Image {
+ log.Printf("loading %s\n", path)
+ resp, err := http.Get(PROTOCOL + path)
+ if err != nil {
+ log.Println(err)
+ return nil
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil
+ }
+
+ img, err := png.Decode(resp.Body)
+ if err != nil {
+ log.Fatal("decoding ", err)
+ }
+ return ebiten.NewImageFromImage(img)
+}
+
+var cards = map[string]map[string]map[string]*ebiten.Image{}
+
+func GetCard(cardPath string, language string) *ebiten.Image {
+ card := path.Base(cardPath)
+ set := path.Dir(cardPath)
+ img, found := cards[language][set][card]
+ if !found {
+ url := path.Join(BASE_URL, IMAGE_URL_PART, language, cardPath+".png")
+ img = retrieveAsset(url)
+
+ if cards[language] == nil {
+ cards[language] = map[string]map[string]*ebiten.Image{}
+ }
+
+ if cards[language][set] == nil {
+ cards[language][set] = map[string]*ebiten.Image{}
+ }
+
+ cards[language][set][card] = img
+ }
+ return img
+}
+
+var tiles = map[string]*ebiten.Image{}
+
+func GetTile(tile string) *ebiten.Image {
+ img, found := tiles[tile]
+ if !found {
+ url := path.Join(BASE_URL, TILE_URL_PART, strings.Replace(tile, " ", "_", -1)+".png")
+ img = retrieveAsset(url)
+ tiles[tile] = img
+ }
+ return img
+}
+
+var symbols = map[string]*ebiten.Image{}
+
+func GetSymbol(symbol string) *ebiten.Image {
+ img, found := symbols[symbol]
+ if !found {
+ url := path.Join(BASE_URL, SYMBOL_URL_PART, symbol+".png")
+ img = retrieveAsset(url)
+ symbols[symbol] = img
+ }
+ return img
+}
+
+func GetGenericSymbol(symbol string) *ebiten.Image {
+ img, found := symbols[symbol]
+ if !found {
+ genericBase := GetSymbol("generic_base")
+ img = ebiten.NewImageFromImage(genericBase)
+ text.Draw(img, symbol, font.Font24, 5, 27, color.Black)
+ }
+ return img
+}
diff --git a/go/client/game.go b/go/client/game.go
new file mode 100644
index 00000000..00ef4153
--- /dev/null
+++ b/go/client/game.go
@@ -0,0 +1,531 @@
+package main
+
+import (
+ "fmt"
+ "log"
+
+ "github.com/hajimehoshi/ebiten/v2"
+ "github.com/hajimehoshi/ebiten/v2/ebitenutil"
+ "github.com/hajimehoshi/ebiten/v2/inpututil"
+
+ "muhq.space/muhqs-game/go/game"
+ "muhq.space/muhqs-game/go/ui"
+)
+
+const (
+ DEFAULT_BUTTON_HEIGHT = 40
+ PASS_BUTTON_WIDTH = 100
+
+ RESOLVE_BUTTON_X = 500
+ STACK_BUFFER_WIDTH = 300
+
+ HOVER_THRESHOLD = 60
+
+ HAND_VIEW_HEIGHT = 500
+ HAND_VIEW_WIDTH = 500
+
+ STATE_BAR_WIDTH = 300
+)
+
+// A simple structure to detect if the cursor has not moved for HOVER_THRESHOLD
+// ticks.
+// hoverDetector.update must be called during each Update invocation to track the
+// frame and the current cursor position.
+type hoverDetector struct {
+ last_x, last_y int
+ ticks int
+ // ResetFunc is called when the cursor has moved.
+ // If resetFunc returns true the hoverDetector is reset
+ // otherwise the reset is skipped.
+ resetFunc func() bool
+}
+
+func (h *hoverDetector) update(x, y int) {
+ if x == h.last_x && y == h.last_y {
+ h.ticks++
+ } else {
+ h.last_x, h.last_y = x, y
+ h.reset()
+ }
+}
+
+func (h *hoverDetector) reset() {
+ if h.resetFunc != nil && !h.resetFunc() {
+ return
+ }
+ h.ticks = 0
+ h.resetFunc = nil
+}
+
+func (h *hoverDetector) startedHovering() bool {
+ return h.ticks == HOVER_THRESHOLD
+}
+
+type Game struct {
+ width, height int
+
+ selectedObject interface{}
+ hoverDetector hoverDetector
+
+ handLayer *ui.HandView
+ stateBar *ui.StateBar
+ mapView *ui.MapView
+ actionChoice *ui.Choice
+ passButton *ui.SimpleButton
+ discardPileView *ui.PocList
+ discardPileButton *ui.SimpleButton
+ storeView *ui.PocList
+ storeButton *ui.SimpleButton
+ stackBuffer *ui.Buffer
+ prompt *ui.Prompt
+ widgets []ui.Widget
+
+ activePlayer *game.Player
+
+ playerCtrl *game.ChanPlayerControl
+ hasPriority bool
+
+ gameState *game.State
+ storesOnMap bool
+}
+
+func newGame(app *app) *Game {
+ g := &Game{
+ width: app.windowWidth,
+ height: app.windowHeight,
+ }
+
+ g.widgets = []ui.Widget{}
+ g.gameState = game.NewState()
+
+ g.passButton = ui.NewSimpleButton(g.width-PASS_BUTTON_WIDTH,
+ g.height-DEFAULT_BUTTON_HEIGHT,
+ PASS_BUTTON_WIDTH,
+ DEFAULT_BUTTON_HEIGHT,
+ "Pass",
+ func(*ui.SimpleButton) {
+ if g.prompt != nil {
+ a := g.prompt.Confirm()
+ g.declareAction(a)
+ g.removeWidget(g.prompt)
+ g.prompt = nil
+
+ } else if g.hasPriority {
+ g.passPriority()
+ }
+ })
+
+ g.stackBuffer = ui.NewBuffer(g.width-STACK_BUFFER_WIDTH, g.passButton.Y-400,
+ STACK_BUFFER_WIDTH, 400)
+ return g
+}
+
+func (g *Game) loadMap(mapName string) *Game {
+ m, err := game.GetMap(mapName)
+ if err != nil {
+ log.Fatal(err)
+ }
+ g.gameState.Map = m
+ g.mapView = ui.NewMapView(g.gameState)
+
+ storeTiles := m.FilterTiles(func(t *game.Tile) bool { return t.Type == game.TileTypes.Store })
+ g.storesOnMap = len(storeTiles) > 0
+ return g
+}
+
+func (g *Game) addActivePlayer(name string, deckList string) *Game {
+ deck := game.NewDeckFromDeckList(deckList)
+ color := playerColors[len(g.gameState.Players)]
+ g.gameState.AddNewPlayer(name, deck)
+ p := g.getPlayer(name)
+ p.Color = color
+ return g.initPlayerUi(p)
+}
+
+func (g *Game) getPlayer(name string) *game.Player {
+ for _, p := range g.gameState.Players {
+ if p.Name == name {
+ return p
+ }
+ }
+ return nil
+}
+
+func (g *Game) initPlayerUi(player *game.Player) *Game {
+ g.activePlayer = player
+ g.playerCtrl = game.NewChanPlayerControl()
+ player.Ctrl = g.playerCtrl
+ var x, y int
+
+ g.handLayer = ui.NewHandView(0,
+ g.mapView.Height(),
+ HAND_VIEW_WIDTH,
+ g.height-g.mapView.Height()-DEFAULT_BUTTON_HEIGHT,
+ player.Hand,
+ true)
+ g.addWidget(g.handLayer)
+
+ g.stateBar = ui.NewStateBar(0,
+ g.height-DEFAULT_BUTTON_HEIGHT,
+ STATE_BAR_WIDTH,
+ DEFAULT_BUTTON_HEIGHT,
+ g.gameState,
+ player)
+ g.addWidget(g.stateBar)
+
+ g.discardPileButton = ui.NewSimpleButton(g.stateBar.Width,
+ g.height-DEFAULT_BUTTON_HEIGHT,
+ 150,
+ DEFAULT_BUTTON_HEIGHT,
+ "DiscardPile",
+ func(*ui.SimpleButton) {
+ if idx := g.findWidget(g.discardPileView); idx == -1 {
+ g.discardPileView.ForceRedraw()
+ g.addWidget(g.discardPileView)
+ } else {
+ g.removeWidget(g.discardPileView)
+ }
+ })
+ g.addWidget(g.discardPileButton)
+
+ x = g.discardPileButton.X + g.discardPileButton.Width/2
+ y = g.height - DEFAULT_BUTTON_HEIGHT
+ g.discardPileView = ui.NewPocList(x, y, g.activePlayer.DiscardPile)
+
+ if !g.storesOnMap {
+ x = g.stateBar.Width + g.discardPileButton.Width
+ y = g.height - DEFAULT_BUTTON_HEIGHT
+ g.storeButton = ui.NewSimpleButton(x, y,
+ 150,
+ DEFAULT_BUTTON_HEIGHT,
+ "Store",
+ func(*ui.SimpleButton) {
+ if idx := g.findWidget(g.storeView); idx == -1 {
+ g.showStore()
+ } else {
+ g.hideStore()
+ }
+ })
+ g.addWidget(g.storeView)
+
+ x = g.storeButton.X + g.storeButton.Width/2
+ y = g.height - DEFAULT_BUTTON_HEIGHT
+ g.storeView = ui.NewPocList(x, y, g.activePlayer.Store)
+ }
+
+ return g
+}
+
+func (g *Game) showStore() {
+ g.storeView.ForceRedraw()
+ g.addWidget(g.storeView)
+}
+
+func (g *Game) hideStore() {
+ g.removeWidget(g.storeView)
+}
+
+func (g *Game) addWidget(w ui.Widget) {
+ g.widgets = append(g.widgets, w)
+}
+
+func (g *Game) findWidget(widget ui.Widget) int {
+ for i, w := range g.widgets {
+ if w != widget {
+ continue
+ }
+ return i
+ }
+ return -1
+}
+
+func (g *Game) removeWidget(widget ui.Widget) {
+ idx := g.findWidget(widget)
+ if idx != -1 {
+ g.removeWidgetAt(idx)
+ }
+}
+
+func (g *Game) removeWidgetAt(idx int) {
+ nwidgets := len(g.widgets)
+ g.widgets[idx] = g.widgets[nwidgets-1]
+ g.widgets = g.widgets[:nwidgets-1]
+}
+
+func (g *Game) addActionChoice(perm game.Permanent, x, y int) {
+ actions := perm.AvailActions()
+ if len(actions) > 0 {
+ g.selectedObject = perm
+ onClick := func(c *ui.Choice, x, y int) {
+ g.removeActionChoice()
+ a := actions[c.GetChoosen(x, y)]
+ g.selectedObject = a
+ }
+ g.actionChoice = ui.NewActionChoice(x, y, actions, onClick)
+ g.addWidget(g.actionChoice)
+ }
+}
+
+func (g *Game) removeActionChoice() {
+ g.removeWidget(g.actionChoice)
+ g.actionChoice = nil
+}
+
+func (g *Game) AddHighlight(obj interface{}) {
+ switch obj := obj.(type) {
+ case ui.HandCard:
+ g.handLayer.AddHighlightCard(obj.C)
+ case *game.Tile:
+ g.mapView.AddHighlightTile(obj)
+ }
+}
+
+func (g *Game) clearMapHighlights() {
+ g.mapView.ClearPermanentsHighlights()
+ g.mapView.ClearTileHighlights()
+ g.mapView.ForceRedraw()
+}
+
+func (g *Game) declareAction(a game.Action) {
+ g.playerCtrl.SendAction(a)
+ g.clearMapHighlights()
+ g.handLayer.ClearHighlights()
+ g.hasPriority = false
+ g.hidePassButton()
+ g.stateBar.ForceRedraw()
+}
+
+func (g *Game) showPassButton() {
+ g.addWidget(g.passButton)
+}
+
+func (g *Game) hidePassButton() {
+ g.removeWidget(g.passButton)
+}
+
+func (g *Game) passPriority() {
+ g.declareAction(game.NewPassPriority(g.activePlayer))
+}
+
+func (g *Game) handlePlayerNotifications() {
+ n := g.playerCtrl.RecvNotification()
+ for n != nil {
+ switch n.Notification {
+ case game.PriorityNotification:
+ g.showPassButton()
+ g.hasPriority = true
+
+ case game.DeclaredActionNotification:
+ if n.Error != nil {
+ log.Fatal(n.Error)
+ }
+ a := n.Context.(game.Action)
+ g.stackBuffer.AddLine(a.String())
+
+ case game.ResolvedActionNotification:
+ g.clearMapHighlights()
+ g.stackBuffer.RemoveLast()
+ g.mapView.ForceRedraw()
+
+ case game.BuyPrompt:
+ if !g.storesOnMap {
+ g.showStore()
+ }
+
+ case game.HandCardSelectionPrompt:
+ ctx := n.Context.([]int)
+ min, max := ctx[0], ctx[1]
+ promptTxt := fmt.Sprintf("Select between %d and %d hand cards", min, max)
+ g.prompt = ui.NewHandCardPrompt(g.height/2, g.width, promptTxt)
+ g.addWidget(g.prompt)
+ g.showPassButton()
+ }
+
+ g.stateBar.ForceRedraw()
+ g.handLayer.ForceRedraw()
+
+ n = g.playerCtrl.RecvNotification()
+ }
+}
+
+func (g *Game) findObjectAt(x, y int) interface{} {
+ // Iterate the widget in reverse order to ensure that objects are found
+ // in the newer widget possibly drawn over older ones
+ for i := len(g.widgets) - 1; i >= 0; i-- {
+ w := g.widgets[i]
+ if obj := w.FindObjectAt(x, y); obj != nil {
+ return obj
+ }
+ }
+ if obj := g.mapView.FindObjectAt(x, y); obj != nil {
+ return obj
+ }
+
+ return nil
+}
+
+func (g *Game) handleTargetSelection(obj interface{}) {
+ log.Println("Handle target selection")
+ a := g.selectedObject.(game.Action)
+ a.Targets().AddTarget(obj)
+
+ if !a.Targets().RequireTargets() {
+ log.Println("declare", a)
+ g.declareAction(a)
+ g.selectedObject = nil
+ }
+}
+
+func (g *Game) handleSelection(obj interface{}, x, y int) {
+ if obj == nil {
+ fmt.Printf("No object found at cursor position (%d, %d)\n", x, y)
+ return
+ }
+
+ if _, ok := g.selectedObject.(game.Action); ok {
+ g.handleTargetSelection(obj)
+ } else {
+ switch obj := obj.(type) {
+ case ui.Button:
+ obj.Click(x, y)
+
+ case *game.Tile:
+ g.mapView.HighlightTile(obj)
+
+ case game.Permanent:
+ perm := obj
+ g.addActionChoice(perm, x, y)
+
+ case ui.HandCard:
+ g.selectedObject = game.NewPlayAction(g.activePlayer, obj.C)
+ g.handLayer.HighlightCard(obj.C)
+
+ case *game.Card:
+ if g.gameState.IsActivePlayer(g.activePlayer) &&
+ g.gameState.ActivePhase == game.Phases.BuyPhase {
+ g.declareAction(game.NewBuyAction(g.activePlayer, obj))
+ }
+
+ default:
+ log.Fatalf("Object of type %T not handled", obj)
+ }
+ }
+}
+
+func (g *Game) highlightTargets() {
+ a, ok := g.selectedObject.(game.Action)
+ if !ok {
+ return
+ }
+
+ options := a.Targets().NextOptions()
+ for _, option := range options {
+ g.AddHighlight(option)
+ }
+}
+
+func (g *Game) handleHover(obj interface{}, x, y int) {
+ var hoverHint ui.Widget
+ switch obj := obj.(type) {
+ case *game.Card:
+ wx, wy := x, y
+ if x+500 > g.width || y+700 > g.height {
+ wx = x - 500
+ wy = y - 700
+ }
+ hoverHint = ui.NewScaledCardView(wx, wy, 500, 700, obj.Path())
+ case *game.Unit:
+ hoverHint = ui.NewUnitInfo(x+ui.TILE_WIDTH, y, obj)
+ case game.Permanent:
+ hoverHint = ui.NewPermInfo(x+ui.TILE_WIDTH, y, obj)
+ case *game.Tile:
+ if g.storesOnMap && obj.Type == game.TileTypes.Store {
+ poc := g.gameState.Map.StoreOn(obj.Position)
+ hoverHint = ui.NewPocList(x, y, poc)
+ // Set a special hoverDetector resetFunc keeping the hovering store
+ // around and allow card selection until the cursor leaves the store widget.
+ g.hoverDetector.resetFunc = func() bool {
+ x, y := ebiten.CursorPosition()
+ if hoverHint.Contains(x, y) {
+ return false
+ }
+ g.removeWidget(hoverHint)
+ return true
+ }
+ }
+ }
+
+ if hoverHint != nil {
+ g.addWidget(hoverHint)
+ // Set the default reset function removing the hoverHint
+ if g.hoverDetector.resetFunc == nil {
+ g.hoverDetector.resetFunc = func() bool {
+ g.removeWidget(hoverHint)
+ return true
+ }
+ }
+ }
+}
+
+func (g *Game) Update() error {
+ g.handlePlayerNotifications()
+
+ x, y := ebiten.CursorPosition()
+ g.hoverDetector.update(x, y)
+
+ obj := g.findObjectAt(x, y)
+
+ scrollX, scrollY := ebiten.Wheel()
+ if scrollX != 0 || scrollY != 0 {
+ if scrollable, ok := obj.(ui.Scrollable); ok {
+ scrollable.Scroll(int(scrollX), int(scrollY))
+ }
+ }
+
+ g.highlightTargets()
+
+ if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
+ if g.prompt != nil {
+ g.prompt.Add(obj)
+ g.AddHighlight(obj)
+ } else {
+ g.handleSelection(obj, x, y)
+ }
+ } else if g.hoverDetector.startedHovering() && g.selectedObject == nil && obj != nil {
+ g.handleHover(obj, x, y)
+ } else if inpututil.IsKeyJustPressed(ebiten.KeySpace) {
+ g.passButton.Click(0, 0)
+ } else if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
+ g.removeActionChoice()
+ g.selectedObject = nil
+ g.hoverDetector.reset()
+ g.clearMapHighlights()
+ g.handLayer.ClearHighlights()
+ }
+ return nil
+}
+
+func (g *Game) Draw(screen *ebiten.Image) {
+ g.mapView.Draw(screen)
+ if !g.gameState.Stack.IsEmpty() {
+ g.stackBuffer.Draw(screen)
+ }
+ for _, b := range g.widgets {
+ b.Draw(screen)
+ }
+ ebitenutil.DebugPrint(screen,
+ fmt.Sprintf("TPS: %0.2f FPS: %0.2f", ebiten.ActualTPS(), ebiten.ActualFPS()))
+}
+
+func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
+ return g.width, g.height
+}
+
+func (g *Game) Start() *Game {
+ go func() {
+ w := g.gameState.Loop()
+ log.Println("Winners are", w)
+ }()
+
+ return g
+}
diff --git a/go/client/main.go b/go/client/main.go
new file mode 100644
index 00000000..3635404f
--- /dev/null
+++ b/go/client/main.go
@@ -0,0 +1,95 @@
+package main
+
+import (
+ "flag"
+ "image/color"
+ "io"
+ "log"
+ "os"
+
+ "github.com/hajimehoshi/ebiten/v2"
+
+ "muhq.space/muhqs-game/go/font"
+)
+
+const (
+ WINDOW_WIDTH = 800
+ WINDOW_HEIGHT = 1024
+)
+
+var playerColors = []color.RGBA{
+ {1, 0xff, 1, 0xff},
+ {1, 1, 0xff, 0xff},
+}
+
+var (
+ playerName string
+ startDeckPath string
+)
+
+type app struct {
+ windowWidth int
+ windowHeight int
+
+ active ebiten.Game
+ running bool
+}
+
+func (a *app) Update() error {
+ return a.active.Update()
+}
+
+func (a *app) Draw(screen *ebiten.Image) {
+ a.active.Draw(screen)
+}
+
+func (a *app) Layout(outsideWidth, outsideHeight int) (int, int) {
+ return a.windowWidth, a.windowHeight
+}
+
+func main() {
+ font.InitFont()
+
+ app := &app{}
+ startMenu := NewStartMenu(app)
+
+ flag.IntVar(&app.windowWidth, "windowWidth", WINDOW_WIDTH, "the window width")
+ flag.IntVar(&app.windowHeight, "windowHeight", WINDOW_HEIGHT, "the window height")
+
+ flag.StringVar(&startMenu.playerName, "player", "muhq", "player name")
+ flag.StringVar(&startDeckPath, "deck", "", "deck list to load")
+
+ flag.StringVar(&startMenu.mapPath, "map", "the-kraken", "the map to load")
+ flag.StringVar(&startMenu.remote, "remote", "", "address of remote game to connect to")
+ flag.Parse()
+
+ ebiten.SetWindowSize(app.windowWidth, app.windowHeight)
+ ebiten.SetWindowTitle("Muhq's Game")
+
+ startMenu.width, startMenu.height = app.windowWidth, app.windowHeight
+
+ if startDeckPath != "" {
+ file, err := os.Open(startDeckPath)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ defer file.Close()
+
+ deckList, err := io.ReadAll(file)
+ if err != nil {
+ log.Fatal(err)
+ }
+ startMenu.startDeck = string(deckList)
+ } else {
+ startMenu.startDeck = `1 nautics/barge
+1 nautics/captain
+1 nautics/fisher
+1 nautics/galley`
+ }
+
+ app.active = startMenu
+ if err := ebiten.RunGame(app); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/go/client/startMenu.go b/go/client/startMenu.go
new file mode 100644
index 00000000..735753b0
--- /dev/null
+++ b/go/client/startMenu.go
@@ -0,0 +1,92 @@
+package main
+
+import (
+ "log"
+
+ "github.com/hajimehoshi/ebiten/v2"
+ "github.com/hajimehoshi/ebiten/v2/inpututil"
+ // "muhq.space/muhqs-game/go/game"
+ "muhq.space/muhqs-game/go/ui"
+)
+
+const (
+ START_BUTTON_WIDTH = 150
+ START_BUTTON_HEIGHT = 50
+)
+
+type StartMenu struct {
+ app *app
+ width, height int
+
+ playerName string
+ startDeck string
+ remote string
+ mapPath string
+ widgets []ui.Widget
+}
+
+func NewStartMenu(app *app) *StartMenu {
+ m := &StartMenu{app: app}
+
+ return m
+}
+
+func (m *StartMenu) build() {
+ m.widgets = append(m.widgets, ui.NewSimpleButton(
+ (m.width-START_BUTTON_WIDTH)/2,
+ (m.height-START_BUTTON_HEIGHT)/2,
+ 150,
+ 50,
+ "Start",
+ func(*ui.SimpleButton) {
+ m.app.active = m.next()
+ }))
+}
+
+func (m *StartMenu) Update() error {
+ if m.widgets == nil {
+ m.build()
+ }
+
+ x, y := ebiten.CursorPosition()
+ if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
+ for _, w := range m.widgets {
+ if !w.Contains(x, y) {
+ continue
+ }
+
+ if btn, ok := w.(ui.Button); ok {
+ btn.Click(x, y)
+ }
+ }
+ }
+
+ return nil
+}
+
+func (m *StartMenu) Draw(screen *ebiten.Image) {
+ for _, w := range m.widgets {
+ w.Draw(screen)
+ }
+}
+
+func (m *StartMenu) Layout(width, height int) (int, int) {
+ return m.width, m.height
+}
+
+func (m *StartMenu) next() ebiten.Game {
+ if m.remote != "" {
+ log.Fatal("Remote games are currently not implemented")
+ }
+
+ g := newGame(m.app).loadMap(m.mapPath).addActivePlayer(m.playerName, m.startDeck)
+
+ if m.mapPath == "the-kraken" {
+ idx := len(g.gameState.Players)
+ color := playerColors[idx]
+ g.gameState.AddKraken()
+ g.gameState.Players[idx].Color = color
+ }
+
+ return g.Start()
+}
diff --git a/go/font/font.go b/go/font/font.go
new file mode 100644
index 00000000..ae75cf8d
--- /dev/null
+++ b/go/font/font.go
@@ -0,0 +1,38 @@
+package font
+
+import (
+ "log"
+
+ "golang.org/x/image/font"
+ "golang.org/x/image/font/gofont/goregular"
+ "golang.org/x/image/font/opentype"
+)
+
+var Font24 font.Face
+var Font18 font.Face
+var Font12 font.Face
+
+func InitFont() {
+ tt, err := opentype.Parse(goregular.TTF)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ opts := &opentype.FaceOptions{Size: 24, DPI: 72}
+ Font24, err = opentype.NewFace(tt, opts)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ opts = &opentype.FaceOptions{Size: 18, DPI: 72}
+ Font18, err = opentype.NewFace(tt, opts)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ opts = &opentype.FaceOptions{Size: 12, DPI: 72}
+ Font12, err = opentype.NewFace(tt, opts)
+ if err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/go/game/action.go b/go/game/action.go
new file mode 100644
index 00000000..4894d47d
--- /dev/null
+++ b/go/game/action.go
@@ -0,0 +1,351 @@
+package game
+
+import (
+ "fmt"
+)
+
+type (
+ ActionCostFunc func(*State) bool
+ ActionResolveFunc func(*State)
+ ActionFuncPrototype func(Action) ActionResolveFunc
+)
+
+type Action interface {
+ Source() interface{}
+ Targets() *Targets
+
+ CheckTargets(*State) error
+ PayCosts(s *State) bool
+ Resolve(s *State)
+ String() string
+}
+
+type PassPriority struct{ source *Player }
+
+func (a *PassPriority) Source() interface{} { return a.source }
+func (*PassPriority) Targets() *Targets { return nil }
+func (*PassPriority) CheckTargets(*State) error { return nil }
+func (*PassPriority) PayCosts(*State) bool { return true }
+func (*PassPriority) Resolve(*State) {}
+func (*PassPriority) String() string { return "pass" }
+func NewPassPriority(p *Player) Action { return &PassPriority{p} }
+
+type HandCardSelection struct{ cards []*Card }
+
+func (*HandCardSelection) Source() interface{} { return nil }
+func (*HandCardSelection) Targets() *Targets { return nil }
+func (*HandCardSelection) CheckTargets(*State) error { return nil }
+func (*HandCardSelection) PayCosts(*State) bool { return true }
+func (*HandCardSelection) Resolve(*State) {}
+func (*HandCardSelection) String() string { return "hand card selection" }
+func NewHandCardSelection(cards []*Card) Action { return &HandCardSelection{cards} }
+
+type ActionBase struct {
+ source interface{}
+ Card *Card
+ targets *Targets
+ resolveFunc ActionResolveFunc
+ costFunc ActionCostFunc
+}
+
+func (a *ActionBase) Source() interface{} {
+ return a.source
+}
+
+func (a *ActionBase) Resolve(s *State) {
+ a.resolveFunc(s)
+}
+
+func (a *ActionBase) PayCosts(s *State) bool {
+ return a.costFunc(s)
+}
+
+func (a *ActionBase) Targets() *Targets {
+ return a.targets
+}
+
+func (a *ActionBase) CheckTargets(s *State) error {
+ return a.targets.CheckTargets(s)
+}
+
+type PlayAction struct {
+ ActionBase
+}
+
+const permanentPlayActionTarget = "available spawn tile"
+
+func NewPlayAction(p *Player, c *Card, args ...interface{}) *PlayAction {
+ a := &PlayAction{ActionBase{
+ source: p,
+ Card: c,
+ }}
+
+ if len(args) > 0 {
+ if costFunc, ok := args[0].(ActionCostFunc); ok {
+ a.costFunc = costFunc
+ }
+ }
+
+ if a.costFunc == nil {
+ a.costFunc = func(*State) bool {
+ cost := c.PlayCosts()
+
+ if p.Resource < cost {
+ return false
+ }
+ p.Resource -= cost
+ return true
+ }
+ }
+
+ s := p.gameState
+ if c.IsPermanent() {
+ a.targets = newTargets(s, []string{permanentPlayActionTarget}, a)
+ } else {
+ a.targets = newTargets(s, []string{}, a)
+ }
+
+ p.Hand.RemoveCard(c)
+
+ a.resolveFunc = func(s *State) {
+ s.resolvePlay(p, c, a.targets)
+ }
+
+ return a
+}
+
+func (a *PlayAction) String() string {
+ p := a.source.(*Player)
+ if a.targets.RequireTargets() {
+ return fmt.Sprintf(" %s play %s", p.Name, a.Card.Name)
+ }
+
+ return fmt.Sprintf("%s play %s@%v", p.Name, a.Card.Name, a.targets)
+}
+
+type MoveAction struct {
+ ActionBase
+}
+
+const moveActionTargetDesc = "available tile"
+
+func NewMoveAction(u *Unit) *MoveAction {
+ a := &MoveAction{
+ ActionBase{
+ source: u,
+ Card: u.Card(),
+ },
+ }
+
+ a.targets = newTargets(u.Controller().gameState, []string{moveActionTargetDesc}, a)
+
+ a.resolveFunc = func(s *State) {
+ tile := a.targets.sel[0].(*Tile)
+ u.move(s, tile)
+ }
+
+ a.costFunc = func(*State) bool {
+ if u.AvailMoveActions < 1 {
+ return false
+ }
+ u.AvailMoveActions--
+ return true
+ }
+
+ return a
+}
+
+func (a *MoveAction) String() string {
+ u := a.source.(*Unit)
+ if a.targets.RequireTargets() {
+ return fmt.Sprintf("move %s", u.Movement.String())
+ }
+
+ t := a.targets.sel[0].(*Tile)
+ return fmt.Sprintf("%s: %s -> %s", u.Controller().Name, u.Card().Name, t.Position.String())
+}
+
+type AttackAction struct {
+ ActionBase
+}
+
+const attackActionTargetDesc = "attackable permanent"
+
+func NewAttackAction(u *Unit) *AttackAction {
+ a := &AttackAction{ActionBase{
+ source: u,
+ Card: u.Card(),
+ }}
+
+ a.targets = newTargets(u.Controller().gameState, []string{attackActionTargetDesc}, a)
+
+ a.resolveFunc = func(s *State) {
+ s.Fight(u, a.targets.sel[0].(Permanent))
+ }
+
+ a.costFunc = func(*State) bool {
+ if u.AvailAttackActions < 1 {
+ return false
+ }
+ u.AvailAttackActions--
+ return true
+ }
+
+ return a
+}
+
+func (a *AttackAction) String() string {
+ u := a.source.(*Unit)
+ if a.targets.RequireTargets() {
+ return fmt.Sprintf("attack %s", u.Attack.String())
+ }
+
+ target := a.targets.sel[0].(Permanent)
+ return fmt.Sprintf("%s: %s x %s",
+ u.Controller().Name, u.Card().Name, target.Tile().Position.String())
+}
+
+type FullAction struct {
+ ActionBase
+ desc string
+}
+
+func NewFullAction(u *Unit, proto ActionFuncPrototype, desc string) *FullAction {
+ a := &FullAction{
+ ActionBase{
+ source: u,
+ Card: u.Card(),
+ },
+
+ desc,
+ }
+
+ a.resolveFunc = func(s *State) {
+ rf := proto(a)
+ rf(s)
+ }
+
+ a.costFunc = func(*State) bool {
+ if u.AvailAttackActions < 1 && u.AvailMoveActions < 1 {
+ return false
+ }
+ u.AvailAttackActions--
+ u.AvailMoveActions--
+ return true
+ }
+ return a
+}
+
+func (a *FullAction) String() string {
+ u := a.source.(*Unit)
+ if a.targets.RequireTargets() {
+ return fmt.Sprintf("↻ %s", a.desc)
+ }
+
+ return fmt.Sprintf("%s: %s ↻@%s", u.Controller(), u.Card().Name, a.targets)
+}
+
+type FreeAction struct {
+ ActionBase
+ desc string
+}
+
+func NewFreeAction(p Permanent, resolveProto ActionFuncPrototype, costFunc ActionCostFunc,
+ desc string,
+) *FreeAction {
+ a := &FreeAction{
+ ActionBase{
+ source: p,
+ Card: p.Card(),
+ costFunc: costFunc,
+ },
+ desc,
+ }
+
+ a.resolveFunc = func(s *State) {
+ rf := resolveProto(a)
+ rf(s)
+ }
+
+ return a
+}
+
+func (a *FreeAction) String() string {
+ p := a.source.(Permanent)
+ if a.targets.RequireTargets() {
+ return fmt.Sprintf("free_action %s", a.desc)
+ }
+
+ return fmt.Sprintf("%s: %s free_action@%s", p.Controller(), p.Card().Name, a.targets)
+}
+
+type BuyAction struct {
+ player *Player
+ card *Card
+}
+
+func (a *BuyAction) Source() interface{} {
+ return a.player
+}
+
+func (a *BuyAction) Targets() *Targets {
+ return newTargetsWithSel(a.player.gameState, []string{"store cards"}, a, []interface{}{a.card})
+}
+
+func (a *BuyAction) CheckTargets(s *State) error {
+ return s.isValidBuy(a)
+}
+
+func (a *BuyAction) Resolve(s *State) {
+ a.player.Store.MoveCard(a.card, a.player.DiscardPile)
+}
+
+func (a *BuyAction) PayCosts(*State) bool {
+ if a.card.BuyCost < 0 || a.card.BuyCost > a.player.Resource {
+ return false
+ }
+
+ a.player.Resource -= a.card.BuyCost
+ return true
+}
+
+func (a *BuyAction) String() string { return fmt.Sprintf("%s buy %s", a.player.Name, a.card.Name) }
+
+func NewBuyAction(p *Player, c *Card) *BuyAction {
+ return &BuyAction{p, c}
+}
+
+type UpkeepAction struct {
+ player *Player
+ unit *Unit
+ pay bool
+}
+
+func (a *UpkeepAction) Resolve(s *State) {
+ if a.pay {
+ a.player.Resource -= a.unit.Upkeep
+ } else {
+ s.DestroyPermanent(a.unit)
+ }
+}
+
+func (a *UpkeepAction) PayCosts(*State) bool {
+ if a.unit.Upkeep > a.player.Resource {
+ return false
+ }
+
+ a.player.Resource -= a.unit.Upkeep
+ return true
+}
+
+func (a *UpkeepAction) String() string {
+ if a.pay {
+ return fmt.Sprintf("%s keep %s", a.player.Name, a.unit.Tile().Position.String())
+ }
+
+ return fmt.Sprintf("%s disband %s", a.player.Name, a.unit.Tile().Position.String())
+}
+
+func NewUpkeepAction(p *Player, u *Unit, pay bool) *UpkeepAction {
+ return &UpkeepAction{p, u, pay}
+}
diff --git a/go/game/ai.go b/go/game/ai.go
new file mode 100644
index 00000000..0adce722
--- /dev/null
+++ b/go/game/ai.go
@@ -0,0 +1,343 @@
+package game
+
+import (
+ "fmt"
+ "log"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/RyanCarrier/dijkstra"
+)
+
+type UnitAI struct {
+ s *State
+ u *Unit
+ actions chan Action
+ syncGameState *sync.WaitGroup
+}
+
+func (ai *UnitAI) NextAction() Action {
+ return <-ai.actions
+}
+
+func (ai *UnitAI) PostAndContinue(a Action) bool {
+ if a == nil {
+ return true
+ }
+
+ ai.actions <- a
+ return ai.awaitGameStateSync()
+}
+
+func (ai *UnitAI) awaitGameStateSync() bool {
+ ai.syncGameState.Wait()
+ ai.syncGameState.Add(1)
+ return !ai.u.IsDestroyed()
+}
+
+func (ai *UnitAI) promptAction() {
+ ai.syncGameState.Done()
+}
+
+func (ai *UnitAI) Execute(f func(*UnitAI)) {
+ defer close(ai.actions)
+ if ai.awaitGameStateSync() {
+ f(ai)
+ }
+}
+
+func NewUnitAI(s *State, u *Unit) *UnitAI {
+ aiDesc := u.Card().getAI()
+ if aiDesc == "" {
+ return nil
+ }
+
+ c := make(chan Action)
+ wg := new(sync.WaitGroup)
+ wg.Add(1)
+ ai := &UnitAI{s, u, c, wg}
+
+ switch aiDesc {
+ case "aggressive":
+ go ai.Execute(AggressiveAI)
+ case "shy":
+ go ai.Execute(ShyAI)
+ case "wandering":
+ aiDesc := u.Card().getAI()
+ x, err := strconv.Atoi(strings.Split(u.Card().getAI(), " ")[1])
+ if err != nil {
+ log.Fatalf("Invalid wandering AI description %s", aiDesc)
+ }
+ go ai.Execute(func(ai *UnitAI) { WanderingAI(ai, x) })
+ case "target-oriented":
+ go ai.Execute(TargetOrientedAI)
+ }
+
+ return ai
+}
+
+func (m *Map) generateMapGraphFor(u *Unit) *dijkstra.Graph {
+ graph := dijkstra.NewGraph()
+ for _, t := range m.AllTiles() {
+ graph.AddMappedVertex(t.Position.String())
+ }
+
+ for _, t := range m.AllTiles() {
+ tId := t.Position.String()
+ for _, neighbour := range TilesInRangeFromOrigin(m, t.Position, 1) {
+ if u.IsAvailableTile(neighbour) {
+ cost := 2
+ if t.OnDiagonal(neighbour) {
+ cost = 3
+ }
+ err := graph.AddMappedArc(tId, neighbour.Position.String(), int64(cost))
+ if err != nil {
+ log.Fatalf("Failed to add mapped arc: %s", err)
+ }
+ }
+ }
+ }
+ return graph
+}
+
+func findPathTo(graph *dijkstra.Graph, u *Unit, pos Position) (dijkstra.BestPath, error) {
+ srcId, err := graph.GetMapping(u.Tile().Position.String())
+ if err != nil {
+ log.Fatalf("No mapping %s in Graph: %s", u.Tile().Position.String(), err)
+ }
+
+ destId, err := graph.GetMapping(pos.String())
+ if err != nil {
+ log.Fatalf("No mapping %s in Graph: %s", pos.String(), err)
+ }
+
+ return graph.Shortest(srcId, destId)
+}
+
+func findPathsToUnits(graph *dijkstra.Graph, m *Map, u *Unit, units []*Unit) []*dijkstra.BestPath {
+ paths := []*dijkstra.BestPath{}
+
+ for _, unit := range units {
+ var bestPathToUnit *dijkstra.BestPath = nil
+ for _, adjacentTile := range TilesInRangeFromOrigin(m, unit.Tile().Position, 1) {
+ path, err := findPathTo(graph, u, adjacentTile.Position)
+ if err != nil {
+ continue
+ } else if bestPathToUnit == nil || path.Distance < bestPathToUnit.Distance {
+ bestPathToUnit = &path
+ }
+ }
+ paths = append(paths, bestPathToUnit)
+ }
+ return paths
+}
+
+func moveToRandomTile(ai *UnitAI) Action {
+ if ai.u.AvailMoveActions == 0 {
+ return nil
+ }
+
+ a := NewMoveAction(ai.u)
+ options := a.Targets().NextOptions()
+ if len(options) == 0 {
+ return nil
+ }
+
+ idx := 0
+ if len(options) > 1 {
+ idx = ai.s.Rand.Intn(len(options) - 1)
+ }
+ a.Targets().AddTarget(options[idx])
+
+ return a
+}
+
+func moveTowardsNearestEnemyUnit(ai *UnitAI) Action {
+ if ai.u.AvailMoveActions == 0 {
+ return nil
+ }
+
+ graph := ai.s.Map.generateMapGraphFor(ai.u)
+ enemyUnits := ai.s.EnemyUnits(ai.u.Controller())
+ if len(enemyUnits) == 0 {
+ return nil
+ }
+
+ paths := findPathsToUnits(graph, ai.s.Map, ai.u, enemyUnits)
+ nearest := 0
+ for i, p := range paths {
+ if p == nil {
+ continue
+ }
+
+ if paths[nearest] == nil || p.Distance < paths[nearest].Distance {
+ nearest = i
+ }
+ }
+
+ if paths[nearest] == nil {
+ return nil
+ }
+
+ var target Position
+ for _, id := range paths[nearest].Path[1:] {
+ mapping, _ := graph.GetMapped(id)
+ var x, y int
+ fmt.Sscanf(mapping, "(%d, %d)", &x, &y)
+ nextStep := Position{X: x, Y: y}
+ if IsPositionInRange(ai.u.Tile().Position, nextStep, ai.u.Movement.Range) {
+ target = nextStep
+ } else {
+ break
+ }
+ }
+
+ a := NewMoveAction(ai.u)
+ a.Targets().AddTarget(ai.s.Map.TileAt(target))
+ return a
+}
+
+func attackAttackableEnemyPerm(ai *UnitAI) Action {
+ if ai.u.AvailAttackActions == 0 {
+ return nil
+ }
+
+ a := NewAttackAction(ai.u)
+ options := a.Targets().NextOptions()
+ if len(options) == 0 {
+ return nil
+ }
+
+ idx := 0
+ if len(options) > 1 {
+ idx = ai.s.Rand.Intn(len(options) - 1)
+ }
+ a.Targets().AddTarget(options[idx])
+ return a
+}
+
+// 1. If enemy Unit in attack Range then
+// attack enemy Unit in Range
+// 2. While move action available do
+// 3a. If enemy unit exists
+// move towards nearest enemy unit until it is in attack range
+// if attack action avail then proceed at 1.
+// 3b. Otherwise
+// move to random tile in movement range
+func AggressiveAI(ai *UnitAI) {
+ // 1.
+ a := attackAttackableEnemyPerm(ai)
+ if !ai.PostAndContinue(a) {
+ return
+ }
+
+ // 2.
+ if ai.u.AvailMoveActions > 0 {
+ a = moveTowardsNearestEnemyUnit(ai)
+ if !ai.PostAndContinue(a) {
+ return
+ }
+
+ if a != nil && ai.u.AvailAttackActions > 0 {
+ AggressiveAI(ai)
+ } else { // 3b.
+ a = moveToRandomTile(ai)
+ if a != nil {
+ ai.actions <- a
+ }
+ }
+ }
+}
+
+// 2.2 Shy
+// 1. If not in the attack Range of an enemy Unit then
+// activate full action if available
+// 2. If move action available then
+// move to most distant point from all enemy units
+// 3. If enemy Unit in attack Range then
+// attack enemy Unit in Range
+func ShyAI(ai *UnitAI) {
+ // 1.
+ inEnemyAttackRange := false
+out:
+ for _, enemyUnit := range ai.s.EnemyUnits(ai.u.Controller()) {
+ for _, p := range enemyUnit.AttackablePermanents(ai.s.Map) {
+ if p == ai.u {
+ inEnemyAttackRange = true
+ break out
+ }
+ }
+ }
+ if !inEnemyAttackRange && ai.u.HasFullAction() {
+ fullActions := ai.u.Card().Impl.fullActions(ai.u)
+ idx := 0
+ if len(fullActions) > 1 {
+ idx = ai.s.Rand.Intn(len(fullActions) - 1)
+ }
+
+ a := fullActions[idx]
+ options := a.Targets().NextOptions()
+ if len(options) > 1 {
+ idx = ai.s.Rand.Intn(len(options) - 1)
+ }
+ a.Targets().AddTarget(options[idx])
+ ai.actions <- a
+ }
+
+ // 2.
+ // TODO: implement path finding
+ // if u.AvailMoveActions > 0 {
+ // }
+
+ // 3.
+ if ai.u.AvailAttackActions > 0 {
+ attackAttackableEnemyPerm(ai)
+ }
+}
+
+// 2.3 Wandering X
+// 1. If enemy unit in Range X then
+// execute aggressive AI
+// 2. If move action available then
+// move to random tile in movement Range
+// proceed at 1.
+func WanderingAI(ai *UnitAI, x int) {
+ // 1.
+ for _, enemyUnit := range ai.s.EnemyUnits(ai.u.Controller()) {
+ if IsPositionInRange(ai.u.Tile().Position, enemyUnit.Tile().Position, x) {
+ AggressiveAI(ai)
+ break
+ }
+ }
+ // 2.
+ if ai.u.AvailMoveActions > 0 {
+ moveToRandomTile(ai)
+ WanderingAI(ai, x)
+ }
+}
+
+// 2.4 Target-oriented TARGET
+// 1. Use full action if possible
+// 2a. If a TARGET exists and is reachable
+//
+// Move onto or towards nearest TARGET
+// 3a. If enemy Unit in attack Range
+// attack enemy Unit in Range
+//
+// 2b. Otherwise
+//
+// execute wandering 3 AI
+func TargetOrientedAI(ai *UnitAI) {
+ // 1.
+ if ai.u.HasFullAction() {
+ ai.u.FullAction()
+ }
+
+ // if ai.t != nil {
+ // // TODO: path finding again
+ // attackAttackableEnemyPerm(ai)
+ // } else {
+ WanderingAI(ai, 3)
+ // }
+}
diff --git a/go/game/ai_test.go b/go/game/ai_test.go
new file mode 100644
index 00000000..5b68ae27
--- /dev/null
+++ b/go/game/ai_test.go
@@ -0,0 +1,66 @@
+package game
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestGenerateGraph(t *testing.T) {
+ mapDef := `map: |1-
+ HST
+ HSF
+ TST
+symbols:
+ T: tower
+ H: house
+ F: farm
+ S: street
+`
+ r := strings.NewReader(mapDef)
+ m, _ := readMap(r)
+ a := NewUnit(NewCard("base/archer"), m.TileAt(Position{0, 0}), nil)
+ f := NewUnit(NewCard("misc/farmer"), m.TileAt(Position{2, 2}), nil)
+
+ graph := m.generateMapGraphFor(a)
+ p, err := findPathTo(graph, a, Position{1, 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if p.Distance != 3 {
+ t.Fatalf("Wrong distance %d", p.Distance)
+ }
+
+ paths := findPathsToUnits(graph, m, a, []*Unit{f})
+ if paths[0] == nil {
+ t.Fatal("No path found")
+ }
+
+ if paths[0].Distance != 3 {
+ t.Fatalf("Wrong distance %d", p.Distance)
+ }
+}
+
+func TestAngrySquidDiagonal(t *testing.T) {
+ mapDef := `map: |1-
+ WWWWWWW
+ W W
+ WW WW
+ WWWWWWW
+ WWWWWWW
+ W W
+symbols:
+ W: deep water
+`
+ r := strings.NewReader(mapDef)
+ m, _ := readMap(r)
+ s := NewUnit(NewCard("kraken/angry_squid"), m.TileAt(Position{5, 0}), nil)
+ f := NewUnit(NewCard("misc/farmer"), m.TileAt(Position{6, 5}), nil)
+
+ graph := m.generateMapGraphFor(s)
+
+ paths := findPathsToUnits(graph, m, s, []*Unit{f})
+ if paths[0] == nil {
+ t.Fatal("No path found")
+ }
+}
diff --git a/go/game/artifact.go b/go/game/artifact.go
new file mode 100644
index 00000000..acbaeb74
--- /dev/null
+++ b/go/game/artifact.go
@@ -0,0 +1,51 @@
+package game
+
+import (
+ "log"
+ "strconv"
+ "strings"
+)
+
+type Artifact struct {
+ PermanentBase
+ Solid int
+}
+
+func NewArtifactFromPath(cardPath string, tile *Tile, owner *Player) *Artifact {
+ card := NewCard(cardPath)
+ return NewArtifact(card, tile, owner)
+}
+
+func NewArtifact(card *Card, tile *Tile, owner *Player) *Artifact {
+ a := &Artifact{NewPermanentBase(card, tile, owner), 0}
+ tile.Permanent = a
+
+ effects := card.getEffects()
+ for _, e := range effects {
+ if strings.HasPrefix(e, "Solid") {
+ tokens := strings.Split(e, " ")
+ var err error
+ a.Solid, err = strconv.Atoi(tokens[1])
+ if err != nil {
+ log.Fatalf("Invalid Solid definition %s\n", e)
+ }
+ }
+ }
+
+ return a
+}
+
+func (a *Artifact) Fight(p Permanent) {
+}
+
+func (a *Artifact) IsDestroyed() bool {
+ return a.Damage() >= a.Solid
+}
+
+func (a *Artifact) Attackable() bool {
+ return a.Solid > 0
+}
+
+func (a *Artifact) AvailActions() []Action {
+ return []Action{}
+}
diff --git a/go/game/attack.go b/go/game/attack.go
new file mode 100644
index 00000000..97881d48
--- /dev/null
+++ b/go/game/attack.go
@@ -0,0 +1,50 @@
+package game
+
+import (
+ "fmt"
+ "log"
+ "strconv"
+ "strings"
+)
+
+type Attack struct {
+ Damage int
+ Range int
+}
+
+func INVALID_ATTACK() Attack {
+ return Attack{-1, -1}
+}
+
+func (a Attack) String() string {
+ if a == INVALID_ATTACK() {
+ return "invalid attack"
+ }
+
+ if a.Range == 1 {
+ return fmt.Sprintf("%d", a.Damage)
+ }
+ return fmt.Sprintf("%d range %d", a.Damage, a.Range)
+}
+
+func parseAttack(attack interface{}) Attack {
+ if damage, ok := attack.(int); ok {
+ return Attack{damage, 1}
+ }
+
+ attackStr := attack.(string)
+ tokens := strings.Split(attackStr, " ")
+ a, err := strconv.Atoi(tokens[0])
+ if err != nil {
+ log.Fatalf("Attack %s has no valid Damage value\n", attackStr)
+ }
+
+ r := 1
+ if len(tokens) == 3 {
+ r, err = strconv.Atoi(tokens[2])
+ if err != nil {
+ log.Fatalf("Attack %s has no valid Range value\n", attackStr)
+ }
+ }
+ return Attack{a, r}
+}
diff --git a/go/game/card.go b/go/game/card.go
new file mode 100644
index 00000000..9013a059
--- /dev/null
+++ b/go/game/card.go
@@ -0,0 +1,316 @@
+package game
+
+import (
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "path"
+ "strconv"
+ "strings"
+
+ "golang.org/x/exp/slices"
+ "gopkg.in/yaml.v3"
+)
+
+type CardType int
+
+const (
+ undefinedCardType CardType = iota
+ unitType
+ spellType
+ artifactType
+ equipmentType
+ potionType
+ intentionType
+)
+
+func (ct CardType) String() string {
+ switch ct {
+ case undefinedCardType:
+ return "undefined"
+ case unitType:
+ return "unit"
+ case spellType:
+ return "spell"
+ case artifactType:
+ return "artifact"
+ case equipmentType:
+ return "equipment"
+ case potionType:
+ return "potion"
+ case intentionType:
+ return "intention"
+ }
+ log.Panicf("Switch not exhausting %d", ct)
+ return ""
+}
+
+var CardTypes = struct {
+ Unit CardType
+ Spell CardType
+ Artifact CardType
+ Equipment CardType
+ Potion CardType
+ Intention CardType
+}{
+ Unit: unitType,
+ Spell: spellType,
+ Artifact: artifactType,
+ Equipment: equipmentType,
+ Potion: potionType,
+ Intention: intentionType,
+}
+
+var CardTypeNames = map[string]CardType{
+ "unit": unitType,
+ "spell": spellType,
+ "artifact": artifactType,
+ "equipment": equipmentType,
+ "potion": potionType,
+ "intention": intentionType,
+}
+
+type cardImplementation interface {
+ spawnTiles(*State, *Player) []*Tile
+ fullActions(*Unit) []*FullAction
+ freeActions(Permanent) []*FreeAction
+}
+
+// Default implementation of the cardImplementation interface.
+//
+// Card Implementations should embed this struct to "inherit" the
+// default behavior.
+type cardImplementationBase struct{}
+
+func (impl *cardImplementationBase) spawnTiles(*State, *Player) []*Tile {
+ return nil
+}
+
+func (impl *cardImplementationBase) fullActions(*Unit) []*FullAction {
+ return nil
+}
+
+func (impl *cardImplementationBase) freeActions(Permanent) []*FreeAction {
+ return nil
+}
+
+// Map of all card implementations
+var cardImplementations map[string]cardImplementation
+
+func getCardImplementation(c *Card) cardImplementation {
+ if impl, found := cardImplementations[c.Path()]; found {
+ return impl
+ }
+
+ impl := newParsedCardImplementation(c)
+ cardImplementations[c.Path()] = impl
+ return impl
+}
+
+const (
+ CARD_DATA_URL string = "data/cards"
+ NOT_BUYABLE int = -1 // Maybe this needs to be changed some day to allow more design space
+)
+
+type Card struct {
+ Name string
+ Set SetIdentifier
+ Type CardType
+ BuyCost int
+ Values map[string]interface{}
+ Impl cardImplementation
+}
+
+var cardDefinitions = map[string]map[string][]byte{}
+
+func getCardDefinition(set, cardName string) []byte {
+ cardDefinition, found := cardDefinitions[set][cardName]
+ if !found {
+ cardDefinition = retrieveCardDefinition(path.Join(set, cardName))
+ if cardDefinitions[set] == nil {
+ cardDefinitions[set] = map[string][]byte{}
+ }
+ cardDefinitions[set][cardName] = cardDefinition
+ }
+ return cardDefinition
+}
+
+func retrieveCardDefinition(cardPath string) []byte {
+ url := PROTOCOL + path.Join(BASE_URL, CARD_DATA_URL, cardPath+".yml")
+ log.Printf("loading %s\n", url)
+ resp, err := http.Get(url)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ defer resp.Body.Close()
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ log.Fatal(err)
+ }
+ return data
+}
+
+func (card *Card) parseDefinition(definition []byte) {
+ data := make(map[interface{}]interface{})
+ err := yaml.Unmarshal(definition, &data)
+ if err != nil {
+ log.Fatalf("parsing %s failed with: %v\n", definition, err)
+ }
+
+ name, found := data["name"]
+ if !found {
+ log.Fatalf("no name in card yaml %s\n", definition)
+ }
+ delete(data, "name")
+
+ switch nameYml := name.(type) {
+ case string:
+ card.Name = nameYml
+ case map[string]interface{}:
+ cardName := nameYml["en"]
+ card.Name = cardName.(string)
+ default:
+ log.Fatalf("Unexpected type %T of yml card field 'Name'\n", name)
+ }
+
+ t, found := data["type"]
+ if !found {
+ log.Fatalf("no type in card yaml %s\n", definition)
+ }
+ delete(data, "type")
+
+ card.Type = CardTypeNames[t.(string)]
+
+ bc, found := data["buy"]
+ if found {
+ card.BuyCost = bc.(int)
+ } else {
+ card.BuyCost = NOT_BUYABLE
+ }
+ delete(data, "buy")
+
+ for k, v := range data {
+ card.Values[k.(string)] = v
+ }
+
+ card.Impl = getCardImplementation(card)
+}
+
+func (card *Card) getCanonicalValues(key string) []string {
+ var values []string
+ untypedValues, found := card.Values[key]
+ if !found {
+ return []string{}
+ }
+
+ switch typedValues := untypedValues.(type) {
+ case map[string]interface{}:
+ enValues := typedValues["en"]
+ switch typedEnValues := enValues.(type) {
+ case string:
+ values = []string{typedEnValues}
+ case []string:
+ values = typedEnValues
+ case []interface{}:
+ values = []string{}
+ for _, v := range typedEnValues {
+ values = append(values, v.(string))
+ }
+ }
+ case string:
+ values = []string{typedValues}
+ case int:
+ values = []string{strconv.Itoa(typedValues)}
+ case []string:
+ values = typedValues
+ }
+
+ for i, value := range values {
+ values[i] = strings.ToLower(value)
+ }
+
+ return values
+}
+
+func (card *Card) getEffects() []string {
+ return card.getCanonicalValues("effect")
+}
+
+func (card *Card) hasEffect(effect string) bool {
+ return slices.Contains(card.getEffects(), effect)
+}
+
+func (card *Card) getXEffect(effect string) (int, error) {
+ for _, e := range card.getEffects() {
+ if !strings.Contains(e, effect) {
+ continue
+ }
+
+ tokens := strings.SplitN(e, " ", 2)
+ return strconv.Atoi(tokens[1])
+ }
+ return 0, fmt.Errorf("Crad %s has no effect %s", card.Name, effect)
+}
+
+func (card *Card) hasPlacementConstrain(effect string) bool {
+ if slices.Contains(card.getEffects(), effect) {
+ return true
+ } else if card.Type != CardTypes.Unit {
+ return false
+ }
+
+ movementDesc := card.getCanonicalValues("movement")[0]
+ return strings.Contains(movementDesc, effect)
+}
+
+func (card *Card) getAI() string {
+ aiDesc := card.getCanonicalValues("ai")
+ if len(aiDesc) == 0 {
+ return ""
+ }
+ return strings.Split(aiDesc[0], " ")[0]
+}
+
+func NewCard(cardPath string) *Card {
+ cardName := path.Base(cardPath)
+ set := path.Dir(cardPath)
+
+ cardDefinition := getCardDefinition(set, cardName)
+
+ c := Card{
+ Name: "",
+ Set: SetIdentifierNames[set],
+ Type: undefinedCardType,
+ BuyCost: NOT_BUYABLE,
+ Values: map[string]interface{}{},
+ }
+ c.parseDefinition(cardDefinition)
+ return &c
+}
+
+func (c *Card) Path() string {
+ fileName := strings.ReplaceAll(strings.ToLower(c.Name), " ", "_")
+ return path.Join(c.Set.String(), fileName)
+}
+
+func (c *Card) IsPermanent() bool {
+ return c.Type == CardTypes.Unit ||
+ c.Type != CardTypes.Artifact ||
+ c.Type != CardTypes.Equipment ||
+ c.Type != CardTypes.Potion
+}
+
+func (c *Card) IsBuyable() bool {
+ return c.BuyCost != NOT_BUYABLE
+}
+
+func (c *Card) PlayCosts() int {
+ switch c.Type {
+ case CardTypes.Unit:
+ return c.Values["upkeep"].(int)
+ default:
+ return c.Values["play"].(int)
+ }
+}
diff --git a/go/game/cardImplementations.go b/go/game/cardImplementations.go
new file mode 100644
index 00000000..8024e4fc
--- /dev/null
+++ b/go/game/cardImplementations.go
@@ -0,0 +1,56 @@
+package game
+
+type farmerImplementation struct {
+ cardImplementationBase
+}
+
+type advisorImplementation struct {
+ cardImplementationBase
+}
+
+func (impl *advisorImplementation) fullAction(u *Unit) []*FullAction {
+ resolvePrototype := func(a Action) ActionResolveFunc {
+ u := a.Source().(*Unit)
+ return func(s *State) {
+ controller := u.Controller()
+ controller.DrawN(1)
+ cards := controller.PromptHandCardSelection(0, 1)
+ if cards != nil {
+ controller.Deck.MoveCard(cards[0], s.Exile)
+ }
+ }
+ }
+ return []*FullAction{NewFullAction(u, resolvePrototype, "draw 1. exile 1")}
+}
+
+type missionaryImplementation struct {
+ cardImplementationBase
+}
+
+func (impl *missionaryImplementation) fullAction(u *Unit) []*FullAction {
+ resolvePrototype := func(a Action) ActionResolveFunc {
+ u := a.Source().(*Unit)
+ target := a.Targets().sel[0].(*Unit)
+ return func(s *State) {
+ faith, found := target.States[UnitMarks.Faith]
+ if !found {
+ target.States[UnitMarks.Faith] = 2
+ } else {
+ target.States[UnitMarks.Faith] = faith + 2
+ if faith+2 > target.Card().BuyCost {
+ target.controller = u.Controller()
+ }
+ }
+ }
+ }
+ return []*FullAction{NewFullAction(u, resolvePrototype, "put faith ...")}
+}
+
+func init() {
+ cardImplementations = map[string]cardImplementation{
+ "base/advisor": &advisorImplementation{},
+ "base/missionary": &missionaryImplementation{},
+
+ "misc/farmer": &farmerImplementation{},
+ }
+}
diff --git a/go/game/cardParsing.go b/go/game/cardParsing.go
new file mode 100644
index 00000000..afdefe54
--- /dev/null
+++ b/go/game/cardParsing.go
@@ -0,0 +1,179 @@
+package game
+
+import (
+ "log"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+type parsedCardImplementation struct {
+ card *Card
+ spawnTilesFunc func(*State, *Player) []*Tile
+ genFullActions func(*Unit) []*FullAction
+ genFreeActions func(Permanent) []*FreeAction
+}
+
+func (impl *parsedCardImplementation) spawnTiles(s *State, p *Player) []*Tile {
+ if impl.spawnTilesFunc != nil {
+ return impl.spawnTilesFunc(s, p)
+ }
+ return nil
+}
+
+func (impl *parsedCardImplementation) fullActions(u *Unit) []*FullAction {
+ if impl.genFullActions != nil {
+ return impl.genFullActions(u)
+ }
+ return nil
+}
+
+func (impl *parsedCardImplementation) freeActions(p Permanent) []*FreeAction {
+ if impl.genFreeActions != nil {
+ return impl.genFreeActions(p)
+ }
+ return nil
+}
+
+func newParsedCardImplementation(c *Card) *parsedCardImplementation {
+ impl := &parsedCardImplementation{card: c}
+ for _, effect := range c.getEffects() {
+ impl.parseEffect(effect)
+ }
+
+ for _, fullAction := range c.getCanonicalValues("full_action") {
+ impl.parseFullAction(fullAction)
+ }
+
+ return impl
+}
+
+func (impl *parsedCardImplementation) parseEffect(effect string) {
+ if strings.Contains(effect, "can be played") {
+ impl.parsePlayModification(effect)
+ }
+}
+
+func (impl *parsedCardImplementation) parseFullAction(fullAction string) {
+ var resolveProtos []ActionFuncPrototype
+ gainRessourceRegex := regexp.MustCompile(`gain [1-9][0-9]* resource`)
+
+ if find := gainRessourceRegex.Find([]byte(fullAction)); find != nil {
+ tokens := strings.SplitN(string(find), " ", 3)
+ gain, err := strconv.Atoi(tokens[1])
+ if err != nil {
+ log.Fatalf("Failed to parse resource gain full action: %s", err)
+ }
+
+ resolveProto := func(a Action) ActionResolveFunc {
+ u := a.Source().(*Unit)
+ return func(*State) {
+ u.controller.gainResource(gain)
+ }
+ }
+ resolveProtos = append(resolveProtos, resolveProto)
+ }
+
+ if resolveProtos != nil {
+ impl.genFullActions = func(u *Unit) []*FullAction {
+ fullActions := make([]*FullAction, 0, len(resolveProtos))
+
+ for _, proto := range resolveProtos {
+ fullActions = append(fullActions, NewFullAction(u, proto, ""))
+ }
+
+ return fullActions
+ }
+ }
+}
+
+func parseUnitConstrain(constrain string) func(ctx interface{}, u *Unit) bool {
+ enemy := strings.Contains(constrain, "enemy")
+ if enemy {
+ return func(ctx interface{}, e *Unit) bool {
+ p := ctx.(*Player)
+ return p.IsEnemy(e.Controller())
+ }
+ } else {
+ log.Println("Only enemy unit constrains are implemented yet")
+ }
+
+ return nil
+}
+
+func parseLocation(location string) func(interface{}, *State) []*Tile {
+ r := -1
+ var what string
+ if strings.Contains(location, "on") {
+ r = 0
+ tokens := strings.SplitN(location, "on ", 2)
+ what = tokens[1]
+
+ } else if strings.Contains(location, "next to") {
+ r = 1
+ tokens := strings.SplitN(location, "next to ", 2)
+ what = tokens[1]
+
+ } else if strings.Contains(location, "in range") {
+ tokens := strings.SplitN(location, "in range ", 2)
+ afterRangePattern := tokens[1]
+
+ var rangeStr string
+ tokens = strings.SplitN(afterRangePattern, " ", 2)
+ rangeStr, what = tokens[0], tokens[1]
+
+ var err error
+ r, err = strconv.Atoi(rangeStr)
+ if err != nil {
+ log.Fatalf("Unsupported range pattern \"%s\" in \"%s\"", "range "+rangeStr, location)
+ }
+ }
+
+ if r == -1 || what == "" {
+ return nil
+ }
+
+ if strings.Contains(what, "unit") {
+ constrainFunc := parseUnitConstrain(what)
+ if constrainFunc != nil {
+ return func(ctx interface{}, s *State) []*Tile {
+ origins := s.Map.FilterTiles(func(t *Tile) bool {
+ if t.Permanent != nil &&
+ t.Permanent.Card().Type == CardTypes.Unit &&
+ constrainFunc(ctx, t.Permanent.(*Unit)) {
+ return true
+ }
+ return false
+ })
+
+ if r == 0 {
+ return origins
+ } else {
+ tiles := []*Tile{}
+ for _, origin := range origins {
+ tiles = append(tiles, TilesInRangeFromOrigin(s.Map, origin.Position, r)...)
+ }
+ return tiles
+ }
+ }
+ }
+ }
+ log.Println("Only unit location constrains are implemented yet")
+ return nil
+}
+
+func (impl *parsedCardImplementation) parsePlayModification(effect string) {
+ tokens := strings.SplitN(effect, " can be played ", 2)
+ log.Println(tokens)
+ what, how := tokens[0], tokens[1]
+ if what == strings.ToLower(impl.card.Name) {
+ if locationFunc := parseLocation(how); locationFunc != nil {
+ impl.spawnTilesFunc = func(p *State, s *Player) []*Tile { return locationFunc(s, p) }
+ } else {
+ log.Println("Play modification other than locations are not implemented ")
+ }
+ } else {
+ log.Println(effect)
+ log.Println("Play modification for other cards are not implemented")
+ }
+}
diff --git a/go/game/deck.go b/go/game/deck.go
new file mode 100644
index 00000000..569a952d
--- /dev/null
+++ b/go/game/deck.go
@@ -0,0 +1,77 @@
+package game
+
+import (
+ "log"
+ "math/rand"
+ "strconv"
+ "strings"
+)
+
+type Deck struct {
+ PileOfCardsBase
+}
+
+func NewDeck() *Deck {
+ d := Deck{PileOfCardsBase{[]*Card{}}}
+ return &d
+}
+
+func (d *Deck) Shuffle(r *rand.Rand) {
+ r.Shuffle(d.Size(), func(i, j int) {
+ d.cards[i], d.cards[j] = d.cards[j], d.cards[i]
+ })
+}
+
+func (d *Deck) Draw(amount int) []*Card {
+ if d.Size() < amount {
+ drawn := d.cards
+ d.cards = []*Card{}
+ return drawn
+ }
+
+ drawn := d.cards[0:amount]
+ d.cards = d.cards[amount:]
+ return drawn
+}
+
+func (d *Deck) DrawOne() *Card {
+ drawn := d.Draw(1)
+ if len(drawn) == 0 {
+ return nil
+ }
+ return drawn[0]
+}
+
+func NewDeckFrom(cards PileOfCards) *Deck {
+ d := NewDeck()
+ cards.MoveInto(d)
+ return d
+}
+
+func NewDeckFromCardPaths(cardPaths []string) *Deck {
+ d := NewDeck()
+ for _, cardPath := range cardPaths {
+ card := NewCard(cardPath)
+ d.AddCard(card)
+ }
+ return d
+}
+
+func NewDeckFromDeckList(deckList string) *Deck {
+ cardPaths := []string{}
+ lines := strings.Split(deckList, "\n")
+ for _, line := range lines {
+ tokens := strings.SplitN(line, " ", 2)
+ cardPath := tokens[1]
+ amount, err := strconv.Atoi(tokens[0])
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for i := 0; i < amount; i++ {
+ cardPaths = append(cardPaths, cardPath)
+ }
+ }
+
+ return NewDeckFromCardPaths(cardPaths)
+}
diff --git a/go/game/discardPiles.go b/go/game/discardPiles.go
new file mode 100644
index 00000000..c39a9646
--- /dev/null
+++ b/go/game/discardPiles.go
@@ -0,0 +1,10 @@
+package game
+
+type DiscardPile struct {
+ PileOfCardsBase
+}
+
+func NewDiscardPile() *DiscardPile {
+ dp := DiscardPile{PileOfCardsBase{[]*Card{}}}
+ return &dp
+}
diff --git a/go/game/equipment.go b/go/game/equipment.go
new file mode 100644
index 00000000..1b78c8ad
--- /dev/null
+++ b/go/game/equipment.go
@@ -0,0 +1,5 @@
+package game
+
+type Equipment struct {
+ Artifact
+}
diff --git a/go/game/hand.go b/go/game/hand.go
new file mode 100644
index 00000000..2cbbe5b6
--- /dev/null
+++ b/go/game/hand.go
@@ -0,0 +1,10 @@
+package game
+
+type Hand struct {
+ PileOfCardsBase
+}
+
+func NewHand() *Hand {
+ h := Hand{PileOfCardsBase{[]*Card{}}}
+ return &h
+}
diff --git a/go/game/kraken.go b/go/game/kraken.go
new file mode 100644
index 00000000..19adfd3c
--- /dev/null
+++ b/go/game/kraken.go
@@ -0,0 +1,179 @@
+package game
+
+import (
+ "log"
+ "strings"
+ "sync"
+)
+
+const (
+ KRAKEN_NAME = "Kraken"
+)
+
+var KrakenDecklist = `2 kraken/angry_squid
+2 kraken/chachalot
+2 kraken/clownfish
+2 kraken/deja_vu!
+2 kraken/devour_the_poor!
+2 kraken/dolphin
+2 kraken/flying_dutchmen
+2 kraken/frost_pylon
+2 kraken/gigantic_hail
+2 kraken/sailfish
+2 kraken/sold_out!
+2 kraken/suprise!
+2 kraken/tentacle_slap
+2 kraken/tides_change!
+2 kraken/unholy_cannonball`
+
+func NewKraken(id int, s *State) *Player {
+ kraken := NewPlayer(id, KRAKEN_NAME, NewDeck(), s)
+ d := NewDeckFromDeckList(KrakenDecklist)
+ d.Shuffle(s.Rand)
+ kraken.Deck = d
+
+ kraken.DrawPerTurn = 0
+
+ kraken_tile := s.Map.FilterTiles(func(t *Tile) bool { return t.Raw == "the kraken" })
+ if len(kraken_tile) != 1 {
+ log.Fatal("No kraken tile found in Map")
+ }
+
+ s.AddPermanent(NewUnitFromPath("kraken/the_kraken", kraken_tile[0], kraken))
+
+ addKrakenControl(kraken)
+
+ return kraken
+}
+
+func (s *State) AddKraken() {
+ kraken := NewKraken(len(s.Players)+1, s)
+ s.Players = append(s.Players, kraken)
+}
+
+type KrakenControl struct {
+ kraken *Player
+ actions chan Action
+ syncGameState *sync.WaitGroup
+ turn int
+}
+
+func addKrakenControl(kraken *Player) {
+ ctrl := &KrakenControl{kraken, make(chan Action), new(sync.WaitGroup), 0}
+ kraken.Ctrl = ctrl
+
+ ctrl.syncGameState.Add(1)
+ go func() {
+ s := kraken.gameState
+ for len(s.Map.WinCondition(s)) == 0 {
+ ctrl.krakenTurn()
+ }
+ }()
+}
+
+func (ctrl *KrakenControl) awaitGameStateSync() {
+ ctrl.syncGameState.Wait()
+ ctrl.syncGameState.Add(1)
+}
+
+func (ctrl *KrakenControl) krakenTurn() {
+ s := ctrl.kraken.gameState
+ kraken := ctrl.kraken
+
+ ctrl.awaitGameStateSync()
+ ctrl.turn++
+ if ctrl.turn != kraken.Turn {
+ log.Fatalf("Ai turn out of sync %d != %d", ctrl.turn, kraken.Turn)
+ }
+
+turn:
+ for {
+ drawn := kraken.Deck.Draw(1)
+ if len(drawn) == 0 {
+ break
+ }
+ c := drawn[0]
+
+ if c.BuyCost > kraken.Resource {
+ log.Println("Kraken discards", c.Name, "because resource to low", kraken.Resource)
+ kraken.DiscardPile.AddCard(c)
+ break
+ }
+ kraken.Hand.AddCard(c)
+
+ costFunc := func(s *State) bool {
+ kraken.Resource -= c.BuyCost
+ return true
+ }
+
+ a := NewPlayAction(kraken, c, costFunc)
+ for a.Targets().RequireTargets() {
+ options := a.Targets().NextOptions()
+ if len(options) == 0 {
+ log.Println("No target available for", c.Name)
+ kraken.DiscardPile.AddCard(c)
+ continue turn
+ }
+
+ idx := 0
+ if len(options) > 1 {
+ idx = s.Rand.Intn(len(options) - 1)
+ }
+ a.Targets().AddTarget(options[idx])
+ }
+
+ ctrl.actions <- a
+ ctrl.awaitGameStateSync()
+ }
+
+ for _, u := range s.OwnUnits(kraken) {
+ unitAI := NewUnitAI(s, u)
+ if unitAI == nil {
+ continue
+ }
+
+ unitAI.promptAction()
+ for a := range unitAI.actions {
+ ctrl.actions <- a
+ ctrl.awaitGameStateSync()
+ unitAI.promptAction()
+ }
+ }
+
+ ctrl.actions <- NewPassPriority(ctrl.kraken)
+}
+
+func (ctrl *KrakenControl) RecvAction() Action {
+ s := ctrl.kraken.gameState
+ kraken := ctrl.kraken
+ if !s.IsActivePlayer(kraken) ||
+ s.ActivePhase != Phases.ActionPhase || !s.Stack.IsEmpty() {
+
+ return NewPassPriority(ctrl.kraken)
+ }
+
+ ctrl.syncGameState.Done()
+ return <-ctrl.actions
+}
+
+func (ctrl *KrakenControl) SendNotification(n PlayerNotification) {
+}
+
+func (p *Player) IsKraken() bool {
+ return p.Name == KRAKEN_NAME
+}
+
+func (s *State) FindKraken() *Player {
+ for _, p := range s.Players {
+ if p.IsKraken() {
+ return p
+ }
+ }
+ return nil
+}
+
+func (s *State) KrakenSpawnTiles() []*Tile {
+ return s.Map.FilterTiles(func(t *Tile) bool {
+ return strings.Contains(t.Raw, "deep water spawn")
+ })
+}
diff --git a/go/game/map.go b/go/game/map.go
new file mode 100644
index 00000000..cab18f26
--- /dev/null
+++ b/go/game/map.go
@@ -0,0 +1,303 @@
+package game
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "math/rand"
+ "net/http"
+ "os"
+ "path"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
+
+const (
+ PROTOCOL string = "http://"
+ BASE_URL string = "localhost:8000/"
+ MAP_URL_PART string = "html/build/maps"
+
+ DEFAULT_RESOURCE_GAIN int = 5
+ DEFAULT_START_DECK string = "3 misc/farmer"
+)
+
+type MapYml struct {
+ Map string `yaml:"map"`
+ Symbols map[string]string `yaml:"symbols"`
+ StartDeckList string `yaml:"start_deck_list"`
+}
+
+func DummyWinCondition(*State) []*Player {
+ return []*Player{}
+}
+
+type Map struct {
+ Tiles [][]Tile
+ symbols map[string]string
+ ResourceGain int
+ StartDeckList string
+ WinCondition func(*State) []*Player
+ Stores map[Position]*Store
+}
+
+func readMap(r io.Reader) (*Map, error) {
+ data, err := io.ReadAll(r)
+ if err != nil {
+ return nil, err
+ }
+
+ return ParseMap(data)
+}
+
+func ParseMap(data []byte) (*Map, error) {
+ mapYml := MapYml{}
+ err := yaml.Unmarshal(data, &mapYml)
+ if err != nil {
+ return nil, err
+ }
+
+ mapYml.Symbols[" "] = "neutral"
+
+ m := &Map{
+ Tiles: [][]Tile{},
+ symbols: mapYml.Symbols,
+ ResourceGain: DEFAULT_RESOURCE_GAIN,
+ StartDeckList: DEFAULT_START_DECK,
+ WinCondition: DummyWinCondition,
+ Stores: make(map[Position]*Store),
+ }
+
+ if mapYml.StartDeckList != "" {
+ m.StartDeckList = mapYml.StartDeckList
+ }
+
+ rows := strings.Split(mapYml.Map, "\n")
+ for y := 0; y < len(rows); y++ {
+ row := rows[y]
+ m.Tiles = append(m.Tiles, []Tile{})
+ for x := 0; x < len(rows[y]); x++ {
+ pos := Position{x, y}
+ s := string(row[x])
+ tile, err := NewTileFromString(mapYml.Symbols[s], pos)
+ if err != nil {
+ return nil, err
+ }
+
+ if tile.Type == TileTypes.Store {
+ m.Stores[pos] = NewStore()
+ }
+
+ m.Tiles[y] = append(m.Tiles[y], tile)
+ }
+ }
+
+ m.selectStreets()
+
+ return m, nil
+}
+
+func (m *Map) selectStreets() {
+ for y := 0; y < len(m.Tiles); y++ {
+ for x := 0; x < len(m.Tiles[y]); x++ {
+ tile := &m.Tiles[y][x]
+ if tile.Type != TileTypes.Street {
+ continue
+ }
+ connections, left, right, above, below := m.FindStreetConnections(x, y)
+
+ // Decide if curve or straight
+ if connections == 2 && (left || right) && (above || below) {
+ tile.Raw = "street 2"
+ }
+
+ if connections > 2 {
+ tile.Raw = fmt.Sprintf("street %d", connections)
+ }
+ }
+ }
+}
+
+func (m *Map) FindNeighbours(x, y int) (left, right, above, below *Tile) {
+ left, right, above, below = nil, nil, nil, nil
+ if y > 0 {
+ above = &m.Tiles[y-1][x]
+ }
+
+ if y < len(m.Tiles)-1 {
+ below = &m.Tiles[y+1][x]
+ }
+
+ if x > 0 {
+ left = &m.Tiles[y][x-1]
+ }
+
+ if x < len(m.Tiles[y])-1 {
+ right = &m.Tiles[y][x+1]
+ }
+
+ return left, right, above, below
+}
+
+func (m *Map) FindAnyConnections(x, y int) (connections int, left, right, above, below bool) {
+ leftT, rightT, aboveT, belowT := m.FindNeighbours(x, y)
+ connections = 0
+
+ if leftT != nil && leftT.Type != TileTypes.Neutral {
+ left = true
+ connections += 1
+ }
+ if rightT != nil && rightT.Type != TileTypes.Neutral {
+ right = true
+ connections += 1
+ }
+ if aboveT != nil && aboveT.Type != TileTypes.Neutral {
+ above = true
+ connections += 1
+ }
+ if belowT != nil && belowT.Type != TileTypes.Neutral {
+ below = true
+ connections += 1
+ }
+
+ return
+}
+
+func (m *Map) FindStreetConnections(x int, y int) (connections int, left, right, above, below bool) {
+ leftT, rightT, aboveT, belowT := m.FindNeighbours(x, y)
+ connections = 0
+
+ if leftT != nil && leftT.Type == TileTypes.Street {
+ left = true
+ connections += 1
+ }
+ if rightT != nil && rightT.Type == TileTypes.Street {
+ right = true
+ connections += 1
+ }
+ if aboveT != nil && aboveT.Type == TileTypes.Street {
+ above = true
+ connections += 1
+ }
+ if belowT != nil && belowT.Type == TileTypes.Street {
+ below = true
+ connections += 1
+ }
+
+ return
+}
+
+func GetMap(name string) (*Map, error) {
+ url := path.Join(BASE_URL, MAP_URL_PART, name+".yml")
+ log.Printf("loading map from %s\n", url)
+ resp, err := http.Get(PROTOCOL + url)
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer resp.Body.Close()
+ return readMap(resp.Body)
+}
+
+func LoadMap(file string) (*Map, error) {
+ f, err := os.Open(file)
+ if err != nil {
+ log.Fatal("Opening map ", file, " failed: ", err)
+ }
+ defer f.Close()
+
+ return readMap(f)
+}
+
+func (m *Map) TileAt(p Position) *Tile {
+ if p.Y >= len(m.Tiles) || p.X >= len(m.Tiles[p.Y]) {
+ return nil
+ }
+ return &m.Tiles[p.Y][p.X]
+}
+
+func (m *Map) RandomTile(r *rand.Rand, types []TileType) Position {
+ candidates := []Position{}
+ for y := 0; y < len(m.Tiles); y++ {
+ for x := 0; x < len(m.Tiles[y]); x++ {
+ candidate := m.Tiles[y][x]
+ for _, t := range types {
+ if candidate.Type == t {
+ candidates = append(candidates, candidate.Position)
+ break
+ }
+ }
+ }
+ }
+
+ randIdx := r.Intn(len(candidates))
+ return candidates[randIdx]
+}
+
+func (m *Map) RandomTileFromSymbols(r *rand.Rand, symbols []string) (Position, error) {
+ types := make([]TileType, 0, len(symbols))
+ for _, symbol := range symbols {
+ tileString, ok := m.symbols[symbol]
+ if !ok {
+ return INVALID_POSITION(), fmt.Errorf(fmt.Sprintf("symbol %s not in the map symbols %v", symbol, m.symbols))
+ }
+
+ tileType, ok := TileNames[tileString]
+ if !ok {
+ return INVALID_POSITION(), errors.New("tile type unknown")
+ }
+
+ types = append(types, tileType)
+ }
+ return m.RandomTile(r, types), nil
+}
+
+func (m *Map) FilterTiles(filter func(t *Tile) bool) []*Tile {
+ tiles := []*Tile{}
+ for y := 0; y < len(m.Tiles); y++ {
+ for x := 0; x < len(m.Tiles[y]); x++ {
+ tile := &m.Tiles[y][x]
+ if filter(tile) {
+ tiles = append(tiles, tile)
+ }
+ }
+ }
+ return tiles
+}
+
+func (m *Map) AllTiles() []*Tile {
+ return m.FilterTiles(func(*Tile) bool { return true })
+}
+
+func (m *Map) distributeStoreCards(cards PileOfCards, rand *rand.Rand) {
+ nStores := len(m.Stores)
+ stores := make([]*Store, 0, nStores)
+ for _, store := range m.Stores {
+ stores = append(stores, store)
+ }
+
+ d := NewDeckFrom(cards)
+ d.Shuffle(rand)
+ nCards := d.Size()
+
+ for i := 0; i < nCards; i++ {
+ c := d.DrawOne()
+ stores[i%nStores].AddCard(c)
+ }
+}
+
+func (m *Map) redistributeStoreCards(cards PileOfCards, rand *rand.Rand) {
+ poc := NewPileOfCards()
+ for _, store := range m.Stores {
+ store.MoveInto(poc)
+ }
+ m.distributeStoreCards(poc, rand)
+}
+
+func (m *Map) HasStores() bool {
+ return len(m.Stores) > 0
+}
+
+func (m *Map) StoreOn(p Position) *Store {
+ return m.Stores[p]
+}
diff --git a/go/game/map_test.go b/go/game/map_test.go
new file mode 100644
index 00000000..6972f867
--- /dev/null
+++ b/go/game/map_test.go
@@ -0,0 +1,85 @@
+package game
+
+import (
+ "strings"
+ "testing"
+)
+
+func parseMapString(t *testing.T, mapDef string) *Map {
+ r := strings.NewReader(mapDef)
+ m, err := readMap(r)
+ if err != nil {
+ t.Fatalf("Parsing failed with: %v", err)
+ }
+ return m
+}
+
+func TestParseMapEndingStreets(t *testing.T) {
+ mapDef := `map: |1-
+ HST
+ HSF
+ TST
+
+symbols:
+ T: tower
+ H: house
+ F: farm
+ S: street
+`
+
+ parseMapString(t, mapDef)
+}
+
+func TestParseMap(t *testing.T) {
+ s := `map: |1-
+ T H HT
+ FSSSSS2H
+ SHSH HS
+ S SST S
+ H HFH H
+ S TSS S
+ SH HSHS
+ H1SSSSSF
+ TH H T
+
+symbols:
+ T: tower
+ H: house
+ F: farm
+ S: street
+ 1: spawn player 1
+ 2: spawn player 2
+
+kings:
+ - (1,9)
+ - (9,1)`
+
+ m := parseMapString(t, s)
+
+ if m.Tiles[0][0].Type != TileTypes.Tower {
+ t.Fatalf("Unexpected tile at 0,0: %v (expected %v)", m.Tiles[0][0], TileTypes.Tower)
+ }
+
+ for y := 0; y < len(m.Tiles); y++ {
+ for x := 0; x < len(m.Tiles[y]); x++ {
+ if m.TileAt(Position{x, y}) != &m.Tiles[y][x] {
+ t.Fatalf("TileAt and Tiles differ")
+ }
+ }
+ }
+
+ if m.ResourceGain != DEFAULT_RESOURCE_GAIN {
+ t.Fatalf("resource gain is not the default one")
+ }
+}
+
+func TestLoadingAllMaps(t *testing.T) {
+ maps := []string{"the-tyrant", "2P-ring-street", "2P-river-king", "3P-ring-street", "4P-islands", "the-kraken"}
+
+ for _, m := range maps {
+ _, err := LoadMap("../../maps/" + m + ".yml")
+ if err != nil {
+ t.Fatalf("Loading map %s failed with %s", m, err)
+ }
+ }
+}
diff --git a/go/game/movement.go b/go/game/movement.go
new file mode 100644
index 00000000..1a3f03c4
--- /dev/null
+++ b/go/game/movement.go
@@ -0,0 +1,52 @@
+package game
+
+import (
+ "fmt"
+ "log"
+ "strconv"
+ "strings"
+)
+
+type Movement struct {
+ Range int
+ Swimming bool
+}
+
+func INVALID_MOVEMENT() Movement {
+ return Movement{-1, false}
+}
+
+func parseMovement(desc interface{}) Movement {
+ var movementStr string
+ switch typedDesc := desc.(type) {
+ case int:
+ return Movement{typedDesc, false}
+ case string:
+ movementStr = typedDesc
+ case map[string]interface{}:
+ movementStr = typedDesc["en"].(string)
+ }
+
+ tokens := strings.Split(movementStr, " ")
+ r, err := strconv.Atoi(tokens[0])
+ if err != nil {
+ log.Fatalf("Invalid movement description %s", movementStr)
+ }
+
+ swimming := false
+ if len(tokens) > 1 && tokens[1] == "swimming" {
+ swimming = true
+ }
+
+ return Movement{r, swimming}
+}
+
+func (m Movement) String() string {
+ if m == INVALID_MOVEMENT() {
+ return "invalid movement"
+ }
+ if m.Swimming {
+ return fmt.Sprintf("%d swimming", m.Range)
+ }
+ return fmt.Sprintf("%d", m.Range)
+}
diff --git a/go/game/permanent.go b/go/game/permanent.go
new file mode 100644
index 00000000..b4090592
--- /dev/null
+++ b/go/game/permanent.go
@@ -0,0 +1,131 @@
+package game
+
+import (
+ "fmt"
+ "log"
+ "strconv"
+ "strings"
+
+ "golang.org/x/exp/slices"
+)
+
+type Permanent interface {
+ Card() *Card
+ Tile() *Tile
+ Damage() int
+ Pile() []Permanent
+ Controller() *Player
+ Owner() *Player
+ Effects() []string
+ HasEffect(string) bool
+ XEffect(string) (int, error)
+
+ String() string
+ Fight(p Permanent)
+ AddDamage(damage int)
+ IsDestroyed() bool
+ Attackable() bool
+ AvailActions() []Action
+ IsAvailableTile(*Tile) bool
+}
+
+type PermanentBase struct {
+ card *Card
+ tile *Tile
+ damage int
+ pile []Permanent
+ controller *Player
+ owner *Player
+ tmpEffects []string
+}
+
+func NewPermanentBase(card *Card, tile *Tile, owner *Player) PermanentBase {
+ if tile.Permanent != nil {
+ log.Fatalf("Tile at %v already occupied by %v", tile.Position, tile.Permanent)
+ }
+
+ return PermanentBase{
+ card: card,
+ tile: tile,
+ controller: owner,
+ owner: owner,
+ }
+}
+
+func (p *PermanentBase) Card() *Card {
+ return p.card
+}
+
+func (p *PermanentBase) Tile() *Tile {
+ return p.tile
+}
+
+func (p *PermanentBase) Damage() int {
+ return p.damage
+}
+
+func (p *PermanentBase) Pile() []Permanent {
+ return p.pile
+}
+
+func (p *PermanentBase) Controller() *Player {
+ return p.controller
+}
+
+func (p *PermanentBase) Owner() *Player {
+ return p.owner
+}
+
+func (p *PermanentBase) String() string {
+ return fmt.Sprintf("%s's %s@%s",
+ p.Controller().Name, p.Card().Name, p.Tile().Position.String())
+}
+
+func DealDamage(src, dest Permanent, damage int) {
+ actualDamage := damage
+ shield, err := dest.XEffect("shield")
+ if err != nil {
+ // TODO: implement piercing
+
+ actualDamage -= shield
+ if actualDamage < 0 {
+ actualDamage = 0
+ }
+ }
+ dest.AddDamage(actualDamage)
+}
+
+func (p *PermanentBase) AddDamage(damage int) {
+ p.damage += damage
+}
+
+func (p *PermanentBase) Effects() []string {
+ return append(p.card.getEffects(), p.tmpEffects...)
+}
+
+func (p *PermanentBase) HasEffect(effect string) bool {
+ return slices.Contains(p.tmpEffects, effect) || p.card.hasEffect(effect)
+}
+
+func (p *PermanentBase) XEffect(effect string) (int, error) {
+ x, err := p.card.getXEffect(effect)
+
+ for _, e := range p.tmpEffects {
+ if !strings.Contains(e, effect) {
+ continue
+ }
+
+ tokens := strings.SplitN(e, " ", 2)
+ tmpX, err := strconv.Atoi(tokens[1])
+ if err != nil {
+ return 0, err
+ }
+ x += tmpX
+ }
+
+ return x, err
+}
+
+func (p *PermanentBase) IsAvailableTile(tile *Tile) bool {
+ return tile.IsAvailableForCard(p.card)
+}
diff --git a/go/game/phases.go b/go/game/phases.go
new file mode 100644
index 00000000..ea239278
--- /dev/null
+++ b/go/game/phases.go
@@ -0,0 +1,42 @@
+package game
+
+type PhaseType int
+
+const (
+ drawPhase = iota
+ upkeepPhase
+ actionPhase
+ buyPhase
+ discardPhase
+)
+
+func (p PhaseType) String() string {
+ switch p {
+ case drawPhase:
+ return "Draw"
+ case upkeepPhase:
+ return "Upkeep"
+ case actionPhase:
+ return "Actions"
+ case buyPhase:
+ return "Buy"
+ case discardPhase:
+ return "Discard"
+ }
+
+ return ""
+}
+
+var Phases = struct {
+ DrawPhase PhaseType
+ UpkeepPhase PhaseType
+ ActionPhase PhaseType
+ BuyPhase PhaseType
+ DiscardPhase PhaseType
+}{
+ DrawPhase: drawPhase,
+ UpkeepPhase: upkeepPhase,
+ ActionPhase: actionPhase,
+ BuyPhase: buyPhase,
+ DiscardPhase: discardPhase,
+}
diff --git a/go/game/pileOfCards.go b/go/game/pileOfCards.go
new file mode 100644
index 00000000..c8b81c87
--- /dev/null
+++ b/go/game/pileOfCards.go
@@ -0,0 +1,63 @@
+package game
+
+type PileOfCards interface {
+ Size() int
+ IsEmpty() bool
+ Cards() []*Card
+ AddCards(cards []*Card)
+ AddCard(card *Card)
+ RemoveCard(card *Card)
+ MoveCard(card *Card, into PileOfCards)
+ MoveInto(into PileOfCards)
+}
+
+type PileOfCardsBase struct {
+ cards []*Card
+}
+
+func NewPileOfCards() *PileOfCardsBase {
+ return &PileOfCardsBase{[]*Card{}}
+}
+
+func (poc *PileOfCardsBase) Size() int {
+ return len(poc.cards)
+}
+
+func (poc *PileOfCardsBase) IsEmpty() bool {
+ return poc.Size() == 0
+}
+
+func (poc *PileOfCardsBase) Cards() []*Card {
+ return poc.cards
+}
+
+func (poc *PileOfCardsBase) AddCards(cards []*Card) {
+ poc.cards = append(poc.cards, cards...)
+}
+
+func (poc *PileOfCardsBase) AddCard(card *Card) {
+ poc.cards = append(poc.cards, card)
+}
+
+func (poc *PileOfCardsBase) RemoveCard(card *Card) {
+ ncards := poc.Size()
+ for i, c := range poc.cards {
+ if c != card {
+ continue
+ }
+
+ poc.cards[i] = poc.cards[ncards-1]
+ poc.cards = poc.cards[:ncards-1]
+ return
+ }
+}
+
+func (poc *PileOfCardsBase) MoveCard(c *Card, into PileOfCards) {
+ poc.RemoveCard(c)
+ into.AddCard(c)
+}
+
+func (poc *PileOfCardsBase) MoveInto(into PileOfCards) {
+ into.AddCards(poc.cards)
+ poc.cards = []*Card{}
+}
diff --git a/go/game/player.go b/go/game/player.go
new file mode 100644
index 00000000..0eb474e9
--- /dev/null
+++ b/go/game/player.go
@@ -0,0 +1,140 @@
+package game
+
+import (
+ "image/color"
+ "log"
+)
+
+const (
+ MAX_DRAW int = 3
+)
+
+type Player struct {
+ Id int
+ Name string
+ Turn int
+ Resource int
+ DrawPerTurn int
+ Hand *Hand
+ DiscardPile *DiscardPile
+ Deck *Deck
+ Store *Store
+ gameState *State
+ Color color.Color
+ Ctrl PlayerControl
+}
+
+func NewPlayer(id int, name string, deck *Deck, gameState *State) *Player {
+ store := NewStore()
+ deck.MoveInto(store)
+
+ p := Player{
+ Id: id,
+ Name: name,
+ Turn: 0,
+ Resource: 0,
+ DrawPerTurn: MAX_DRAW,
+ Hand: NewHand(),
+ DiscardPile: NewDiscardPile(),
+ Deck: NewDeckFromDeckList(gameState.Map.StartDeckList),
+ Store: store,
+ gameState: gameState,
+ Ctrl: nil,
+ }
+ return &p
+}
+
+func (p *Player) resourceGain() int {
+ if p.Name == "The kraken" {
+ return p.gameState.Map.ResourceGain * (len(p.gameState.Players) - 1)
+ } else {
+ return p.gameState.Map.ResourceGain
+ }
+}
+
+func (p *Player) gainResource(gain int) {
+ p.Resource += gain
+}
+
+func (p *Player) Upkeep() {
+ p.gainResource(p.resourceGain())
+
+ // TODO: handle upkeep triggers
+
+ for _, unit := range p.gameState.Units {
+ if unit.controller != p {
+ continue
+ }
+
+ // TODO: let players decide if they want to pay the upkeep
+ p.Resource -= unit.Upkeep
+
+ // TODO: better refresh unit actions
+ unit.AvailAttackActions = 1
+ unit.AvailMoveActions = 1
+ }
+}
+
+func (p *Player) ActionPhase() {
+ for {
+ a := promptAction(p.Ctrl)
+ if _, ok := a.(*PassPriority); ok {
+ return
+ }
+ p.gameState.DeclareAction(a)
+ p.gameState.Stack.Resolve()
+ }
+}
+
+func (p *Player) BuyPhase() {
+ a := promptBuy(p.Ctrl)
+ if _, ok := a.(*PassPriority); ok {
+ return
+ }
+ p.gameState.DeclareAction(a)
+ p.gameState.Stack.Resolve()
+}
+
+func (p *Player) DiscardCard(c *Card) {
+ p.Hand.MoveCard(c, p.DiscardPile)
+}
+
+func (p *Player) DiscardPhase() {
+ cards := p.PromptHandCardSelection(0, p.Hand.Size())
+ for _, c := range cards {
+ p.DiscardCard(c)
+ }
+}
+
+func (p *Player) PromptHandCardSelection(min, max int) []*Card {
+ p.Ctrl.SendNotification(NewHandCardSelectionPrompt(min, max))
+ switch a := p.Ctrl.RecvAction().(type) {
+ case *HandCardSelection:
+ // TODO: validate cards
+ return a.cards
+ case *PassPriority:
+ return nil
+ default:
+ log.Fatalf("Unexpected response type %T for hand card selection", a)
+ }
+ return nil
+}
+
+func (p *Player) Draw() {
+ toDraw := p.DrawPerTurn - p.Hand.Size()
+ if toDraw < 1 {
+ return
+ }
+
+ p.DrawN(toDraw)
+}
+
+func (p *Player) DrawN(n int) {
+ drawnCards := p.Deck.Draw(n)
+ p.Hand.AddCards(drawnCards)
+}
+
+func (p *Player) IsEnemy(other *Player) bool {
+ // TODO: support coop / teams
+ return p != other
+}
diff --git a/go/game/playerControl.go b/go/game/playerControl.go
new file mode 100644
index 00000000..e62d91b3
--- /dev/null
+++ b/go/game/playerControl.go
@@ -0,0 +1,92 @@
+package game
+
+type PlayerNotificationType int
+
+const (
+ DeclaredActionNotification = iota
+ ResolvedActionNotification
+ PriorityNotification
+ UpkeepPrompt
+ BuyPrompt
+ HandCardSelectionPrompt
+)
+
+func (n PlayerNotification) IsPriorityNotification() bool {
+ return n.Notification == PriorityNotification
+}
+
+type PlayerNotification struct {
+ Notification PlayerNotificationType
+ Context interface{}
+ Error error
+}
+
+func NewPriorityNotification() PlayerNotification {
+ return PlayerNotification{PriorityNotification, nil, nil}
+}
+
+func NewDeclaredActionNotification(a Action, err error) PlayerNotification {
+ return PlayerNotification{DeclaredActionNotification, a, err}
+}
+
+func NewResolvedActionNotification(a Action, err error) PlayerNotification {
+ return PlayerNotification{ResolvedActionNotification, a, err}
+}
+
+func NewUpkeepPrompt(u *Unit) PlayerNotification {
+ return PlayerNotification{ResolvedActionNotification, u, nil}
+}
+
+func NewBuyPrompt() PlayerNotification {
+ return PlayerNotification{BuyPrompt, nil, nil}
+}
+
+func NewHandCardSelectionPrompt(min, max int) PlayerNotification {
+ return PlayerNotification{HandCardSelectionPrompt, []int{min, max}, nil}
+}
+
+type PlayerControl interface {
+ RecvAction() Action
+ SendNotification(PlayerNotification)
+}
+
+type DummyPlayerControl struct{ P *Player }
+
+func (c *DummyPlayerControl) RecvAction() Action { return NewPassPriority(c.P) }
+func (*DummyPlayerControl) SendNotification(PlayerNotification) {}
+
+type ChanPlayerControl struct {
+ Actions chan Action
+ Notifications chan PlayerNotification
+}
+
+func (c *ChanPlayerControl) SendAction(a Action) { c.Actions <- a }
+func (c *ChanPlayerControl) RecvAction() Action { return <-c.Actions }
+func (c *ChanPlayerControl) SendNotification(n PlayerNotification) { c.Notifications <- n }
+func (c *ChanPlayerControl) RecvNotification() *PlayerNotification {
+ select {
+ case n := <-c.Notifications:
+ return &n
+ default:
+ return nil
+ }
+}
+
+func NewChanPlayerControl() *ChanPlayerControl {
+ a := make(chan Action)
+ n := make(chan PlayerNotification)
+ return &ChanPlayerControl{a, n}
+}
+
+func prompt(ctrl PlayerControl, notification PlayerNotification) Action {
+ ctrl.SendNotification(NewPriorityNotification())
+ return ctrl.RecvAction()
+}
+
+func promptBuy(ctrl PlayerControl) Action {
+ return prompt(ctrl, NewBuyPrompt())
+}
+
+func promptAction(ctrl PlayerControl) Action {
+ return prompt(ctrl, NewPriorityNotification())
+}
diff --git a/go/game/pos.go b/go/game/pos.go
new file mode 100644
index 00000000..2a646a83
--- /dev/null
+++ b/go/game/pos.go
@@ -0,0 +1,22 @@
+package game
+
+import (
+ "fmt"
+)
+
+type Position struct {
+ X int
+ Y int
+}
+
+func (p *Position) String() string {
+ if *p == INVALID_POSITION() {
+ return "INVALID_POSITION"
+ }
+
+ return fmt.Sprintf("(%d, %d)", p.X, p.Y)
+}
+
+func INVALID_POSITION() Position {
+ return Position{-1, -1}
+}
diff --git a/go/game/range.go b/go/game/range.go
new file mode 100644
index 00000000..ab86c44e
--- /dev/null
+++ b/go/game/range.go
@@ -0,0 +1,58 @@
+package game
+
+import (
+ "math"
+)
+
+func IsPositionInRange(origin Position, pos Position, r int) bool {
+ x, y := float64(pos.X-origin.X), float64(pos.Y-origin.Y)
+ ax, ay := math.Abs(x), math.Abs(y)
+ if ax > float64(r) || ay > float64(r) {
+ return false
+ }
+
+ var abs float64
+ if pos.X > pos.Y {
+ abs = ax + math.Floor(ay/2)
+ } else {
+ abs = ay + math.Floor(ax/2)
+ }
+
+ return abs <= float64(r)
+}
+
+func PositionsInRange(origin Position, r int, includeOrigin bool) []Position {
+ positions := []Position{}
+ for x := -r; x <= r; x++ {
+ for y := -r; y <= r; y++ {
+ pos := Position{origin.X + x, origin.Y + y}
+ if pos.X < 0 || pos.Y < 0 {
+ continue
+ }
+
+ if !includeOrigin && pos == origin {
+ continue
+ }
+
+ if IsPositionInRange(origin, pos, r) {
+ positions = append(positions, pos)
+ }
+ }
+ }
+ return positions
+}
+
+func TilesInRangeFromOrigin(m *Map, origin Position, r int) []*Tile {
+ tiles := []*Tile{}
+ for _, pos := range PositionsInRange(origin, r, false) {
+ tile := m.TileAt(pos)
+ if tile != nil {
+ tiles = append(tiles, tile)
+ }
+ }
+ return tiles
+}
+
+func TilesInRange(m *Map, p Permanent, r int) []*Tile {
+ return TilesInRangeFromOrigin(m, p.Tile().Position, r)
+}
diff --git a/go/game/range_test.go b/go/game/range_test.go
new file mode 100644
index 00000000..4b6e05ad
--- /dev/null
+++ b/go/game/range_test.go
@@ -0,0 +1,61 @@
+package game
+
+import (
+ "testing"
+
+ "golang.org/x/exp/slices"
+)
+
+func TestPositionsInRange(t *testing.T) {
+ origin := Position{0, 0}
+ positionsInRange1 := []Position{{0, 1}, {1, 0}, {1, 1}}
+ calculatedPositions := PositionsInRange(origin, 1, false)
+ if len(positionsInRange1) != len(calculatedPositions) {
+ t.Fatalf("Unexpected number of calculated positions")
+ }
+
+ for _, p := range positionsInRange1 {
+ if !slices.Contains(calculatedPositions, p) {
+ t.Fatalf("Position %v not in calculated positions", p)
+ }
+ }
+
+ origin = Position{2, 2}
+ positionsInRange2 := []Position{
+ {0, 1},
+ {0, 2},
+ {0, 3},
+ {1, 0},
+ {1, 1},
+ {1, 2},
+ {1, 3},
+ {1, 4},
+ {2, 0},
+ {2, 1},
+ {2, 3},
+ {2, 4},
+ {3, 0},
+ {3, 1},
+ {3, 2},
+ {3, 3},
+ {3, 4},
+ {4, 1},
+ {4, 2},
+ {4, 3},
+ }
+ calculatedPositions = PositionsInRange(origin, 2, false)
+
+ for _, p := range positionsInRange2 {
+ if !slices.Contains(calculatedPositions, p) {
+ t.Fatalf("Position %v not in calculated positions", p)
+ }
+ }
+}
+
+func TestPositionNotInRange(t *testing.T) {
+ o := Position{5, 0}
+ p := Position{6, 3}
+ if IsPositionInRange(o, p, 2) {
+ t.Fatalf("Invalid range inclusion %v not in %v", o, PositionsInRange(o, 2, false))
+ }
+}
diff --git a/go/game/set.go b/go/game/set.go
new file mode 100644
index 00000000..b0565eca
--- /dev/null
+++ b/go/game/set.go
@@ -0,0 +1,78 @@
+package game
+
+import (
+ "log"
+)
+
+type SetIdentifier int
+
+const (
+ Base SetIdentifier = iota + 1
+ Magic
+ Equipments
+ Misc
+ Nautics
+ Potions
+ Kraken
+ Tyrant
+ Exp1
+)
+
+func (si SetIdentifier) String() string {
+ switch si {
+ case Base:
+ return "base"
+ case Magic:
+ return "magic"
+ case Equipments:
+ return "equipments"
+ case Misc:
+ return "misc"
+ case Nautics:
+ return "nautics"
+ case Potions:
+ return "potions"
+ case Kraken:
+ return "kraken"
+ case Tyrant:
+ return "tyrant"
+ case Exp1:
+ return "exp1"
+ }
+ log.Panicf("Switch not exhausting %d", si)
+ return ""
+}
+
+var SetIdentifiers = struct {
+ Base SetIdentifier
+ Magic SetIdentifier
+ Equipments SetIdentifier
+ Misc SetIdentifier
+ Nautics SetIdentifier
+ Potions SetIdentifier
+ Kraken SetIdentifier
+ Tyrant SetIdentifier
+ Exp1 SetIdentifier
+}{
+ Base: Base,
+ Magic: Magic,
+ Equipments: Equipments,
+ Misc: Misc,
+ Nautics: Nautics,
+ Potions: Potions,
+ Kraken: Kraken,
+ Tyrant: Tyrant,
+ Exp1: Exp1,
+}
+
+var SetIdentifierNames = map[string]SetIdentifier{
+ "base": Base,
+ "magic": Magic,
+ "equipments": Equipments,
+ "misc": Misc,
+ "nautics": Nautics,
+ "potions": Potions,
+ "kraken": Kraken,
+ "tyrant": Tyrant,
+ "exp1": Exp1,
+}
diff --git a/go/game/stack.go b/go/game/stack.go
new file mode 100644
index 00000000..9132ae13
--- /dev/null
+++ b/go/game/stack.go
@@ -0,0 +1,46 @@
+package game
+
+import (
+ "log"
+)
+
+type Stack struct {
+ gameState *State
+ Actions []Action
+}
+
+func NewStack(s *State) *Stack {
+ return &Stack{s, []Action{}}
+}
+
+func (s *Stack) IsEmpty() bool {
+ return len(s.Actions) == 0
+}
+
+func (s *Stack) Push(a Action) {
+ s.Actions = append(s.Actions, a)
+}
+
+func (s *Stack) Pop() {
+ l := len(s.Actions)
+ if l == 0 {
+ log.Fatalf("Can not pop from empty stack")
+ }
+
+ a := s.Actions[l-1]
+ s.Actions = s.Actions[:l-1]
+
+ err := s.gameState.ValidateAction(a)
+ if err == nil {
+ a.Resolve(s.gameState)
+ }
+ log.Println("Resolved", a, err)
+ s.gameState.broadcastNotification(NewResolvedActionNotification(a, err))
+}
+
+func (s *Stack) Resolve() {
+ for !s.IsEmpty() {
+ s.gameState.AllPassing(false)
+ s.Pop()
+ }
+}
diff --git a/go/game/state.go b/go/game/state.go
new file mode 100644
index 00000000..1d4b644f
--- /dev/null
+++ b/go/game/state.go
@@ -0,0 +1,453 @@
+package game
+
+import (
+ "fmt"
+ "log"
+ "math/rand"
+ "strconv"
+ "strings"
+ "time"
+
+ "golang.org/x/exp/slices"
+)
+
+type State struct {
+ Decks []*Deck
+ Stores []*Store
+ Exile *PileOfCardsBase
+ Map *Map
+ Stack *Stack
+ Hands []*Hand
+ DiscardPiles []*DiscardPile
+ Players []*Player
+ ActivePlayer *Player
+ ActivePhase PhaseType
+ Permanents []Permanent
+ Units []*Unit
+ Artifacts []*Artifact
+ Rand *rand.Rand
+}
+
+func NewState() *State {
+ s := &State{}
+ s.Stack = NewStack(s)
+ s.Exile = NewPileOfCards()
+ s.Rand = rand.New(rand.NewSource(time.Now().UnixNano()))
+ return s
+}
+
+func (s *State) AddNewPlayer(name string, deck *Deck) {
+ p := NewPlayer(len(s.Players)+1, name, deck, s)
+ s.Players = append(s.Players, p)
+}
+
+func (s *State) IsActivePlayer(p *Player) bool {
+ return s.ActivePlayer == p
+}
+
+func (s *State) Loop() []*Player {
+ winners := []*Player{}
+
+ if s.Map.HasStores() {
+ poc := NewPileOfCards()
+ for _, p := range s.Players {
+ if p.IsKraken() {
+ continue
+ }
+
+ p.Store.MoveInto(poc)
+ }
+ s.Map.distributeStoreCards(poc, s.Rand)
+ }
+
+ for len(winners) == 0 {
+ for _, p := range s.Players {
+ s.ActivePlayer = p
+ p.Turn++
+
+ if p.Deck.IsEmpty() {
+ log.Printf("Shuffle %s pile into the deck\n", p.Name)
+ p.DiscardPile.MoveInto(p.Deck)
+ p.Deck.Shuffle(s.Rand)
+ }
+
+ s.ActivePhase = Phases.DrawPhase
+ p.Draw()
+
+ s.ActivePhase = Phases.UpkeepPhase
+ p.Upkeep()
+ s.PhaseChange()
+
+ s.ActivePhase = Phases.ActionPhase
+ p.ActionPhase()
+ s.PhaseChange()
+
+ s.ActivePhase = Phases.BuyPhase
+ p.BuyPhase()
+ s.PhaseChange()
+
+ s.ActivePhase = Phases.DiscardPhase
+ p.DiscardPhase()
+
+ winners = s.Map.WinCondition(s)
+ }
+ }
+
+ return winners
+}
+
+func (s *State) IsValidPlay(a *PlayAction) error {
+ if s.ActivePhase != Phases.ActionPhase {
+ log.Fatalf("Play actions can only be declared during one's actions phase")
+ }
+
+ if a.Card.IsPermanent() {
+ spawn := a.Targets().sel[0].(*Tile)
+ if !slices.Contains(s.AvailableSpawnTiles(a.Source().(*Player), a.Card), spawn) {
+ return fmt.Errorf("Spawn %v is not a valid spawn tile for %s",
+ spawn.Position.String(), a.Card.Name)
+ }
+ }
+
+ return nil
+}
+
+func (s *State) IsValidMove(a *MoveAction) error {
+ if s.ActivePhase != Phases.ActionPhase {
+ return fmt.Errorf("Move actions can only be declared during one's actions phase")
+ }
+
+ u := a.Source().(*Unit)
+ tile := a.Targets().sel[0].(*Tile)
+
+ if !slices.Contains(u.MoveRangeTiles(s.Map), tile) {
+ return fmt.Errorf("Unit %s at %s can not move to %s",
+ u.card.Name, u.tile.Position.String(), tile.Position.String())
+ }
+
+ if tile.Permanent != nil {
+ return fmt.Errorf("Unit %s at %s can not move to occupied %s",
+ u.card.Name, u.tile.Position.String(), tile.Position.String())
+ }
+
+ return nil
+}
+
+func (s *State) IsValidAttack(a *AttackAction) error {
+ if s.ActivePhase != Phases.ActionPhase {
+ return fmt.Errorf("Attack actions can only be declared during one's actions phase")
+ }
+
+ unit := a.Source().(Unit)
+ if unit.Attack == INVALID_ATTACK() {
+ return fmt.Errorf("%s can not attack", unit.card.Name)
+ }
+
+ target := a.Targets().sel[0].(Permanent)
+ if !IsPositionInRange(unit.tile.Position, target.Tile().Position, unit.Attack.Range) {
+ return fmt.Errorf("Attack target on %s is not in attack range %d of %s on %s",
+ target.Tile().Position.String(), unit.Attack.Range, unit.card.Name,
+ unit.tile.Position.String())
+ }
+
+ return nil
+}
+
+func (s *State) isValidBuy(a *BuyAction) error {
+ if s.Map.HasStores() {
+ var storeTiles []*Tile
+ for pos, store := range s.Map.Stores {
+ if slices.Contains(store.cards, a.card) {
+ storeTiles = append(storeTiles, s.Map.TileAt(pos))
+ }
+ }
+
+ if storeTiles == nil {
+ return fmt.Errorf("no store contains %s", a.card.Name)
+ }
+
+ controlsUnitOnStore := false
+ for _, tile := range storeTiles {
+ controlsUnitOnStore = controlsUnitOnStore || (tile.Permanent != nil &&
+ tile.Permanent.Card().Type == CardTypes.Unit &&
+ tile.Permanent.Controller() == a.player)
+ }
+ if !controlsUnitOnStore {
+ return fmt.Errorf("%s' controls no unit on a store containing %s",
+ a.player.Name, a.card.Name)
+ }
+
+ } else if !slices.Contains(a.player.Store.cards, a.card) {
+ return fmt.Errorf("%s's store does not contain %s", a.player.Name, a.card.Name)
+ }
+
+ return nil
+}
+
+func (s *State) ValidateAction(a Action) (err error) {
+ err = a.CheckTargets(s)
+ if err != nil {
+ return
+ }
+
+ switch a := a.(type) {
+ case *PlayAction:
+ err = s.IsValidPlay(a)
+ case *MoveAction:
+ err = s.IsValidMove(a)
+ case *AttackAction:
+ err = s.IsValidAttack(a)
+ case *BuyAction:
+ }
+
+ return
+}
+
+func (s *State) PushAction(a Action) {
+ if !a.PayCosts(s) {
+ log.Fatalf("Cost for %s could not be paid", a)
+ }
+
+ s.Stack.Push(a)
+}
+
+func (s *State) DeclareAction(a Action) {
+ var err error = nil
+
+ err = a.CheckTargets(s)
+
+ if err != nil {
+ switch a := a.(type) {
+ case *BuyAction:
+ if s.ActivePhase != Phases.BuyPhase {
+ err = fmt.Errorf("Cards can only be bought during one's buy phase")
+ }
+ if !a.card.IsBuyable() {
+ err = fmt.Errorf("Card %s is not buyable", a.card.Name)
+ }
+ case *FreeAction:
+ default:
+ if !s.Stack.IsEmpty() {
+ err = fmt.Errorf("Slow action can not be declared while other actions are on the stack")
+ }
+ }
+ }
+
+ if err == nil {
+ s.PushAction(a)
+ }
+
+ s.broadcastNotification(NewDeclaredActionNotification(a, err))
+}
+
+func (s *State) AllPassing(skipFirst bool) bool {
+ nPlayers := len(s.Players)
+ i := 0
+ if skipFirst {
+ i++
+ }
+ for ; i < nPlayers; i++ {
+ p := s.Players[(i+s.ActivePlayer.Id-1)%nPlayers]
+ p.Ctrl.SendNotification(NewPriorityNotification())
+ a := p.Ctrl.RecvAction()
+ switch a.(type) {
+ case *PassPriority:
+ log.Printf("%s passed %d/%d\n", p.Name, i+1, nPlayers)
+ default:
+ s.DeclareAction(a)
+ return false
+ }
+ }
+ return true
+}
+
+func (s *State) PhaseChange() {
+ skipFirst := true
+ for !s.AllPassing(skipFirst) {
+ s.Stack.Resolve()
+ skipFirst = false
+ }
+}
+
+func (s *State) broadcastNotification(n PlayerNotification) {
+ for _, p := range s.Players {
+ p.Ctrl.SendNotification(n)
+ }
+}
+
+func (s *State) AddPermanent(perm Permanent) {
+ s.Permanents = append(s.Permanents, perm)
+ switch p := perm.(type) {
+ case *Unit:
+ s.Units = append(s.Units, p)
+ case *Artifact:
+ s.Artifacts = append(s.Artifacts, p)
+ }
+}
+
+func (s *State) AddNewUnit(card *Card, pos Position, owner *Player) {
+ s.AddPermanent(NewUnit(card, s.Map.TileAt(pos), owner))
+}
+
+func (s *State) AddNewArtifact(card *Card, pos Position, owner *Player) {
+ s.AddPermanent(NewArtifact(card, s.Map.TileAt(pos), owner))
+}
+
+func (s *State) AddNewPermFromPath(cardPath string, pos Position, owner *Player) {
+ card := NewCard(cardPath)
+ switch card.Type {
+ case CardTypes.Unit:
+ s.AddNewUnit(card, pos, owner)
+ case CardTypes.Artifact:
+ s.AddNewArtifact(card, pos, owner)
+ }
+}
+
+func (s *State) removePermanent(p Permanent) {
+ for i, perm := range s.Permanents {
+ if perm == p {
+ s.Permanents[i] = s.Permanents[len(s.Permanents)-1]
+ s.Permanents = s.Permanents[:len(s.Permanents)-1]
+ }
+ }
+}
+
+func removePtr[P *Unit | *Artifact](collection []P, ptr P) []P {
+ for i, p := range collection {
+ if p == ptr {
+ collection[i] = collection[len(collection)-1]
+ return collection[:len(collection)-1]
+ }
+ }
+ return collection
+}
+
+func (s *State) DestroyPermanent(p Permanent) {
+ s.removePermanent(p)
+
+ switch p := p.(type) {
+ case *Unit:
+ s.Units = removePtr(s.Units, p)
+ case *Artifact:
+ s.Artifacts = removePtr(s.Artifacts, p)
+ }
+ p.Owner().DiscardPile.AddCard(p.Card())
+}
+
+func (s *State) Fight(p1, p2 Permanent) {
+ p1.Fight(p2)
+ p2.Fight(p1)
+ if p1.IsDestroyed() {
+ s.DestroyPermanent(p1)
+ }
+ if p2.IsDestroyed() {
+ s.DestroyPermanent(p2)
+ }
+}
+
+func (s *State) MoveUnit(u *Unit, t *Tile) {
+ u.move(s, t)
+}
+
+func (s *State) FilterUnits(filter func(*Unit) bool) []*Unit {
+ units := []*Unit{}
+ for _, u := range s.Units {
+ if filter(u) {
+ units = append(units, u)
+ }
+ }
+ return units
+}
+
+func (s *State) OwnUnits(player *Player) []*Unit {
+ return s.FilterUnits(func(u *Unit) bool {
+ return u.Controller() == player
+ })
+}
+
+func (s *State) EnemyUnits(player *Player) []*Unit {
+ return s.FilterUnits(func(u *Unit) bool {
+ return u.Controller().IsEnemy(player)
+ })
+}
+
+func (s *State) resolvePlay(p *Player, c *Card, targets *Targets) {
+ switch c.Type {
+ case CardTypes.Unit:
+ tile := targets.sel[0].(*Tile)
+ s.AddNewUnit(c, tile.Position, p)
+ case CardTypes.Artifact:
+ tile := targets.sel[0].(*Tile)
+ s.AddNewArtifact(c, tile.Position, p)
+ default:
+ log.Printf("Resolving unhandled card type: %s\n", c.Type.String())
+ }
+
+ if c.IsPermanent() {
+ } else {
+ p.DiscardPile.AddCard(c)
+ }
+}
+
+func (s *State) SpawnTiles(p *Player) []*Tile {
+ spawns := s.Map.FilterTiles(func(t *Tile) bool {
+ if t.Type != TileTypes.Spawn {
+ return false
+ }
+
+ return strings.HasSuffix(t.Raw, strconv.Itoa(p.Id)) || t.Raw == "player spawn"
+ })
+
+ return spawns
+}
+
+func (s *State) WaterSpawnTiles(p *Player) []*Tile {
+ if p.IsKraken() {
+ return s.KrakenSpawnTiles()
+ }
+
+ docks := s.Map.FilterTiles(func(t *Tile) bool {
+ if t.Type != TileTypes.Docks {
+ return false
+ }
+
+ return t.Raw == "docks" || strings.HasSuffix(t.Raw, strconv.Itoa(p.Id))
+ })
+
+ spawns := []*Tile{}
+ for _, dock := range docks {
+ for _, candidate := range TilesInRangeFromOrigin(s.Map, dock.Position, 1) {
+ if candidate.Water {
+ spawns = append(spawns, candidate)
+ }
+ }
+ }
+
+ return spawns
+}
+
+func (s *State) AvailableSpawnTiles(p *Player, c *Card) []*Tile {
+ if !c.IsPermanent() {
+ log.Fatalf("AvailableSpawnTiles called for %s", c.Type.String())
+ }
+
+ candidates := c.Impl.spawnTiles(s, p)
+ if candidates == nil {
+ // Default spawn tiles
+ if c.hasPlacementConstrain("swimming") {
+ candidates = s.WaterSpawnTiles(p)
+ } else {
+ candidates = s.SpawnTiles(p)
+ }
+ }
+
+ tiles := []*Tile{}
+
+ for _, candidate := range candidates {
+ if candidate.IsAvailableForCard(c) {
+ tiles = append(tiles, candidate)
+ }
+ }
+
+ return tiles
+}
diff --git a/go/game/store.go b/go/game/store.go
new file mode 100644
index 00000000..46c136e0
--- /dev/null
+++ b/go/game/store.go
@@ -0,0 +1,10 @@
+package game
+
+type Store struct {
+ PileOfCardsBase
+}
+
+func NewStore() *Store {
+ s := &Store{PileOfCardsBase{[]*Card{}}}
+ return s
+}
diff --git a/go/game/targets.go b/go/game/targets.go
new file mode 100644
index 00000000..1a8c7ab3
--- /dev/null
+++ b/go/game/targets.go
@@ -0,0 +1,307 @@
+package game
+
+import (
+ "fmt"
+ "log"
+ "strconv"
+ "strings"
+
+ "golang.org/x/exp/slices"
+ "muhq.space/muhqs-game/go/utils"
+)
+
+type (
+ TargetConstrainFunc func(interface{}) error
+ TargetConstrain struct {
+ desc string
+ constrain TargetConstrainFunc
+ }
+)
+
+type Targets struct {
+ s *State
+ action Action
+ constrains []*TargetConstrain
+ sel []interface{}
+}
+
+func fmtSel(sel interface{}) string {
+ switch t := sel.(type) {
+ case *Tile:
+ return t.Position.String()
+ default:
+ return fmt.Sprintf("%d", t)
+ }
+}
+
+func (t *Targets) String() string {
+ if len(t.sel) == 0 {
+ return "undecided targets"
+ }
+
+ if len(t.sel) == 1 {
+ return fmt.Sprintf("%s", fmtSel(t.sel[0]))
+ }
+
+ s := "["
+ for _, sel := range t.sel {
+ s += fmt.Sprintf("%s ", fmtSel(sel))
+ }
+ return s[:len(s)-1] + "]"
+}
+
+func newTargetsWithSel(s *State, targets []string, action Action, sel []interface{}) *Targets {
+ t := &Targets{
+ s: s,
+ sel: sel,
+ }
+
+ t.buildConstrains(targets, action)
+ return t
+}
+
+func newTargets(s *State, targets []string, action Action) *Targets {
+ return newTargetsWithSel(s, targets, action, make([]interface{}, 0, len(targets)))
+}
+
+func (t *Targets) buildConstrains(targets []string, action Action) {
+ for _, desc := range targets {
+ c := &TargetConstrain{desc, targetConstrain(desc, t.s, action)}
+ t.constrains = append(t.constrains, c)
+ }
+}
+
+func (t *Targets) RequireTargets() bool {
+ return len(t.constrains) != len(t.sel)
+}
+
+func (t *Targets) Options() [][]interface{} {
+ options := make([][]interface{}, 0, len(t.constrains))
+ for _, c := range t.constrains {
+ options = append(options, c.options(t.s))
+ }
+ return options
+}
+
+func (t *Targets) NextOptions() []interface{} {
+ next := len(t.sel)
+ return t.constrains[next].options(t.s)
+}
+
+func (t *Targets) AddTarget(target interface{}) {
+ t.sel = append(t.sel, target)
+}
+
+func (t *Targets) CheckTargets(s *State) error {
+ if t.RequireTargets() {
+ return fmt.Errorf("Number of selected targets and required ones does not match")
+ }
+
+ for i, c := range t.constrains {
+ sel := t.sel[i]
+ if err := c.constrain(sel); err != nil {
+ return fmt.Errorf("selected target %s does not fit its desciption %s: %s",
+ sel, c.desc, err)
+ }
+ }
+
+ return nil
+}
+
+func targetConstrain(desc string, s *State, action Action) TargetConstrainFunc {
+ constrains := []TargetConstrainFunc{}
+ if strings.Contains(desc, "unit") {
+ constrains = append(constrains, parseUnitTargetConstrain(desc, s, action)...)
+ } else if strings.Contains(desc, "artifact") {
+ constrains = append(constrains, parseArtifactTargetConstrain(desc, s, action)...)
+ } else if strings.Contains(desc, "tile") {
+ constrains = append(constrains, parseTileTargetConstrain(desc, s, action)...)
+ // } else if strings.Contains("action") {
+ // contrains = append(contrains, parseActionTargetConstrain(desc, s, action)...)
+ // } else if strings.Contains("spell") {
+ // contrains = append(contrains, parseSpellTargetConstrain(desc, s, action)...)
+ }
+
+ return func(t interface{}) error {
+ for _, constrain := range constrains {
+ if err := constrain(t); err != nil {
+ return err
+ }
+ }
+
+ return nil
+ }
+}
+
+func enemyPermanentTargetConstrain(action Action) TargetConstrainFunc {
+ var sourceController *Player
+ switch source := action.Source().(type) {
+ case *Player:
+ sourceController = source
+ case Permanent:
+ sourceController = source.Controller()
+ default:
+ log.Fatalf("Unhandled source type %T in enemyPermanentTargetConstrain", source)
+ }
+
+ return func(t interface{}) (err error) {
+ p, _ := t.(Permanent)
+ if !p.Controller().IsEnemy(sourceController) {
+ err = fmt.Errorf("Controller %s of target %t is not an enemy of %s",
+ p.Controller(), t, sourceController)
+ }
+ return
+ }
+}
+
+func rangeTargetConstrain(source interface{}, r int) TargetConstrainFunc {
+ var sourcePos Position
+ switch source := source.(type) {
+ case *Tile:
+ sourcePos = source.Position
+ case Permanent:
+ sourcePos = source.Tile().Position
+ default:
+ log.Fatalf("Unhandled source type %T in rangeTargetConstrain", source)
+ }
+
+ return func(t interface{}) (err error) {
+ p, _ := t.(Permanent)
+ if !IsPositionInRange(sourcePos, p.Tile().Position, r) {
+ err = fmt.Errorf("Position %s of target %s not in range %d of source's position %s",
+ p.Tile().Position, t, r, sourcePos)
+ }
+ return
+ }
+}
+
+var unitTargetConstrain TargetConstrainFunc = func(t interface{}) (err error) {
+ _, ok := t.(*Unit)
+ if !ok {
+ err = fmt.Errorf("target is %T not *Unit", t)
+ }
+ return
+}
+
+func parseUnitTargetConstrain(desc string, s *State, action Action) []TargetConstrainFunc {
+ constrains := []TargetConstrainFunc{unitTargetConstrain}
+
+ if strings.Contains(desc, "enemy unit") {
+ constrains = append(constrains, enemyPermanentTargetConstrain(action))
+ }
+
+ if strings.Contains(desc, " in range ") {
+ tokens := strings.SplitN(desc, " in range ", 2)
+ r, err := strconv.Atoi(tokens[1])
+ if err != nil {
+ log.Fatalf("Invalid range %s in target constrain %s", tokens[1], desc)
+ }
+ constrains = append(constrains, rangeTargetConstrain(action, r))
+ }
+
+ return constrains
+}
+
+var artifactTargetConstrain TargetConstrainFunc = func(t interface{}) (err error) {
+ _, ok := t.(*Artifact)
+ if !ok {
+ err = fmt.Errorf("target is %T not *Artifact", t)
+ }
+ return
+}
+
+func parseArtifactTargetConstrain(constrain string, s *State, action Action) []TargetConstrainFunc {
+ constrains := []TargetConstrainFunc{artifactTargetConstrain}
+ return constrains
+}
+
+var tileTargetConstrain TargetConstrainFunc = func(t interface{}) (err error) {
+ _, ok := t.(*Tile)
+ if !ok {
+ err = fmt.Errorf("target is %T not *Tile", t)
+ }
+ return
+}
+
+func parseTileTargetConstrain(desc string, s *State, action Action) []TargetConstrainFunc {
+ log.Println("parsing", desc)
+ constrains := []TargetConstrainFunc{tileTargetConstrain}
+
+ if moveAction, ok := action.(*MoveAction); ok {
+ u := moveAction.Source().(*Unit)
+ constrains = append(constrains, func(t interface{}) (err error) {
+ tile := t.(*Tile)
+ if slices.Contains(u.MoveRangeTiles(s.Map), tile) {
+ return nil
+ }
+ return fmt.Errorf("tile %s is not in %d movement range", tile)
+ })
+ }
+
+ var player *Player
+ switch a := action.(type) {
+ case *PlayAction:
+ player = a.Source().(*Player)
+ }
+
+ var card *Card
+ switch a := action.(type) {
+ case *PlayAction:
+ card = a.Card
+ case *MoveAction:
+ card = a.Card
+ }
+
+ if strings.Contains(desc, "spawn") {
+ if player == nil {
+ log.Fatalf("Not implemented spawn tile constrain for %T", action)
+ }
+
+ constrains = append(constrains, func(t interface{}) (err error) {
+ tile := t.(*Tile)
+ if slices.Contains(s.AvailableSpawnTiles(player, card), tile) {
+ return nil
+ }
+ return fmt.Errorf("tile %s is no spawn tile", tile)
+ })
+ }
+
+ if strings.Contains(desc, "available") {
+ constrains = append(constrains, func(t interface{}) (err error) {
+ tile := t.(*Tile)
+
+ if card == nil {
+ log.Fatalf("Not implemented availability tile constrain for %T", action)
+ }
+
+ if tile.IsAvailableForCard(card) {
+ return nil
+ }
+ return fmt.Errorf("tile %s is not available for %s", tile, card.Name)
+ })
+ }
+
+ return constrains
+}
+
+func (c *TargetConstrain) options(s *State) (options []interface{}) {
+ candidates := targetCandidates(c.desc, s)
+
+ for _, candidate := range candidates {
+ if err := c.constrain(candidate); err == nil {
+ options = append(options, candidate)
+ }
+ }
+ return
+}
+
+func targetCandidates(desc string, s *State) []interface{} {
+ if strings.Contains(desc, "unit") || strings.Contains(desc, "artifact") {
+ return utils.TypedSliceToInterfaceSlice(s.Permanents)
+ } else if strings.Contains(desc, "tile") {
+ return utils.TypedSliceToInterfaceSlice(s.Map.AllTiles())
+ }
+
+ log.Fatalf("Unimplemented target options for %s", desc)
+ return nil
+}
diff --git a/go/game/tile.go b/go/game/tile.go
new file mode 100644
index 00000000..260747d1
--- /dev/null
+++ b/go/game/tile.go
@@ -0,0 +1,123 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+)
+
+type TileType int
+
+const (
+ neutral TileType = iota + 1
+ tower
+ house
+ farm
+ street
+ spawn
+ docks
+ store
+)
+
+func (t TileType) String() string {
+ switch t {
+ case neutral:
+ return "neutral"
+ case tower:
+ return "tower"
+ case house:
+ return "house"
+ case farm:
+ return "farm"
+ case street:
+ return "street"
+ case spawn:
+ return "spawn"
+ case docks:
+ return "docks"
+ case store:
+ return "store"
+ }
+ return ""
+}
+
+var TileTypes = struct {
+ Neutral TileType
+ Tower TileType
+ House TileType
+ Farm TileType
+ Street TileType
+ Spawn TileType
+ Docks TileType
+ Store TileType
+}{
+ Neutral: neutral,
+ Tower: tower,
+ House: house,
+ Farm: farm,
+ Street: street,
+ Spawn: spawn,
+ Docks: docks,
+ Store: store,
+}
+
+var TileNames = map[string]TileType{
+ "neutral": neutral,
+ "tower": tower,
+ "house": house,
+ "farm": farm,
+ "street": street,
+ "spawn": spawn,
+ "docks": docks,
+ "store": store,
+}
+
+type Tile struct {
+ Position Position
+ Permanent Permanent
+ Type TileType
+ Water bool
+ Raw string
+}
+
+func (t *Tile) String() string {
+ return fmt.Sprintf("%d@%d", t.Type, t.Position.String())
+}
+
+func INVALID_TILE() Tile {
+ return Tile{INVALID_POSITION(), nil, 0, false, ""}
+}
+
+func NewTileFromString(t string, pos Position) (Tile, error) {
+ tile := strings.ToLower(t)
+ tokens := strings.Split(tile, " ")
+ tileType, found := TileNames[tokens[0]]
+ if !found {
+ if strings.Contains(tile, "spawn") {
+ tileType = spawn
+ }
+ }
+ water := strings.Contains(tile, "water")
+ return Tile{pos, nil, tileType, water, t}, nil
+}
+
+func (t *Tile) IsFree() bool {
+ return t.Permanent == nil
+}
+
+func (t *Tile) IsAvailableForCard(card *Card) bool {
+ if t.Water && !card.hasPlacementConstrain("swimming") {
+ return false
+ }
+ if !t.Water && card.hasPlacementConstrain("swimming") {
+ return false
+ }
+ return t.IsFree()
+}
+
+func (t *Tile) OnDiagonal(other *Tile) bool {
+ p1, p2 := t.Position, other.Position
+ return p1.X == p2.X-1 && p1.Y == p2.Y-1 ||
+ p1.X == p2.X+1 && p1.Y == p2.Y-1 ||
+ p1.X == p2.X+1 && p1.Y == p2.Y+1 ||
+ p1.X == p2.X-1 && p1.Y == p2.Y+1
+}
diff --git a/go/game/unit.go b/go/game/unit.go
new file mode 100644
index 00000000..2bc90992
--- /dev/null
+++ b/go/game/unit.go
@@ -0,0 +1,211 @@
+package game
+
+import (
+ "log"
+)
+
+type UnitMark int
+
+const (
+ paralysis UnitMark = iota + 1
+ poison
+ panic_
+ rage
+ ward
+ faith
+)
+
+func (mark UnitMark) String() string {
+ switch mark {
+ case paralysis:
+ return "paralysis"
+ case poison:
+ return "poison"
+ case panic_:
+ return "panic"
+ case rage:
+ return "rage"
+ case ward:
+ return "ward"
+ case faith:
+ return "faith"
+ default:
+ log.Fatalf("Unhandled unit mark %d", mark)
+ return ""
+ }
+}
+
+var UnitMarks = struct {
+ Paralysis UnitMark
+ Poison UnitMark
+ Panic UnitMark
+ Rage UnitMark
+ Ward UnitMark
+ Faith UnitMark
+}{
+ Paralysis: paralysis,
+ Poison: poison,
+ Panic: panic_,
+ Rage: rage,
+ Ward: ward,
+ Faith: faith,
+}
+
+type Unit struct {
+ PermanentBase
+ Health int
+ Movement Movement
+ Attack Attack
+ Upkeep int
+ FullActions []*FullAction
+ FreeActions []*FreeAction
+ States map[UnitMark]int
+ // Effects []Effects
+ AvailMoveActions int
+ AvailAttackActions int
+}
+
+func NewUnit(card *Card, tile *Tile, owner *Player) *Unit {
+ var movement Movement
+ movementDesc, found := card.Values["movement"]
+ if !found {
+ movement = INVALID_MOVEMENT()
+ } else {
+ movement = parseMovement(movementDesc)
+ }
+
+ upkeep, ok := card.Values["upkeep"].(int)
+ if !ok {
+ upkeep = 0
+ }
+
+ var attack Attack
+ attackDesc, found := card.Values["attack"]
+ if !found {
+ attack = INVALID_ATTACK()
+ } else {
+ attack = parseAttack(attackDesc)
+ }
+
+ u := &Unit{
+ PermanentBase: NewPermanentBase(card, tile, owner),
+ Health: card.Values["health"].(int),
+ Movement: movement,
+ Attack: attack,
+ Upkeep: upkeep,
+ States: make(map[UnitMark]int),
+ AvailMoveActions: 1,
+ AvailAttackActions: 1,
+ }
+
+ u.FullActions = card.Impl.fullActions(u)
+ u.FreeActions = card.Impl.freeActions(u)
+
+ tile.Permanent = u
+ return u
+}
+
+func NewUnitFromPath(cardPath string, tile *Tile, owner *Player) *Unit {
+ return NewUnit(NewCard(cardPath), tile, owner)
+}
+
+func (u *Unit) move(s *State, tile *Tile) {
+ u.tile.Permanent = nil
+ tile.Permanent = u
+ u.tile = tile
+}
+
+func (u *Unit) MoveRangeTiles(m *Map) []*Tile {
+ tiles := []*Tile{}
+ // TODO: build graph only containing possible tile in movement range
+ graph := m.generateMapGraphFor(u)
+ for _, pos := range PositionsInRange(u.tile.Position, u.Movement.Range, false) {
+ tile := m.TileAt(pos)
+ if tile == nil || !u.IsAvailableTile(tile) {
+ continue
+ }
+
+ path, err := findPathTo(graph, u, pos)
+ if err != nil {
+ continue
+ }
+
+ if path.Distance/2 > int64(u.Movement.Range) {
+ continue
+ }
+
+ tiles = append(tiles, tile)
+ }
+ return tiles
+}
+
+func (u *Unit) AttackableTiles(m *Map) []*Tile {
+ return TilesInRange(m, u, u.Attack.Range)
+}
+
+func (u *Unit) AttackablePermanents(m *Map) []Permanent {
+ permanents := []Permanent{}
+ for _, t := range u.AttackableTiles(m) {
+ if t.Permanent != nil && t.Permanent.Attackable() {
+ permanents = append(permanents, t.Permanent)
+ }
+ }
+ return permanents
+}
+
+func (u *Unit) AttackableEnemyPermanents(m *Map) []Permanent {
+ controller := u.Controller()
+ permanents := []Permanent{}
+ for _, t := range u.AttackableTiles(m) {
+ if t.Permanent != nil && t.Permanent.Attackable() &&
+ t.Permanent.Controller() != controller {
+
+ permanents = append(permanents, t.Permanent)
+ }
+ }
+ return permanents
+}
+
+func (u *Unit) Fight(p Permanent) {
+ DealDamage(u, p, u.Attack.Damage)
+}
+
+func (u *Unit) IsDestroyed() bool {
+ return u.damage >= u.Health
+}
+
+func (u *Unit) Attackable() bool {
+ return true
+}
+
+func (u *Unit) HasFullAction() bool {
+ return len(u.FullActions) > 0
+}
+
+func (u *Unit) FullAction() {
+}
+
+func (u *Unit) AvailActions() (actions []Action) {
+ if u.AvailMoveActions > 0 && u.Movement != INVALID_MOVEMENT() {
+ actions = append(actions, NewMoveAction(u))
+ }
+
+ m := u.Controller().gameState.Map
+ if u.AvailAttackActions > 0 && u.Attack != INVALID_ATTACK() &&
+ len(u.AttackableEnemyPermanents(m)) > 0 {
+
+ actions = append(actions, NewAttackAction(u))
+ }
+
+ if u.AvailAttackActions > 0 && u.AvailMoveActions > 0 {
+ for _, p := range u.FullActions {
+ actions = append(actions, p)
+ }
+ }
+
+ for _, p := range u.FreeActions {
+ actions = append(actions, p)
+ }
+
+ return actions
+}
diff --git a/go/game/winCondition.go b/go/game/winCondition.go
new file mode 100644
index 00000000..4db36e1d
--- /dev/null
+++ b/go/game/winCondition.go
@@ -0,0 +1,36 @@
+package game
+
+import (
+ "golang.org/x/exp/slices"
+)
+
+func KingGame(s *State) []*Player {
+ foundKings := map[*Player]struct{}{}
+ for _, u := range s.Units {
+ if u.card.Name != "King" {
+ continue
+ }
+
+ foundKings[u.owner] = struct{}{}
+ }
+
+ if len(foundKings) == len(s.Players) {
+ return []*Player{}
+ }
+
+ loosers := make([]*Player, 0, len(s.Players))
+ copy(loosers, s.Players)
+ for p := range foundKings {
+ i := slices.Index(loosers, p)
+ loosers[i] = loosers[len(loosers)-1]
+ loosers = loosers[:len(loosers)-1]
+ }
+
+ winners := []*Player{}
+ for _, p := range s.Players {
+ if !slices.Contains(loosers, p) {
+ winners = append(winners, p)
+ }
+ }
+ return winners
+}
diff --git a/go/go.mod b/go/go.mod
new file mode 100644
index 00000000..24b6cb52
--- /dev/null
+++ b/go/go.mod
@@ -0,0 +1,22 @@
+module muhq.space/muhqs-game/go
+
+go 1.19
+
+require (
+ github.com/RyanCarrier/dijkstra v1.1.0
+ github.com/hajimehoshi/ebiten/v2 v2.4.15
+ golang.org/x/exp v0.0.0-20230116083435-1de6713980de
+ golang.org/x/image v0.3.0
+ gopkg.in/yaml.v3 v3.0.1
+)
+
+require (
+ github.com/ebitengine/purego v0.1.1 // indirect
+ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20221017161538-93cebf72946b // indirect
+ github.com/hajimehoshi/file2byteslice v1.0.0 // indirect
+ github.com/jezek/xgb v1.1.0 // indirect
+ golang.org/x/exp/shiny v0.0.0-20230116083435-1de6713980de // indirect
+ golang.org/x/mobile v0.0.0-20221110043201-43a038452099 // indirect
+ golang.org/x/sys v0.4.0 // indirect
+ golang.org/x/text v0.6.0 // indirect
+)
diff --git a/go/go.sum b/go/go.sum
new file mode 100644
index 00000000..f505893f
--- /dev/null
+++ b/go/go.sum
@@ -0,0 +1,102 @@
+github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/RyanCarrier/dijkstra v1.1.0 h1:/NDihjfJA3CxFaZz8EdzTwdFKFZDvvB881OVLdakRcI=
+github.com/RyanCarrier/dijkstra v1.1.0/go.mod h1:5agGUBNEtUAGIANmbw09fuO3a2htPEkc1jNH01qxCWA=
+github.com/RyanCarrier/dijkstra-1 v0.0.0-20170512020943-0e5801a26345 h1:fgSpoKViTSqRb4hjDNj10ig5wUvO0CayCzFdLf6fuRM=
+github.com/RyanCarrier/dijkstra-1 v0.0.0-20170512020943-0e5801a26345/go.mod h1:OK4EvWJ441LQqGzed5NGB6vKBAE34n3z7iayPcEwr30=
+github.com/albertorestifo/dijkstra v0.0.0-20160910063646-aba76f725f72 h1:uGeGZl8PxSq8VZGG4QK5njJTFA4/G/x5CYORvQVXtAE=
+github.com/albertorestifo/dijkstra v0.0.0-20160910063646-aba76f725f72/go.mod h1:o+JdB7VetTHjLhU0N57x18B9voDBQe0paApdEAEoEfw=
+github.com/ebitengine/purego v0.0.0-20220905075623-aeed57cda744/go.mod h1:Eh8I3yvknDYZeCuXH9kRNaPuHEwvXDCk378o9xszmHg=
+github.com/ebitengine/purego v0.1.1 h1:HI8nW+LniW9Yb34k34jBs8nz+PNzsw68o7JF8jWFHHE=
+github.com/ebitengine/purego v0.1.1/go.mod h1:Eh8I3yvknDYZeCuXH9kRNaPuHEwvXDCk378o9xszmHg=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20220806181222-55e207c401ad/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20221017161538-93cebf72946b h1:GgabKamyOYguHqHjSkDACcgoPIz3w0Dis/zJ1wyHHHU=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20221017161538-93cebf72946b/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/hajimehoshi/bitmapfont/v2 v2.2.2 h1:4z08Fk1m3pjtlO7BdoP48u5bp/Y8xmKshf44aCXgYpE=
+github.com/hajimehoshi/bitmapfont/v2 v2.2.2/go.mod h1:Ua/x9Dkz7M9CU4zr1VHWOqGwjKdXbOTRsH7lWfb1Co0=
+github.com/hajimehoshi/ebiten/v2 v2.4.15 h1:yvhCrDv9y7TpdHtdux5ES/IwP6Pfplz5rJVxE0Z+ZPU=
+github.com/hajimehoshi/ebiten/v2 v2.4.15/go.mod h1:BZcqCU4XHmScUi+lsKexocWcf4offMFwfp8dVGIB/G4=
+github.com/hajimehoshi/file2byteslice v0.0.0-20210813153925-5340248a8f41/go.mod h1:CqqAHp7Dk/AqQiwuhV1yT2334qbA/tFWQW0MD2dGqUE=
+github.com/hajimehoshi/file2byteslice v1.0.0 h1:ljd5KTennqyJ4vG9i/5jS8MD1prof97vlH5JOdtw3WU=
+github.com/hajimehoshi/file2byteslice v1.0.0/go.mod h1:CqqAHp7Dk/AqQiwuhV1yT2334qbA/tFWQW0MD2dGqUE=
+github.com/hajimehoshi/go-mp3 v0.3.3/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM=
+github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI=
+github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
+github.com/jakecoffman/cp v1.2.1/go.mod h1:JjY/Fp6d8E1CHnu74gWNnU0+b9VzEdUVPoJxg2PsTQg=
+github.com/jezek/xgb v1.0.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
+github.com/jezek/xgb v1.1.0 h1:wnpxJzP1+rkbGclEkmwpVFQWpuE2PUGNUzP8SbfFobk=
+github.com/jezek/xgb v1.1.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
+github.com/jfreymuth/oggvorbis v1.0.4/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII=
+github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ=
+github.com/mattomatic/dijkstra v0.0.0-20130617153013-6f6d134eb237 h1:acuCHBjzG7MFTugvx3buC4m5rLDLaKC9J8C9jtlraRc=
+github.com/mattomatic/dijkstra v0.0.0-20130617153013-6f6d134eb237/go.mod h1:UOnLAUmVG5paym8pD3C4B9BQylUDC2vXFJJpT7JrlEA=
+github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
+github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
+golang.org/x/exp v0.0.0-20230116083435-1de6713980de h1:DBWn//IJw30uYCgERoxCg84hWtA97F4wMiKOIh00Uf0=
+golang.org/x/exp v0.0.0-20230116083435-1de6713980de/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
+golang.org/x/exp/shiny v0.0.0-20230116083435-1de6713980de h1:4wuvfXFMr6CwrpmIpAbi8bGbPqbymMDbHfb6z7oYGW8=
+golang.org/x/exp/shiny v0.0.0-20230116083435-1de6713980de/go.mod h1:UH99kUObWAZkDnWqppdQe5ZhPYESUw8I0zVV1uWBR+0=
+golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
+golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.1.0/go.mod h1:iyPr49SD/G/TBxYVB/9RRtGUT5eNbo2u4NamWeQcD5c=
+golang.org/x/image v0.3.0 h1:HTDXbdK9bjfSWkPzDJIw89W8CAtfFGduujWs33NLLsg=
+golang.org/x/image v0.3.0/go.mod h1:fXd9211C/0VTlYuAcOhW8dY/RtEJqODXOWBDpmYBf+A=
+golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
+golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
+golang.org/x/mobile v0.0.0-20220722155234-aaac322e2105/go.mod h1:pe2sM7Uk+2Su1y7u/6Z8KJ24D7lepUjFZbhFOrmDfuQ=
+golang.org/x/mobile v0.0.0-20221110043201-43a038452099 h1:aIu0lKmfdgtn2uTj7JI2oN4TUrQvgB+wzTPO23bCKt8=
+golang.org/x/mobile v0.0.0-20221110043201-43a038452099/go.mod h1:aAjjkJNdrh3PMckS4B10TGS2nag27cbKR1y2BpUxsiY=
+golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
+golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220818161305-2296e01440c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
+golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k=
+golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.8-0.20211022200916-316ba0b74098/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/go/server/main.go b/go/server/main.go
new file mode 100644
index 00000000..af051b53
--- /dev/null
+++ b/go/server/main.go
@@ -0,0 +1,13 @@
+package main
+
+import (
+ "fmt"
+
+ "muhq.space/muhqs-game/go/game"
+)
+
+var games = []game.State{}
+
+func main() {
+ fmt.Println("Started muhq's game server", games)
+}
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
+}
diff --git a/go/utils/slices.go b/go/utils/slices.go
new file mode 100644
index 00000000..d2bc04c1
--- /dev/null
+++ b/go/utils/slices.go
@@ -0,0 +1,9 @@
+package utils
+
+func TypedSliceToInterfaceSlice[T any](s []T) []interface{} {
+ is := make([]interface{}, 0, len(s))
+ for _, t := range s {
+ is = append(is, t)
+ }
+ return is
+}
diff --git a/go/webtools/main.go b/go/webtools/main.go
new file mode 100644
index 00000000..c468f4dd
--- /dev/null
+++ b/go/webtools/main.go
@@ -0,0 +1,48 @@
+package main
+
+import (
+ "math/rand"
+ "syscall/js"
+ "time"
+
+ "muhq.space/muhqs-game/go/game"
+)
+
+var r *rand.Rand
+
+func jSRandomTileFromMap() js.Func {
+ jsonFunc := js.FuncOf(func(this js.Value, args []js.Value) any {
+ if len(args) != 2 {
+ return "Invalid number of arguments passed"
+ }
+
+ mapData := args[0].String()
+
+ symbols := make([]string, 0, args[1].Length())
+ for i := 0; i < args[1].Length(); i++ {
+ symbol := args[1].Index(i).String()
+ symbols = append(symbols, symbol)
+ }
+
+ position, err := randomTileFromMap(mapData, symbols)
+ if err != nil {
+ return err.Error()
+ }
+ return map[string]interface{}{"y": position.Y + 1, "x": position.X + 1}
+ })
+ return jsonFunc
+}
+
+func randomTileFromMap(mapData string, symbols []string) (game.Position, error) {
+ m, err := game.ParseMap([]byte(mapData))
+ if err != nil {
+ return game.INVALID_POSITION(), err
+ }
+ return m.RandomTileFromSymbols(r, symbols)
+}
+
+func main() {
+ r = rand.New(rand.NewSource(time.Now().UnixNano()))
+ js.Global().Set("randomTileFromSymbol", jSRandomTileFromMap())
+ <-make(chan bool)
+}