clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

feat/elo-difficulty
puzzletea / games / nurikabe / style.go
style.go 7.1K
1package nurikabe23import (4 "fmt"5 "image/color"67 "charm.land/lipgloss/v2"8 "github.com/FelineStateMachine/puzzletea/game"9 "github.com/FelineStateMachine/puzzletea/theme"10)1112const (13 cellWidth = game.DynamicGridCellWidth14 neutralZone = -115)1617type renderGridState struct {18 zones [][]int19 zoneColors map[int]color.Color20}2122type cellVisual struct {23 text string24 fg color.Color25 bg color.Color26 bridgeBG color.Color27 bold bool28 kind cellVisualKind29}3031type cellVisualKind int3233const (34 cellVisualNeutral cellVisualKind = iota35 cellVisualSea36 cellVisualCursor37 cellVisualSolved38 cellVisualConflict39)4041func buildRenderGridState(m Model) renderGridState {42 zones := make([][]int, m.height)43 for y := range m.height {44 zones[y] = make([]int, m.width)45 for x := range m.width {46 zones[y][x] = neutralZone47 }48 }4950 _, idx := islandComponents(m.marks, m.clues)51 if idx == nil {52 return renderGridState{zones: zones}53 }5455 themeColors := theme.Current().ThemeColors()56 zoneColors := make(map[int]color.Color)57 for y := range m.height {58 for x := range m.width {59 zone := idx[y][x]60 if zone < 0 {61 continue62 }63 zones[y][x] = zone64 if len(themeColors) == 0 {65 continue66 }67 if _, ok := zoneColors[zone]; !ok {68 zoneColors[zone] = themeColors[zone%len(themeColors)]69 }70 }71 }7273 return renderGridState{74 zones: zones,75 zoneColors: zoneColors,76 }77}7879func zoneBackground(renderState renderGridState, zone int) color.Color {80 if zone < 0 {81 return nil82 }83 return renderState.zoneColors[zone]84}8586func resolveCellVisual(m Model, x, y int) cellVisual {87 return resolveCellVisualWithState(m, buildRenderGridState(m), x, y)88}8990func resolveCellVisualWithState(m Model, renderState renderGridState, x, y int) cellVisual {91 p := theme.Current()92 seaBg := theme.Blend(p.BG, p.Secondary, 0.24)93 c := m.marks[y][x]94 clue := m.clues[y][x]95 isCursor := x == m.cursor.X && y == m.cursor.Y96 conflict := m.conflicts[y][x]97 inSeaSquare := isSeaSquareCell(m.marks, x, y)98 islandBg := zoneBackground(renderState, renderState.zones[y][x])99 visual := cellVisual{100 text: " ",101 fg: p.FG,102 bg: p.BG,103 bridgeBG: p.BG,104 kind: cellVisualNeutral,105 }106107 switch {108 case clue > 0:109 visual.text = fmt.Sprintf("%2d ", clue)110 if clue < 10 {111 visual.text = fmt.Sprintf(" %d ", clue)112 }113 if islandBg != nil {114 visual.bg = islandBg115 visual.bridgeBG = islandBg116 visual.fg = theme.TextOnBG(islandBg)117 } else {118 visual.fg = p.Info119 }120 visual.bold = true121 case c == seaCell:122 visual.text = " ~ "123 if inSeaSquare {124 visual.text = " ! "125 }126 visual.bg = seaBg127 visual.bridgeBG = seaBg128 visual.fg = theme.TextOnBG(seaBg)129 visual.kind = cellVisualSea130 case c == islandCell:131 visual.text = " \u00b7 "132 if islandBg != nil {133 visual.bg = islandBg134 visual.bridgeBG = islandBg135 visual.fg = theme.TextOnBG(islandBg)136 }137 case m.solved && c == unknownCell:138 visual.text = " \u00b7 "139 }140141 switch {142 case conflict:143 visual.bg = game.ConflictBG()144 visual.bridgeBG = game.ConflictBG()145 visual.fg = game.ConflictFG()146 visual.kind = cellVisualConflict147 case m.solved:148 visual.bg = p.SuccessBG149 visual.bridgeBG = p.SuccessBG150 visual.fg = theme.TextOnBG(p.SuccessBG)151 visual.kind = cellVisualSolved152 case isCursor:153 visual.bg = game.CursorBG()154 visual.fg = game.CursorFG()155 visual.kind = cellVisualCursor156 }157 if isCursor {158 visual.text = cursorWrappedText(visual.text)159 }160161 return visual162}163164func cursorWrappedText(text string) string {165 runes := []rune(text)166 if len(runes) != cellWidth {167 return text168 }169 if runes[0] != ' ' || runes[cellWidth-1] != ' ' {170 return text171 }172 return game.CursorLeft + string(runes[1]) + game.CursorRight173}174175func renderCellVisual(visual cellVisual) string {176 style := lipgloss.NewStyle().177 Width(cellWidth).178 AlignHorizontal(lipgloss.Center).179 Foreground(visual.fg).180 Background(visual.bg)181 if visual.bold {182 style = style.Bold(true)183 }184 return style.Render(visual.text)185}186187func dominantBridgeBackground(visuals []cellVisual) color.Color {188 for _, kind := range []cellVisualKind{189 cellVisualConflict,190 cellVisualSolved,191 } {192 for _, visual := range visuals {193 if visual.kind == kind {194 return visual.bridgeBG195 }196 }197 }198 return nil199}200201func blendBridgeBackgrounds(visuals []cellVisual) color.Color {202 if len(visuals) == 0 {203 return nil204 }205206 var rSum, gSum, bSum, aSum uint32207 for _, visual := range visuals {208 r, g, b, a := visual.bridgeBG.RGBA()209 rSum += uint32(r >> 8)210 gSum += uint32(g >> 8)211 bSum += uint32(b >> 8)212 aSum += uint32(a >> 8)213 }214215 count := uint32(len(visuals))216 return color.NRGBA{217 R: uint8(rSum / count),218 G: uint8(gSum / count),219 B: uint8(bSum / count),220 A: uint8(aSum / count),221 }222}223224func bridgeFill(m Model, bridge game.DynamicGridBridge) color.Color {225 return bridgeFillWithState(m, buildRenderGridState(m), bridge)226}227228func bridgeFillWithState(m Model, renderState renderGridState, bridge game.DynamicGridBridge) color.Color {229 if bridge.Count == 0 {230 return nil231 }232233 visuals := make([]cellVisual, 0, bridge.Count)234 for i := 0; i < bridge.Count; i++ {235 cell := bridge.Cells[i]236 visuals = append(visuals, resolveCellVisualWithState(m, renderState, cell.X, cell.Y))237 }238239 bg := visuals[0].bridgeBG240 allMatch := true241 for _, visual := range visuals[1:] {242 if !game.SameColor(bg, visual.bridgeBG) {243 allMatch = false244 break245 }246 }247 if allMatch {248 if dominant := dominantBridgeBackground(visuals); dominant != nil {249 return dominant250 }251 if bridge.Uniform && bridge.Zone >= 0 {252 return nil253 }254 return bg255 }256257 if dominant := dominantBridgeBackground(visuals); dominant != nil {258 return dominant259 }260261 return blendBridgeBackgrounds(visuals)262}263264func gridView(m Model) string {265 renderState := buildRenderGridState(m)266267 return game.RenderDynamicGrid(game.DynamicGridSpec{268 Width: m.width,269 Height: m.height,270 Solved: m.solved,271 Cell: func(x, y int) string {272 return renderCellVisual(resolveCellVisualWithState(m, renderState, x, y))273 },274 ZoneAt: func(x, y int) int {275 return renderState.zones[y][x]276 },277 ZoneFill: func(zone int) color.Color {278 return zoneBackground(renderState, zone)279 },280 BridgeFill: func(bridge game.DynamicGridBridge) color.Color {281 return bridgeFillWithState(m, renderState, bridge)282 },283 })284}285286// isSeaSquareCell reports whether (x,y) is part of any 2x2 sea block.287func isSeaSquareCell(marks grid, x, y int) bool {288 if len(marks) == 0 || len(marks[0]) == 0 {289 return false290 }291 if y < 0 || y >= len(marks) || x < 0 || x >= len(marks[0]) {292 return false293 }294 if marks[y][x] != seaCell {295 return false296 }297298 startX := max(0, x-1)299 endX := min(len(marks[0])-2, x)300 startY := max(0, y-1)301 endY := min(len(marks)-2, y)302303 for yy := startY; yy <= endY; yy++ {304 for xx := startX; xx <= endX; xx++ {305 if marks[yy][xx] == seaCell &&306 marks[yy][xx+1] == seaCell &&307 marks[yy+1][xx] == seaCell &&308 marks[yy+1][xx+1] == seaCell {309 return true310 }311 }312 }313314 return false315}316317func statusBarView(showFullHelp bool) string {318 if showFullHelp {319 return game.StatusBarStyle().Render("arrows/wasd: move x/LMB: sea z/RMB: island bkspc: clear esc: menu ctrl+r: reset ctrl+h: help")320 }321 return game.StatusBarStyle().Render("x/LMB: sea z/RMB: island bkspc: clear")322}