package ui import ( "fmt" "image/color" "math" "unicode" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/vector" "muhq.space/muhqs-game/go/assets" "muhq.space/muhqs-game/go/game" "muhq.space/muhqs-game/go/log" ) const ( PERMANENT_WIDTH int = 40 PERMANENT_HEIGHT int = 40 TILE_WIDTH int = 50 TILE_HEIGHT int = 50 ) type MapView struct { hoverPermInfo EventHandlersMap gameState game.State mapLayer *ebiten.Image permanentsLayer *ebiten.Image scale float64 tileHighlights map[game.Position][]color.Color permanentsHighlights map[game.Permanent][]color.Color } func NewMapView(g game.State, c *Collection) *MapView { vw := &MapView{ EventHandlersMap: NewEventHandlersMap(), gameState: g, scale: 1, tileHighlights: make(map[game.Position][]color.Color), permanentsHighlights: make(map[game.Permanent][]color.Color), } vw.hoverPermInfo.init(vw, c) vw.RegisterHandler("hover", vw.hoverPermInfo.Hover) return vw } func (mv *MapView) Height() int { return int(float64(len(mv.gameState.Map().Tiles)*TILE_HEIGHT) * mv.scale) } func (mv *MapView) Width() int { maxWidth := 0 for _, row := range mv.gameState.Map().Tiles { if n := len(row); n > maxWidth { maxWidth = n } } return int(float64(maxWidth*TILE_WIDTH) * mv.scale) } func rotateTileImg(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) handleStreet(x, y int, op *ebiten.DrawImageOptions) *ebiten.Image { var img *ebiten.Image 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.Warn(fmt.Sprintf("Street at (%d, %d) is not connected", x, y)) img = assets.GetTile("street") } else if connections == 1 || (connections == 2 && ((left && right) || (above && below))) { img = assets.GetTile("street") if above || below { rotateTileImg(x, y, math.Pi/2, op) } } else if connections == 2 { img = assets.GetTile("street_2") // normal orientation above and right if right && below { rotateTileImg(x, y, math.Pi/2, op) } else if left && below { rotateTileImg(x, y, math.Pi, op) } else if above && left { rotateTileImg(x, y, 3*math.Pi/2, op) } } else if connections == 3 { img = assets.GetTile("street_3") // normal orientation left above right if above && right && below { rotateTileImg(x, y, math.Pi/2, op) } else if left && below && right { rotateTileImg(x, y, math.Pi, op) } else if below && left && above { rotateTileImg(x, y, 3*math.Pi/2, op) } } else if connections == 4 { img = assets.GetTile("street_4") } return img } func (vw *MapView) handleWall(x, y int, op *ebiten.DrawImageOptions) *ebiten.Image { var img *ebiten.Image connections, left, right, above, below := vw.gameState.Map().FindFortificationConnections(x, y) if connections == 0 { // This wall is not connected to another wall -> // check any other non neutral tiles connections, left, right, above, below = vw.gameState.Map().FindAnyConnections(x, y) } // This wall is not connected to anything. Seams odd! if connections == 0 { log.Warn(fmt.Sprintf("Wall at (%d, %d) is not connected", x, y)) img = assets.GetTile("wall") } else if (connections == 1 || (connections == 2 && ((left && right) || (above && below)))) && (above || below) { if above || below { img = assets.GetTile("wall_ud") } else { img = assets.GetTile("wall") } } else if connections == 2 { if above { img = assets.GetTile("wall_elbow_up") } else { img = assets.GetTile("wall_elbow_down") } if left { op.GeoM.Scale(-1, 1) op.GeoM.Translate(float64(img.Bounds().Dx()), 0) } } else if connections == 3 || connections == 4 { img = assets.GetTile("wall") } return img } func (vw *MapView) handleGate(x, y int, op *ebiten.DrawImageOptions) (img *ebiten.Image) { _, left, right, above, below := vw.gameState.Map().FindFortificationConnections(x, y) if left || right { img = assets.GetTile("gate_lr") } else if above || below { img = assets.GetTile("gate_ud") } else { img = assets.GetTile("gate") } return } func (vw *MapView) handleTower(x, y int, op *ebiten.DrawImageOptions) *ebiten.Image { connections, left, right, above, _ := vw.gameState.Map().FindFortificationConnections(x, y) if connections == 0 { return assets.GetTile("tower") } // corner cases where tower is placed on the edge if (left || above) && len(vw.gameState.Map().Tiles[y])-1 == x { right = true } if (right || above) && x == 0 { left = true } selector := "" if left { selector += "l" } if right { selector += "r" } if above { selector += "u" } tileName := "tower" if selector != "" { tileName += "_" + selector } return assets.GetTile(tileName) } 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) var tileImg *ebiten.Image op := &ebiten.DrawImageOptions{} op.GeoM.Scale(vw.scale, vw.scale) switch tile.Type { case game.TileTypes.Street: tileImg = vw.handleStreet(x, y, op) case game.TileTypes.Wall: tileImg = vw.handleWall(x, y, op) case game.TileTypes.Gate: tileImg = vw.handleGate(x, y, op) case game.TileTypes.Tower: tileImg = vw.handleTower(x, y, op) case game.TileTypes.Neutral: tileImg = assets.GetTile("neutral") default: tileImg = assets.GetTile(tile.Raw) } if tileImg == nil { log.Panic("failed to load tile", "tile", tile.Raw) } op.GeoM.Translate(x_px, y_px) if colors, found := vw.tileHighlights[pos]; found { for _, color := range colors { op.ColorScale.ScaleWithColor(color) } } vw.mapLayer.DrawImage(tileImg, op) x_px += float64(TILE_WIDTH) } x_px = 0 y_px += float64(TILE_HEIGHT) } } screen.DrawImage(vw.mapLayer, &ebiten.DrawImageOptions{}) } // getPermanentSymbol returnes a cached symbol or generates a new generic one. func getPermanentSymbol(p game.Permanent, i int) *ebiten.Image { permanentSymbol := assets.GetSymbol(p.Card().FileName()) 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.Panicf("Failed to generate generic symbol %s", symbol) } } // Return a copy of the image to allow modifications without affect other // uses of the symbol. return ebiten.NewImageFromImage(permanentSymbol) } func (vw *MapView) drawPermanentsLayer(screen *ebiten.Image) { if vw.permanentsLayer == nil { vw.permanentsLayer = vw.newLayerImage() for i, p := range vw.gameState.Permanents() { t := p.Tile() // Skip permanents with no containing tiles (e.g. piled ones) if t == nil { continue } permanentSymbol := getPermanentSymbol(p, i) 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.Scale(vw.scale, vw.scale) op.GeoM.Translate(float64(x_px), float64(y_px)) col := PlayerColors[p.Controller().Id-1] op.ColorScale.ScaleWithColor(col) if colors, found := vw.permanentsHighlights[p]; found { for _, color := range colors { op.ColorScale.ScaleWithColor(color) } } // Draw a pile hint if len(p.Pile()) > 0 { drawPileHint(permanentSymbol) } vw.permanentsLayer.DrawImage(permanentSymbol, op) } } screen.DrawImage(vw.permanentsLayer, &ebiten.DrawImageOptions{}) } const ( PILE_HINT_STARTX = 20 PILE_HINT_LENGTH = 15 PILE_HINT_STARTY = 25 PILE_HINT_WIDTH = 2 PILE_HINT_SEP = 2 ) func drawPileHint(screen *ebiten.Image) { var path vector.Path var i float32 for i = 1.0; i < 4; i++ { y := PILE_HINT_STARTY + i*(PILE_HINT_WIDTH+PILE_HINT_SEP) path.MoveTo(PILE_HINT_STARTX, y) path.LineTo(PILE_HINT_STARTX+PILE_HINT_LENGTH, y) path.Close() } strokeOp := &vector.StrokeOptions{Width: PILE_HINT_WIDTH} drawOp := &vector.DrawPathOptions{AntiAlias: true} vector.StrokePath(screen, &path, strokeOp, drawOp) } 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) any { scaled_tile_wdth := int(float64(TILE_WIDTH) * vw.scale) scaled_tile_hght := int(float64(TILE_HEIGHT) * vw.scale) x := screenX / scaled_tile_wdth y := screenY / scaled_tile_hght if x < 0 || y < 0 { return nil } relativeX := screenX % scaled_tile_wdth relativeY := screenY % scaled_tile_hght xMargin := int(float64(TILE_WIDTH-PERMANENT_WIDTH)*vw.scale) / 2 yMargin := int(float64(TILE_HEIGHT-PERMANENT_HEIGHT)*vw.scale) / 2 // detect if a permanent or the containing tile was selected if relativeX >= xMargin && relativeX <= scaled_tile_wdth-xMargin && relativeY >= yMargin && relativeY <= scaled_tile_hght-yMargin { for _, p := range vw.gameState.Permanents() { t := p.Tile() if t == nil { continue } pos := t.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) GetScreenPosition(t *game.Tile) (int, int) { scaled_tile_wdth := int(float64(TILE_WIDTH) * vw.scale) scaled_tile_hght := int(float64(TILE_HEIGHT) * vw.scale) x := scaled_tile_wdth * t.Position.X y := scaled_tile_hght * t.Position.Y return x, y } func (vw *MapView) HighlightPositions(pos []game.Position, col color.Color) { highlights := make(map[game.Position][]color.Color) for _, p := range pos { highlights[p] = []color.Color{col} } vw.tileHighlights = highlights vw.ForceRedraw() } func (vw *MapView) AddHighlightPosition(pos game.Position, col color.Color) { if colors, found := vw.tileHighlights[pos]; found { vw.tileHighlights[pos] = append(colors, col) } else { vw.tileHighlights[pos] = []color.Color{col} } vw.ForceRedraw() } func (vw *MapView) HighlightTiles(tiles []*game.Tile, color color.Color) { pos := make([]game.Position, 0, len(tiles)) for _, t := range tiles { pos = append(pos, t.Position) } vw.HighlightPositions(pos, color) } func (vw *MapView) HighlightTile(t *game.Tile, color color.Color) { vw.HighlightTiles([]*game.Tile{t}, color) } func (vw *MapView) AddHighlightTile(t *game.Tile, color color.Color) { vw.AddHighlightPosition(t.Position, color) } // ClearTileHighlight removes a tile from the highlighted ones. func (vw *MapView) ClearTileHighlight(t *game.Tile) { delete(vw.tileHighlights, t.Position) vw.ForceRedraw() } // ClearTileHighlights removes all tile highlights. func (vw *MapView) ClearTileHighlights() { vw.HighlightTiles([]*game.Tile{}, nil) } func (vw *MapView) HighlightPermanents(permanents []game.Permanent, col color.Color) { highlights := make(map[game.Permanent][]color.Color) for _, p := range permanents { highlights[p] = []color.Color{col} } vw.permanentsHighlights = highlights vw.ForceRedraw() } func (vw *MapView) AddHighlightPermanent(p game.Permanent, col color.Color) { if colors, found := vw.permanentsHighlights[p]; found { vw.permanentsHighlights[p] = append(colors, col) } else { vw.permanentsHighlights[p] = []color.Color{col} } vw.ForceRedraw() } func (vw *MapView) HighlightPermanent(p game.Permanent, color color.Color) { vw.HighlightPermanents([]game.Permanent{p}, color) } func (vw *MapView) ClearPermanentsHighlights() { vw.HighlightPermanents([]game.Permanent{}, nil) } func (mv *MapView) Contains(x, y int) bool { w := mv.Width() h := mv.Height() return x >= 0 && y >= 0 && x <= w && y <= h } func (*MapView) Render(Widget) *ebiten.Image { return nil } func (mv *MapView) Layout() (int, int) { return mv.Width(), mv.Height() } func (mv *MapView) Scale(s float64) *MapView { mv.scale = s return mv }