package main import ( "fmt" "image/color" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/ebitenutil" "muhq.space/muhqs-game/go/activities" "muhq.space/muhqs-game/go/assets" "muhq.space/muhqs-game/go/game" "muhq.space/muhqs-game/go/log" "muhq.space/muhqs-game/go/ui" "muhq.space/muhqs-game/go/utils" ) const ( DEFAULT_BUTTON_HEIGHT = 40 PASS_BUTTON_WIDTH = 100 RESOLVE_BUTTON_X = 500 STACK_BUFFER_WIDTH = 300 STACK_BUFFER_HEIGHT = 400 MESSAGE_BUFFER_HEIGHT = 200 MESSAGE_BUFFER_MARGIN = 10 HOVER_THRESHOLD = 60 HAND_VIEW_WIDTH = 500 STATE_BAR_WIDTH = 300 POC_BUTTON_WIDTH = 175 ) type Game struct { app *app settings *settings keyBindings keyBindings selectedObject any menuButton *ui.ImageButton handLayer *ui.HandView discardBtn *ui.ImageButton stateBar *ui.StateBar mapView *ui.MapView choice ui.Widget passButton *ui.SimpleButton resetButton *ui.SimpleButton discardPileView *ui.PocList discardPileButton *ui.SimpleButton cardsInDiscardPile int storesVisible bool storeViews []*ui.PocList storeButton *ui.SimpleButton cardsInStore int stackBuffer *ui.StackBuffer prompt *ui.Prompt messageBuffer *ui.Buffer triggers []*game.TriggeredAction ui.Collection activePlayerId int winners []*game.Player done bool playerCtrl *game.ChanPlayerControl hasPriority bool gameState game.State storesOnMap bool } func newGame(app *app, gameState game.State) *Game { g := &Game{ app: app, settings: newSettings(), gameState: gameState, keyBindings: make(keyBindings), Collection: ui.Collection{ Width: app.windowWidth, Height: app.windowHeight, }, } // default key bindings g.keyBindings[ebiten.MouseButtonLeft] = selection g.keyBindings[ui.Tap] = selection g.keyBindings[ebiten.KeySpace] = pass g.keyBindings[ebiten.KeyEscape] = reset g.keyBindings[ebiten.KeyQ] = func(ui.InputEvent, *Game) (bool, error) { return false, ebiten.Termination } menuImg := ebiten.NewImageFromImage(assets.GetIcon("menu.png")) g.menuButton = ui.NewImageButton( g.Width-menuImg.Bounds().Dx()-10, 10, menuImg, func(*ui.ImageButton) { activities.PushActivity(newGameMenu(g)) }) g.AddWidget(g.menuButton) 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.visible(g.messageBuffer) { g.hide(g.messageBuffer) } else if g.prompt != nil { g.progressPrompt() } else if g.hasPriority { g.passPriority() } }) g.resetButton = ui.NewRoundSimpleButton( 10, g.Height/2+(ui.PROMPT_HEIGHT-DEFAULT_BUTTON_HEIGHT)/2, DEFAULT_BUTTON_HEIGHT, "X", func(*ui.SimpleButton) { g.reset() }) g.messageBuffer = ui.NewBuffer( MESSAGE_BUFFER_MARGIN, g.Height-DEFAULT_BUTTON_HEIGHT-MESSAGE_BUFFER_HEIGHT-MESSAGE_BUFFER_MARGIN, g.Width-2*MESSAGE_BUFFER_MARGIN, MESSAGE_BUFFER_HEIGHT, ) return g } func (g *Game) loadMap(mapName string) *Game { m, err := game.GetMap(mapName) if err != nil { log.Fatal(err) } g.gameState.SetMap(m) g.initMapUi() return g } func (g *Game) initMapUi() { g.mapView = ui.NewMapView(g.gameState, &g.Collection) g.AddWidget(g.mapView) // Detect if the map has store tiles. g.gameState.Map().FilterTiles(func(t *game.Tile) bool { if t.Type == game.TileTypes.Store { g.storesOnMap = true } return false }) } func (g *Game) addActivePlayer(name string, deckList string) *Game { deck := game.NewDeckFromDeckList(deckList) g.gameState.AddNewPlayer(name, deck) p := g.gameState.PlayerByName(name) g.activePlayerId = p.Id return g.initPlayerUi(p) } func (g *Game) activePlayer() *game.Player { return g.gameState.PlayerById(g.activePlayerId) } func (g *Game) initPlayerUi(player *game.Player) *Game { g.playerCtrl = game.NewChanPlayerControl(player) 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) discardImg := ebiten.NewImageFromImage(assets.GetIcon("discard2.png")) g.discardBtn = ui.NewImageButton( g.handLayer.X+2, g.handLayer.Y+g.handLayer.Height-discardImg.Bounds().Dy()-2, discardImg, func(*ui.ImageButton) { g.addCancelablePrompt(game.NewFreeDiscardAction(player), "select two hand cards") }, ) g.AddWidget(g.discardBtn) 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, POC_BUTTON_WIDTH, DEFAULT_BUTTON_HEIGHT, "Discard Pile [0]", func(*ui.SimpleButton) { if !g.visible(g.discardPileView) { 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, &g.Collection) // Init the store view and the store button if we know there is only the active player's one. if len(g.gameState.Map().Stores) == 0 { x = g.stateBar.Width + g.discardPileButton.Width y = g.Height - DEFAULT_BUTTON_HEIGHT g.storeButton = ui.NewSimpleButton(x, y, POC_BUTTON_WIDTH, DEFAULT_BUTTON_HEIGHT, fmt.Sprintf("Store [%d]", g.activePlayer().Store.Size()), func(*ui.SimpleButton) { if !g.storesVisible { g.showStores() } else { g.hideStores() } }) g.AddWidget(g.storeButton) x = g.storeButton.X + g.storeButton.Width/2 y = g.Height - DEFAULT_BUTTON_HEIGHT g.storeViews = []*ui.PocList{ui.NewPocList(x, y, g.activePlayer().Store, &g.Collection)} } return g } func (g *Game) showWinners() { g.done = true msg := g.winners[0].Name for _, p := range g.winners[1:] { msg = msg + fmt.Sprintf(", %s", p.Name) } msg = msg + " won" g.AddWidget(ui.NewFixedTextBox(0, g.Height/2, g.Width, ui.PROMPT_HEIGHT, msg).Centering(true)) g.passButton.Bg = ui.HighlightSelectionColor g.passButton.UpdateLabel("back") g.passButton.RegisterHandler("click", func(int, int) { activities.PopActivity() }) } func (g *Game) updateStack() { if g.visible(g.stackBuffer) { g.hide(g.stackBuffer) } if !g.gameState.Stack().IsEmpty() { g.stackBuffer = ui.NewStackBuffer( g.Width-STACK_BUFFER_WIDTH, g.passButton.Y-400, STACK_BUFFER_WIDTH, STACK_BUFFER_HEIGHT, g.gameState.Stack().Actions) g.show(g.stackBuffer) } } func (g *Game) showStores() { if g.storesVisible { return } g.storesVisible = true if g.storesOnMap { // Reset the store views g.storeViews = []*ui.PocList{} for _, t := range g.gameState.Map().AllTiles() { if t.Type == game.TileTypes.Store && g.activePlayer().KnowsStore(t.Position) { x, y := g.mapView.GetScreenPosition(t) g.storeViews = append(g.storeViews, ui.NewPocList(x, y, g.gameState.Map().StoreOn(t.Position), &g.Collection)) } } } for _, s := range g.storeViews { s.ForceRedraw() g.AddWidget(s) } } func (g *Game) hideStores() { g.storesVisible = false for _, s := range g.storeViews { g.RemoveWidget(s) } } func (g *Game) showMessage(message string) { g.messageBuffer.ClearLines() g.messageBuffer.Bg(color.RGBA{0x80, 0x80, 0x80, 0xe0}) g.messageBuffer.AddText(message) g.show(g.messageBuffer) } func (g *Game) showError(message string) { g.messageBuffer.ClearLines() g.messageBuffer.Bg(color.RGBA{0xe0, 0x10, 0x10, 0xe0}) g.messageBuffer.AddText(message) g.show(g.messageBuffer) } func (g *Game) addPermActionChoice(perm game.Permanent, x, y int) { if len(perm.Pile()) == 0 { g.addActionChoice(perm, x, y) return } perms := []game.Permanent{perm} labels := []string{perm.Card().Name} for _, p := range perm.Pile() { if len(p.CurrentlyAvailActions()) > 0 { perms = append(perms, p) labels = append(labels, p.Card().Name) } } if len(perms) > 1 { onClick := func(c *ui.Choice, x, y int) { g.removeChoice() p := perms[c.GetChoosen(x, y)] g.addActionChoice(p, x, y) } g.addChoice(ui.NewChoice(x, y, utils.TypedSliceToInterfaceSlice(labels), onClick)) } else { g.addActionChoice(perms[0], x, y) } } func (g *Game) declareOrPromptTargets(a game.Action) { if a.Targets() != nil && a.Targets().AllowSelection() { g.addCancelablePrompt(a, fmt.Sprintf("Select targets for %v", a)) } else { g.declareAction(a) } } func (g *Game) addActionCostChoice(a game.Action, x, y int) { u := a.Source().(*game.Unit) if u.AvailMoveActions > 0 && u.AvailAttackActions == 0 { game.UseMoveAction(a) } else if u.AvailAttackActions > 0 && u.AvailMoveActions == 0 { game.UseAttackAction(a) } if !a.NeedsActionCostChoice() { g.declareOrPromptTargets(a) return } choices := []string{"move", "attack"} onClick := func(c *ui.Choice, x, y int) { g.removeChoice() switch choices[c.GetChoosen(x, y)] { case "move": game.UseMoveAction(a) case "attack": game.UseAttackAction(a) } g.declareOrPromptTargets(a) } g.addChoice(ui.NewChoice(x, y, utils.TypedSliceToInterfaceSlice(choices), onClick)) } func (g *Game) addActionChoice(perm game.Permanent, x, y int) { actions := perm.CurrentlyAvailActions() if len(actions) > 0 { g.selectedObject = perm onClick := func(c *ui.Choice, x, y int) { g.removeChoice() a := actions[c.GetChoosen(x, y)] if a.NeedsActionCostChoice() { g.addActionCostChoice(a, x, y) } else { g.declareOrPromptTargets(a) } } g.addChoice(ui.NewActionChoice(x, y, actions, onClick)) } } func (g *Game) addChoice(choice ui.Widget) { if g.choice != nil { g.removeChoice() } g.choice = choice g.AddWidget(choice) } func (g *Game) removeChoice() { g.RemoveWidget(g.choice) g.choice = nil } func (g *Game) addCancelablePrompt(action game.Action, prompt string) { g._addPrompt(action, prompt, true) } func (g *Game) addPrompt(action game.Action, prompt string) { g._addPrompt(action, prompt, false) } func (g *Game) _addPrompt(action game.Action, prompt string, cancelable bool) { if g.prompt != nil { g.removePrompt() } if cancelable { g.prompt = ui.NewCancelablePrompt(g.Height/2, g.Width, action, prompt) } else { g.prompt = ui.NewPrompt(g.Height/2, g.Width, action, prompt) } g.AddWidget(g.prompt) if cancelable { g.show(g.resetButton) } g.updateHighlight() } func (g *Game) progressPrompt() { a := g.prompt.Action() targets := a.Targets() if targets.RequireSelection() { targets.Next() g.updateHighlight() } else { g.declareAction(a) g.removePrompt() } } func (g *Game) removePrompt() { g.RemoveWidget(g.prompt) g.RemoveWidget(g.resetButton) g.prompt = nil } func (g *Game) addTriggers(actions []*game.TriggeredAction) { if g.triggers != nil { log.Panicf("There is already an active trigger ui") } g.triggers = actions } func (g *Game) addHighlight(obj any, color color.Color) { switch obj := obj.(type) { case ui.HandCard: g.handLayer.AddHighlightCard(obj.C, color) case *game.Card: g.handLayer.AddHighlightCard(obj, color) case *game.Tile: g.mapView.AddHighlightTile(obj, color) case *game.Unit: g.mapView.AddHighlightPermanent(obj, color) case game.Action: g.stackBuffer.AddHighlight(obj, color) default: log.Panicf("Unhandled highlight of type %T", obj) } } func (g *Game) addHighlights(objs []any, color color.Color) { for _, obj := range objs { g.addHighlight(obj, color) } } func (g *Game) clearMapHighlights() { g.mapView.ClearPermanentsHighlights() g.mapView.ClearTileHighlights() g.mapView.ForceRedraw() } func (g *Game) clearHighlights() { g.clearMapHighlights() g.handLayer.ClearHighlights() if g.stackBuffer != nil { g.stackBuffer.ClearHighlights() } } func (g *Game) declareAction(a game.Action) { if _, ok := a.(*game.BuyAction); ok { g.hideStores() } g.playerCtrl.SendAction(a) g.clearHighlights() g.hasPriority = false g.stateBar.ForceRedraw() } func (g *Game) visible(w ui.Widget) bool { return g.FindWidget(w) != -1 } // show ensures a widget is visible by adding it to the collection or moving it to the last position. func (g *Game) show(w ui.Widget) { idx := g.FindWidget(w) if idx == -1 { g.AddWidget(w) } else { g.MoveIdxToLast(idx) } } func (g *Game) hide(w ui.Widget) { g.RemoveWidget(w) } func (g *Game) updateButtons() { if g.prompt == nil && !g.hasPriority { g.hide(g.passButton) } if g.activePlayer().Hand.Size() < 2 { g.hide(g.discardBtn) } else { g.show(g.discardBtn) } if nCards := g.activePlayer().DiscardPile.Size(); nCards != g.cardsInDiscardPile { g.cardsInDiscardPile = nCards g.discardPileButton.UpdateLabel(fmt.Sprintf("discard pile [%d]", nCards)) g.discardPileButton.ForceRedraw() } if nCards := g.activePlayer().Store.Size(); nCards != g.cardsInStore { g.cardsInStore = nCards g.discardPileButton.UpdateLabel(fmt.Sprintf("store [%d]", nCards)) g.storeButton.ForceRedraw() } passButtonLabel := "pass" if g.prompt != nil { targets := g.prompt.Action().Targets() target := targets.Cur() // We still need a selection if targets.RequireSelection() { // The current target requires a selection if target.RequireSelection() { g.hide(g.passButton) g.show(g.resetButton) return } // The current target does not need further selections so we can // offer to go to the next target. passButtonLabel = "next" } else { passButtonLabel = "confirm" } if g.settings.gameplay["autoProgressPrompt"].(bool) && !targets.Cur().AllowSelection() { g.progressPrompt() return } } g.show(g.passButton) g.passButton.UpdateLabel(passButtonLabel) } func (g *Game) ResetHover() { ui.AppendInput(ui.InputEvent{ui.HoverEnd, -1, -1, nil}) } func (g *Game) reset() { g.removeChoice() g.selectedObject = nil g.ResetHover() g.clearHighlights() g.hideStores() g.RemoveWidget(g.discardPileView) g.RemoveWidget(g.resetButton) if g.prompt != nil { if g.prompt.Action().Targets().NoSelections() { g.removePrompt() } else { g.prompt.ClearSelections() } } } func (g *Game) passPriority() { g.declareAction(game.NewPassPriority(g.activePlayer())) } func (g *Game) handlePlayerNotifications() { n, err := g.playerCtrl.RecvNotification() if err != nil { // FIXME } for n.Valid() { log.Debug("Received", "notification", n) switch n.Notification { case game.PriorityNotification: g.hasPriority = true case game.DeclaredActionNotification: if n.Error != nil { log.Fatal(n.Error) } g.updateStack() case game.ResolvedActionNotification: g.clearMapHighlights() g.updateStack() g.mapView.ForceRedraw() case game.TargetSelectionPrompt: ctx := n.Context.(game.TargetSelectionCtx) if _, ok := ctx.Action.(*game.BuyAction); ok { g.showStores() } g.addPrompt(ctx.Action, ctx.Prompt) case game.DeclareTriggeredActionsPrompt: triggeredActions := n.Context.([]*game.TriggeredAction) // TODO: present triggered action for ordering and target selection g.playerCtrl.SendAction( game.NewDeclareTriggeredActionsAction(triggeredActions)) } g.stateBar.ForceRedraw() g.handLayer.ForceRedraw() n, err = g.playerCtrl.RecvNotification() if err != nil { // FIXME } } } func (g *Game) findObjectAt(x, y int) any { // Iterate the widget in reverse order to ensure that objects are found // in the newer widget possibly drawn over older ones widgets := g.Widgets() for i := len(widgets) - 1; i >= 0; i-- { w := 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) progressPlayAction(a *game.PlayAction) { targets := a.Targets() if targets != nil && targets.AllowSelection() { g.addCancelablePrompt(a, fmt.Sprintf("Select targets to play %v", a.Card.Name)) g.handLayer.HighlightCard(a.Card, ui.HighlightSelectionColor) } else { log.Debug("No targets or no selection allowed", "action", a) g.declareAction(a) } } func (g *Game) handleSelection(obj any, x, y int) { if obj == nil { log.Debug("No object found at cursor", "cursor", game.Position{x, y}) return } // Remove the choice since it is not clicked g.removeChoice() switch obj := obj.(type) { case *game.Tile: // The user selected a store tile which they know. if obj.Type == game.TileTypes.Store && g.activePlayer().KnowsStore(obj.Position) && !g.storesVisible { for _, s := range g.storeViews { poc := g.gameState.Map().StoreOn(obj.Position) if s.Poc == poc { g.show(s) break } } } case game.Permanent: if g.gameState.Stack().IsEmpty() { g.addPermActionChoice(obj, x, y) } case ui.HandCard: if obj.C.IsPermanent() && !g.gameState.Stack().IsEmpty() { return } if obj.C.PlayCosts.IsVariadic() { g.addChoice(ui.NewNumberChoice( g.Width-ui.NUMBER_CHOICE_WIDTH, g.Height-ui.NUMBER_CHOICE_HEIGHT, 0, func(nc *ui.NumberChoice) { a := game.NewPlayActionVariadicCosts( g.activePlayer(), obj.C, nc.GetChoosen()) g.progressPlayAction(a) })) } else { a := game.NewPlayAction(g.activePlayer(), obj.C) g.progressPlayAction(a) } default: log.Panicf("Object of type %T not handled", obj) } } func (g *Game) updateHighlight() { g.clearHighlights() if g.prompt == nil { return } a := g.prompt.Action() if pa, ok := a.(*game.PlayAction); ok { g.addHighlight(ui.HandCard{C: pa.Card}, ui.HighlightOptionColor) } targets := a.Targets() if len(targets.Targets()) == 0 { return } for _, t := range targets.Targets() { g.addHighlights(t.Selection(), ui.HighlightSelectionColor) } target := targets.Cur() if target.AllowSelection() { options := target.Options() g.addHighlights(options, ui.HighlightOptionColor) } } func (g *Game) Update() error { if err := ui.Update(); err != nil { return err } if err := g.Collection.Update(); err != nil { return err } // We are done. Skip the rest. // Especially updating the button resetting the new passButton label. if g.done { return nil } else if len(g.winners) > 0 { g.showWinners() return nil } g.handlePlayerNotifications() if err := g.handleKeyBindings(g.keyBindings); err != nil { return err } g.updateButtons() return nil } func (g *Game) Draw(screen *ebiten.Image) { g.Collection.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() { g.winners = g.gameState.Loop() }() return g }