diff options
61 files changed, 6701 insertions, 28 deletions
diff --git a/assets/symbols/archer.png b/assets/symbols/archer.png Binary files differindex e83f2d17..f641f0a6 100644 --- a/assets/symbols/archer.png +++ b/assets/symbols/archer.png diff --git a/assets/symbols/gear.png b/assets/symbols/gear.png Binary files differindex 821d7e84..d675856e 100644 --- a/assets/symbols/gear.png +++ b/assets/symbols/gear.png 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) +} diff --git a/html/Makefile b/html/Makefile index 3c969595..9188c4a5 100644 --- a/html/Makefile +++ b/html/Makefile @@ -10,6 +10,7 @@ MAKEFILE_DIR := $(dir $(MAKEFILE_PATH)) HTML_ROOT := $(MAKEFILE_DIR) GAME_ROOT := $(MAKEFILE_DIR).. +GO_ROOT := $(GAME_ROOT)/go RULES_ROOT := $(GAME_ROOT)/rules MAPS_ROOT := $(GAME_ROOT)/maps DATA_ROOT := $(GAME_ROOT)/data @@ -55,12 +56,21 @@ CARDS_YAML := $(foreach dir,$(SETS_SOURCEDIRS),$(wildcard $(dir)/*.yml)) MAKEFILE_LIST := Makefile +FILES_TO_COPY := wasm_exec.js hover-placeholder.png tools.html +FILES_TO_COPY_TARGETS := $(addprefix $(BUILDDIR)/,$(FILES_TO_COPY)) + .PHONY: all clean maps misc all: $(HTML) $(RULES_HTML) maps misc $(BLOG_POSTS_HTML) -misc: $(BUILDDIR)/latex-build $(BUILDDIR)/cards-data $(BUILDDIR)/hover-placeholder.png +misc: $(BUILDDIR)/latex-build $(BUILDDIR)/cards-data $(FILES_TO_COPY_TARGETS) \ + $(BUILDDIR)/webtools.wasm + +$(BUILDDIR)/webtools.wasm: + @echo "creating symlink $@" + @if test \( ! \( -d $(@D) \) \) ;then mkdir -p $(@D);fi + ln -s $(GO_ROOT)/webtools/webtools.wasm $@ -$(BUILDDIR)/hover-placeholder.png: hover-placeholder.png +$(FILES_TO_COPY_TARGETS): $(BUILDDIR)/%: % @echo "copying $@" @if test \( ! \( -d $(@D) \) \) ;then mkdir -p $(@D);fi $(VERBOSE) cp $< $@ @@ -73,11 +83,6 @@ maps: $(VERBOSE) ln -sfT $(MAPS_ROOT) $(BUILDDIR)/maps $(VERBOSE) $(MAKE) -C $(MAPS_ROOT) -$(BUILDDIR)/tools.html: tools.html - @echo "copying $@" - @if test \( ! \( -d $(@D) \) \) ;then mkdir -p $(@D);fi - $(VERBOSE) cp $< $@ - $(BUILDDIR)/latex-build: @echo "render cards" $(VERBOSE) $(MAKE) -C $(LATEX_ROOT) diff --git a/html/tools.html b/html/tools.html index 5d638ef5..caca608e 100644 --- a/html/tools.html +++ b/html/tools.html @@ -68,6 +68,15 @@ body { } </style> + <script src="wasm_exec.js"></script> + + <script> + const go = new Go(); + WebAssembly.instantiateStreaming(fetch("webtools.wasm"), go.importObject).then((result) => { + go.run(result.instance); + }); + </script> + <script language="javascript" type="text/javascript"> /* @licstart The following is the entire license notice for the @@ -93,19 +102,6 @@ body { for the JavaScript code in this page. */ - // List of houses on the tyrant map - let tyrantHouses = Array( - {x:1, y:1}, {x:4, y:1}, {x:5, y:1}, {x:8, y:1}, {x:15, y:1}, - {x:3, y:2}, {x:6, y:2}, {x:12, y:2}, {x:13, y:2}, {x:14, y:2}, - {x:2, y:3}, {x:14, y:3}, - {x:2, y:4}, {x:5, y:4}, {x:10, y:4}, - {x:3, y:5}, {x:6, y:4}, {x:9, y:4}, - {x:7, y:6}, {x:10, y:6}, - {x:13, y:10}, - {x:4, y:11}, - {x:3, y:15}, {x:9, y:15}, {x:14, y:15}, - ); - // return a random number between 1 and max function randomInt(max) { return Math.floor(Math.random() * Number(max)) + 1; @@ -200,11 +196,26 @@ body { return false; } - document.getElementById("random_house_tyrant_form").onsubmit = function() { - let idx = Math.floor(Math.random() * tyrantHouses.length); - let house = tyrantHouses[idx]; - result.innerHTML = "Random house: " + (idx + 1) + " at (x:" + house.x + ", y:" - + house.y + ")"; + document.getElementById("random_map_tile_form").onsubmit = function() { + const mapName = document.getElementById("random_map_tile_map_select").value; + const mapUrl = "maps/" + mapName + ".yml" + const symbols = document.getElementById("random_map_tile_symbols").value.split(" "); + const request = new XMLHttpRequest(); + request.onreadystatechange = function() { + if (request.readyState == 4 && request.status == 200) { + const pos = randomTileFromSymbol(request.responseText, symbols); + if (!pos) { + result.innerHTML = "The Go websassembly returned no value"; + } else if (typeof pos === "string" || pos instanceof String) { + result.innerHTML = "An error occured: " + pos; + } else { + result.innerHTML = "Random tile: (x:" + pos.x + ", y:" + pos.y + ")"; + } + } + } + request.open("GET", mapUrl, true); + request.send(null); + return false; } }); @@ -250,9 +261,18 @@ Random tile within Range <h2>The tyrant tools</h2> <div> -<form id="random_house_tyrant_form"> -Random house tile - <input id="random_house_tyrant_button" value="generate" type="submit"> +<form id="random_map_tile_form"> +Random tile from + <select name="map" id="random_map_tile_map_select"> + <option value="the-tyrant">the-tyrant</option> + <option value="2P-ring-street">2P-ring-street</option> + <option value="2P-river-king">2P-river-king</option> + <option value="3P-ring-street">3P-ring-street</option> + <option value="4P-islands">4P-islands</option> + <option value="the-kraken">the-kraken</option> + </select> + <input id="random_map_tile_symbols" value="symbols"> + <input id="random_map_tile_button" value="generate" type="submit"> </form> </div> diff --git a/html/wasm_exec.js b/html/wasm_exec.js new file mode 100644 index 00000000..9ce6a20c --- /dev/null +++ b/html/wasm_exec.js @@ -0,0 +1,554 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +"use strict"; + +(() => { + const enosys = () => { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + return err; + }; + + if (!globalThis.fs) { + let outputBuf = ""; + globalThis.fs = { + constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused + writeSync(fd, buf) { + outputBuf += decoder.decode(buf); + const nl = outputBuf.lastIndexOf("\n"); + if (nl != -1) { + console.log(outputBuf.substr(0, nl)); + outputBuf = outputBuf.substr(nl + 1); + } + return buf.length; + }, + write(fd, buf, offset, length, position, callback) { + if (offset !== 0 || length !== buf.length || position !== null) { + callback(enosys()); + return; + } + const n = this.writeSync(fd, buf); + callback(null, n); + }, + chmod(path, mode, callback) { callback(enosys()); }, + chown(path, uid, gid, callback) { callback(enosys()); }, + close(fd, callback) { callback(enosys()); }, + fchmod(fd, mode, callback) { callback(enosys()); }, + fchown(fd, uid, gid, callback) { callback(enosys()); }, + fstat(fd, callback) { callback(enosys()); }, + fsync(fd, callback) { callback(null); }, + ftruncate(fd, length, callback) { callback(enosys()); }, + lchown(path, uid, gid, callback) { callback(enosys()); }, + link(path, link, callback) { callback(enosys()); }, + lstat(path, callback) { callback(enosys()); }, + mkdir(path, perm, callback) { callback(enosys()); }, + open(path, flags, mode, callback) { callback(enosys()); }, + read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, + readdir(path, callback) { callback(enosys()); }, + readlink(path, callback) { callback(enosys()); }, + rename(from, to, callback) { callback(enosys()); }, + rmdir(path, callback) { callback(enosys()); }, + stat(path, callback) { callback(enosys()); }, + symlink(path, link, callback) { callback(enosys()); }, + truncate(path, length, callback) { callback(enosys()); }, + unlink(path, callback) { callback(enosys()); }, + utimes(path, atime, mtime, callback) { callback(enosys()); }, + }; + } + + if (!globalThis.process) { + globalThis.process = { + getuid() { return -1; }, + getgid() { return -1; }, + geteuid() { return -1; }, + getegid() { return -1; }, + getgroups() { throw enosys(); }, + pid: -1, + ppid: -1, + umask() { throw enosys(); }, + cwd() { throw enosys(); }, + chdir() { throw enosys(); }, + } + } + + if (!globalThis.crypto) { + throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)"); + } + + if (!globalThis.performance) { + throw new Error("globalThis.performance is not available, polyfill required (performance.now only)"); + } + + if (!globalThis.TextEncoder) { + throw new Error("globalThis.TextEncoder is not available, polyfill required"); + } + + if (!globalThis.TextDecoder) { + throw new Error("globalThis.TextDecoder is not available, polyfill required"); + } + + const encoder = new TextEncoder("utf-8"); + const decoder = new TextDecoder("utf-8"); + + globalThis.Go = class { + constructor() { + this.argv = ["js"]; + this.env = {}; + this.exit = (code) => { + if (code !== 0) { + console.warn("exit code:", code); + } + }; + this._exitPromise = new Promise((resolve) => { + this._resolveExitPromise = resolve; + }); + this._pendingEvent = null; + this._scheduledTimeouts = new Map(); + this._nextCallbackTimeoutID = 1; + + const setInt64 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); + } + + const getInt64 = (addr) => { + const low = this.mem.getUint32(addr + 0, true); + const high = this.mem.getInt32(addr + 4, true); + return low + high * 4294967296; + } + + const loadValue = (addr) => { + const f = this.mem.getFloat64(addr, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = this.mem.getUint32(addr, true); + return this._values[id]; + } + + const storeValue = (addr, v) => { + const nanHead = 0x7FF80000; + + if (typeof v === "number" && v !== 0) { + if (isNaN(v)) { + this.mem.setUint32(addr + 4, nanHead, true); + this.mem.setUint32(addr, 0, true); + return; + } + this.mem.setFloat64(addr, v, true); + return; + } + + if (v === undefined) { + this.mem.setFloat64(addr, 0, true); + return; + } + + let id = this._ids.get(v); + if (id === undefined) { + id = this._idPool.pop(); + if (id === undefined) { + id = this._values.length; + } + this._values[id] = v; + this._goRefCounts[id] = 0; + this._ids.set(v, id); + } + this._goRefCounts[id]++; + let typeFlag = 0; + switch (typeof v) { + case "object": + if (v !== null) { + typeFlag = 1; + } + break; + case "string": + typeFlag = 2; + break; + case "symbol": + typeFlag = 3; + break; + case "function": + typeFlag = 4; + break; + } + this.mem.setUint32(addr + 4, nanHead | typeFlag, true); + this.mem.setUint32(addr, id, true); + } + + const loadSlice = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + return new Uint8Array(this._inst.exports.mem.buffer, array, len); + } + + const loadSliceOfValues = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + const a = new Array(len); + for (let i = 0; i < len; i++) { + a[i] = loadValue(array + i * 8); + } + return a; + } + + const loadString = (addr) => { + const saddr = getInt64(addr + 0); + const len = getInt64(addr + 8); + return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); + } + + const timeOrigin = Date.now() - performance.now(); + this.importObject = { + go: { + // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) + // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported + // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). + // This changes the SP, thus we have to update the SP used by the imported function. + + // func wasmExit(code int32) + "runtime.wasmExit": (sp) => { + sp >>>= 0; + const code = this.mem.getInt32(sp + 8, true); + this.exited = true; + delete this._inst; + delete this._values; + delete this._goRefCounts; + delete this._ids; + delete this._idPool; + this.exit(code); + }, + + // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) + "runtime.wasmWrite": (sp) => { + sp >>>= 0; + const fd = getInt64(sp + 8); + const p = getInt64(sp + 16); + const n = this.mem.getInt32(sp + 24, true); + fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); + }, + + // func resetMemoryDataView() + "runtime.resetMemoryDataView": (sp) => { + sp >>>= 0; + this.mem = new DataView(this._inst.exports.mem.buffer); + }, + + // func nanotime1() int64 + "runtime.nanotime1": (sp) => { + sp >>>= 0; + setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); + }, + + // func walltime() (sec int64, nsec int32) + "runtime.walltime": (sp) => { + sp >>>= 0; + const msec = (new Date).getTime(); + setInt64(sp + 8, msec / 1000); + this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); + }, + + // func scheduleTimeoutEvent(delay int64) int32 + "runtime.scheduleTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this._nextCallbackTimeoutID; + this._nextCallbackTimeoutID++; + this._scheduledTimeouts.set(id, setTimeout( + () => { + this._resume(); + while (this._scheduledTimeouts.has(id)) { + // for some reason Go failed to register the timeout event, log and try again + // (temporary workaround for https://github.com/golang/go/issues/28975) + console.warn("scheduleTimeoutEvent: missed timeout event"); + this._resume(); + } + }, + getInt64(sp + 8) + 1, // setTimeout has been seen to fire up to 1 millisecond early + )); + this.mem.setInt32(sp + 16, id, true); + }, + + // func clearTimeoutEvent(id int32) + "runtime.clearTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this.mem.getInt32(sp + 8, true); + clearTimeout(this._scheduledTimeouts.get(id)); + this._scheduledTimeouts.delete(id); + }, + + // func getRandomData(r []byte) + "runtime.getRandomData": (sp) => { + sp >>>= 0; + crypto.getRandomValues(loadSlice(sp + 8)); + }, + + // func finalizeRef(v ref) + "syscall/js.finalizeRef": (sp) => { + sp >>>= 0; + const id = this.mem.getUint32(sp + 8, true); + this._goRefCounts[id]--; + if (this._goRefCounts[id] === 0) { + const v = this._values[id]; + this._values[id] = null; + this._ids.delete(v); + this._idPool.push(id); + } + }, + + // func stringVal(value string) ref + "syscall/js.stringVal": (sp) => { + sp >>>= 0; + storeValue(sp + 24, loadString(sp + 8)); + }, + + // func valueGet(v ref, p string) ref + "syscall/js.valueGet": (sp) => { + sp >>>= 0; + const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 32, result); + }, + + // func valueSet(v ref, p string, x ref) + "syscall/js.valueSet": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); + }, + + // func valueDelete(v ref, p string) + "syscall/js.valueDelete": (sp) => { + sp >>>= 0; + Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); + }, + + // func valueIndex(v ref, i int) ref + "syscall/js.valueIndex": (sp) => { + sp >>>= 0; + storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); + }, + + // valueSetIndex(v ref, i int, x ref) + "syscall/js.valueSetIndex": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); + }, + + // func valueCall(v ref, m string, args []ref) (ref, bool) + "syscall/js.valueCall": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const m = Reflect.get(v, loadString(sp + 16)); + const args = loadSliceOfValues(sp + 32); + const result = Reflect.apply(m, v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, result); + this.mem.setUint8(sp + 64, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, err); + this.mem.setUint8(sp + 64, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + "syscall/js.valueInvoke": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.apply(v, undefined, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + "syscall/js.valueNew": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.construct(v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueLength(v ref) int + "syscall/js.valueLength": (sp) => { + sp >>>= 0; + setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); + }, + + // valuePrepareString(v ref) (ref, int) + "syscall/js.valuePrepareString": (sp) => { + sp >>>= 0; + const str = encoder.encode(String(loadValue(sp + 8))); + storeValue(sp + 16, str); + setInt64(sp + 24, str.length); + }, + + // valueLoadString(v ref, b []byte) + "syscall/js.valueLoadString": (sp) => { + sp >>>= 0; + const str = loadValue(sp + 8); + loadSlice(sp + 16).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + "syscall/js.valueInstanceOf": (sp) => { + sp >>>= 0; + this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0); + }, + + // func copyBytesToGo(dst []byte, src ref) (int, bool) + "syscall/js.copyBytesToGo": (sp) => { + sp >>>= 0; + const dst = loadSlice(sp + 8); + const src = loadValue(sp + 32); + if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + // func copyBytesToJS(dst ref, src []byte) (int, bool) + "syscall/js.copyBytesToJS": (sp) => { + sp >>>= 0; + const dst = loadValue(sp + 8); + const src = loadSlice(sp + 16); + if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + "debug": (value) => { + console.log(value); + }, + } + }; + } + + async run(instance) { + if (!(instance instanceof WebAssembly.Instance)) { + throw new Error("Go.run: WebAssembly.Instance expected"); + } + this._inst = instance; + this.mem = new DataView(this._inst.exports.mem.buffer); + this._values = [ // JS values that Go currently has references to, indexed by reference id + NaN, + 0, + null, + true, + false, + globalThis, + this, + ]; + this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id + this._ids = new Map([ // mapping from JS values to reference ids + [0, 1], + [null, 2], + [true, 3], + [false, 4], + [globalThis, 5], + [this, 6], + ]); + this._idPool = []; // unused ids that have been garbage collected + this.exited = false; // whether the Go program has exited + + // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. + let offset = 4096; + + const strPtr = (str) => { + const ptr = offset; + const bytes = encoder.encode(str + "\0"); + new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); + offset += bytes.length; + if (offset % 8 !== 0) { + offset += 8 - (offset % 8); + } + return ptr; + }; + + const argc = this.argv.length; + + const argvPtrs = []; + this.argv.forEach((arg) => { + argvPtrs.push(strPtr(arg)); + }); + argvPtrs.push(0); + + const keys = Object.keys(this.env).sort(); + keys.forEach((key) => { + argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); + }); + argvPtrs.push(0); + + const argv = offset; + argvPtrs.forEach((ptr) => { + this.mem.setUint32(offset, ptr, true); + this.mem.setUint32(offset + 4, 0, true); + offset += 8; + }); + + // The linker guarantees global data starts from at least wasmMinDataAddr. + // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr. + const wasmMinDataAddr = 4096 + 8192; + if (offset >= wasmMinDataAddr) { + throw new Error("total length of command line and environment variables exceeds limit"); + } + + this._inst.exports.run(argc, argv); + if (this.exited) { + this._resolveExitPromise(); + } + await this._exitPromise; + } + + _resume() { + if (this.exited) { + throw new Error("Go program has already exited"); + } + this._inst.exports.resume(); + if (this.exited) { + this._resolveExitPromise(); + } + } + + _makeFuncWrapper(id) { + const go = this; + return function () { + const event = { id: id, this: this, args: arguments }; + go._pendingEvent = event; + go._resume(); + return event.result; + }; + } + } +})(); |
