clone

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

main
puzzletea / games / fillomino / style.go
style.go 9.3K
1package fillomino23import (4 "image/color"5 "strconv"67 "charm.land/lipgloss/v2"8 "github.com/FelineStateMachine/puzzletea/game"9 "github.com/FelineStateMachine/puzzletea/theme"10)1112const cellWidth = game.DynamicGridCellWidth1314type renderGridState struct {15 zones [][]int16 activeZone int17 zoneFill map[int]color.Color18}1920func cellBaseStyle() lipgloss.Style {21 p := theme.Current()22 return lipgloss.NewStyle().23 Foreground(p.FG).24 Background(p.BG)25}2627func emptyCellStyle() lipgloss.Style {28 p := theme.Current()29 return lipgloss.NewStyle().30 Foreground(p.TextDim).31 Background(p.BG)32}3334func cellView(35 value int,36 provided, cursor, rowHighlight, colHighlight, regionHighlight, solved, conflict bool,37 zoneBG color.Color,38) string {39 p := theme.Current()40 style := cellBaseStyle()41 text := " · "42 if value == 0 {43 style = emptyCellStyle()44 } else {45 text = lipgloss.NewStyle().Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(strconv.Itoa(value))46 }47 if conflict && !cursor {48 text = conflictText(text)49 }5051 if provided && value != 0 {52 style = style.Bold(true)53 }54 if conflict && cursor {55 style = conflictedCursorStyle()56 } else if conflict {57 style = style.Foreground(game.ConflictFG()).Background(game.ConflictBG())58 } else if solved {59 style = style.Foreground(game.SolvedFG()).Background(p.SuccessBG)60 } else if cursor {61 style = game.CursorStyle()62 } else if regionHighlight {63 style = style.Background(p.HighlightBG)64 } else if zoneBG != nil {65 style = style.Background(zoneBG).Foreground(theme.TextOnBG(zoneBG))66 } else if rowHighlight || colHighlight {67 style = style.Background(p.Surface)68 }6970 if provided && value != 0 && !conflict && !solved && !cursor {71 bg := p.BG72 switch {73 case regionHighlight:74 bg = p.HighlightBG75 case zoneBG != nil:76 bg = zoneBG77 case rowHighlight || colHighlight:78 bg = p.Surface79 }80 style = style.Background(theme.GivenTint(bg))81 }8283 if cursor {84 if value == 0 {85 text = game.CursorLeft + "·" + game.CursorRight86 } else {87 text = game.CursorLeft + strconv.Itoa(value) + game.CursorRight88 }89 }9091 return style.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(text)92}9394func conflictedCursorStyle() lipgloss.Style {95 return lipgloss.NewStyle().96 Bold(true).97 Foreground(game.CursorFG()).98 Background(game.ConflictBG())99}100101func conflictText(text string) string {102 runes := []rune(text)103 if len(runes) != cellWidth {104 return text105 }106 return "!" + string(runes[1]) + "!"107}108109func gridView(m Model) string {110 renderState := buildRenderGridState(m)111112 return game.RenderDynamicGrid(game.DynamicGridSpec{113 Width: m.width,114 Height: m.height,115 Solved: m.solved,116 Cell: func(x, y int) string {117 zone := renderState.zones[y][x]118 zoneBG := renderState.zoneFill[zone]119 return cellView(120 m.grid[y][x],121 m.provided[y][x],122 x == m.cursor.X && y == m.cursor.Y,123 y == m.cursor.Y,124 x == m.cursor.X,125 renderState.activeZone >= 0 && zone == renderState.activeZone,126 m.solved,127 m.conflicts[y][x],128 zoneBG,129 )130 },131 ZoneAt: func(x, y int) int {132 return renderState.zones[y][x]133 },134 ZoneFill: func(zone int) color.Color {135 p := theme.Current()136 switch {137 case m.solved:138 return p.SuccessBG139 case renderState.activeZone >= 0 && zone == renderState.activeZone:140 return p.HighlightBG141 default:142 return renderState.zoneFill[zone]143 }144 },145 BridgeFill: func(bridge game.DynamicGridBridge) color.Color {146 return bridgeFill(m, renderState, bridge)147 },148 })149}150151func bridgeFill(m Model, renderState renderGridState, bridge game.DynamicGridBridge) color.Color {152 for i := 0; i < bridge.Count; i++ {153 cell := bridge.Cells[i]154 if m.conflicts[cell.Y][cell.X] {155 return game.ConflictBG()156 }157 }158159 if m.solved {160 return nil161 }162163 if bridge.Uniform {164 switch {165 case renderState.activeZone >= 0 && bridge.Zone == renderState.activeZone:166 return nil167 case renderState.zoneFill[bridge.Zone] != nil:168 return nil169 }170 }171172 if bridge.Count > 0 && !bridgeTouchesBorder(renderState, bridge, m.width, m.height) {173 return nil174 }175176 if game.DynamicGridBridgeOnCrosshairAxis(m.cursor, bridge) {177 return theme.Current().Surface178 }179180 return nil181}182183func bridgeTouchesBorder(renderState renderGridState, bridge game.DynamicGridBridge, width, height int) bool {184 switch bridge.Kind {185 case game.DynamicGridBridgeVertical:186 return zoneJunctionRune(renderState.zones, width, height, bridge.X, bridge.Y) != ' ' ||187 zoneJunctionRune(renderState.zones, width, height, bridge.X, bridge.Y+1) != ' '188 case game.DynamicGridBridgeHorizontal:189 return zoneJunctionRune(renderState.zones, width, height, bridge.X, bridge.Y) != ' ' ||190 zoneJunctionRune(renderState.zones, width, height, bridge.X+1, bridge.Y) != ' '191 default:192 return false193 }194}195196func zoneHorizontalEdge(zones [][]int, height, x, y int) bool {197 switch {198 case y <= 0, y >= height:199 return true200 default:201 return zones[y-1][x] != zones[y][x]202 }203}204205func zoneVerticalEdge(zones [][]int, width, height, x, y int) bool {206 _ = height207 switch {208 case x <= 0, x >= width:209 return true210 default:211 return zones[y][x-1] != zones[y][x]212 }213}214215func zoneJunctionRune(zones [][]int, width, height, x, y int) rune {216 north := y > 0 && zoneVerticalEdge(zones, width, height, x, y-1)217 south := y < height && zoneVerticalEdge(zones, width, height, x, y)218 west := x > 0 && zoneHorizontalEdge(zones, height, x-1, y)219 east := x < width && zoneHorizontalEdge(zones, height, x, y)220221 switch {222 case north && south && west && east:223 return '┼'224 case north && south && west:225 return '┤'226 case north && south && east:227 return '├'228 case west && east && north:229 return '┴'230 case west && east && south:231 return '┬'232 case south && east:233 return '┌'234 case south && west:235 return '┐'236 case north && east:237 return '└'238 case north && west:239 return '┘'240 case north || south:241 return '│'242 case west || east:243 return '─'244 default:245 return ' '246 }247}248249func buildRenderGridState(m Model) renderGridState {250 height := len(m.grid)251 if height == 0 {252 return renderGridState{}253 }254 width := len(m.grid[0])255 zones := make([][]int, height)256 for y := range height {257 zones[y] = make([]int, width)258 }259260 visited := make([][]bool, height)261 for y := range height {262 visited[y] = make([]bool, width)263 }264265 palette := theme.Current()266 colors := palette.ThemeColors()267 zoneFill := make(map[int]color.Color)268 nextZone := 0269 activeZone := -1270 emptyZone := nextZone271 nextZone++272273 for y := range height {274 for x := range width {275 if m.grid[y][x] == 0 {276 zones[y][x] = emptyZone277 continue278 }279 if visited[y][x] {280 continue281 }282283 comp := buildComponent(m.grid, point{x: x, y: y}, visited)284 zone := nextZone285 nextZone++286 for _, cell := range comp.cells {287 zones[cell.y][cell.x] = zone288 }289 if len(colors) > 0 {290 zoneFill[zone] = incompleteRegionColor(comp, colors, palette.Surface)291 }292 if len(colors) > 0 && len(comp.cells) == comp.value && !componentHasConflict(comp, m.conflicts) {293 zoneFill[zone] = completedRegionColor(comp, colors, palette.Surface)294 }295 }296 }297298 if m.cursor.Y >= 0 && m.cursor.Y < height && m.cursor.X >= 0 && m.cursor.X < width && m.grid[m.cursor.Y][m.cursor.X] != 0 {299 activeZone = zones[m.cursor.Y][m.cursor.X]300 }301302 return renderGridState{303 zones: zones,304 activeZone: activeZone,305 zoneFill: zoneFill,306 }307}308309func componentHasConflict(comp component, conflicts [][]bool) bool {310 for _, cell := range comp.cells {311 if conflicts[cell.y][cell.x] {312 return true313 }314 }315 return false316}317318func completedRegionColor(comp component, colors []color.Color, base color.Color) color.Color {319 return regionColor(comp, colors, base, 0.52)320}321322func incompleteRegionColor(comp component, colors []color.Color, base color.Color) color.Color {323 return regionColor(comp, colors, base, 0.26)324}325326func regionColor(comp component, colors []color.Color, base color.Color, amount float64) color.Color {327 if len(colors) == 0 {328 return nil329 }330331 anchor := comp.cells[0]332 index := (anchor.y*37 + anchor.x*17 + comp.value*13) % len(colors)333 return theme.Blend(base, colors[index], amount)334}335336func cursorRegionInfoStyle() lipgloss.Style {337 return lipgloss.NewStyle().Foreground(theme.Current().Info)338}339340func cursorRegionInfoView(m Model) string {341 current, target := cursorRegionInfoData(m)342 return cursorRegionInfoStyle().Render("region size: " + strconv.Itoa(current) + "/" + target)343}344345func cursorRegionInfoVariants(m Model) []string {346 area := m.width * m.height347 maxTarget := m.maxCellValue348 maxTarget = max(maxTarget, 1)349350 return []string{351 cursorRegionInfoStyle().Render("region size: " + strconv.Itoa(area) + "/-"),352 cursorRegionInfoStyle().Render("region size: " + strconv.Itoa(area) + "/" + strconv.Itoa(maxTarget)),353 }354}355356func cursorRegionInfoData(m Model) (current int, target string) {357 if len(m.grid) == 0 || len(m.grid[0]) == 0 {358 return 0, "-"359 }360361 cursor := point{x: m.cursor.X, y: m.cursor.Y}362 value := m.grid[cursor.y][cursor.x]363 visited := make([][]bool, len(m.grid))364 for y := range len(m.grid) {365 visited[y] = make([]bool, len(m.grid[y]))366 }367368 comp := buildComponent(m.grid, cursor, visited)369 if value == 0 {370 return len(comp.cells), "-"371 }372 return len(comp.cells), strconv.Itoa(value)373}374375func statusBarView(showFullHelp bool) string {376 if showFullHelp {377 return game.StatusBarStyle().Render("1-9: place bkspc: clear arrows/wasd: move esc: menu ctrl+r: reset ctrl+h: help")378 }379 return game.StatusBarStyle().Render("1-9: place bkspc: clear")380}