clone

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

bug/architecture-test-trim-path
puzzletea / games / hashiwokakero / style.go
style.go 9.9K
1package hashiwokakero23import (4 "fmt"5 "image/color"6 "slices"7 "strings"89 "charm.land/lipgloss/v2"10 "github.com/FelineStateMachine/puzzletea/game"11 "github.com/FelineStateMachine/puzzletea/theme"12)1314const cellWidth = 51516const islandPillWidth = game.DynamicGridCellWidth1718type cellVisual struct {19 text string20 fg color.Color21 bg color.Color22 outerBG color.Color23 bold bool24 pill bool25}2627func boardBackground() color.Color {28 return theme.Current().BG29}3031func baseIslandBackground() color.Color {32 p := theme.Current()33 return theme.Blend(p.BG, p.Success, 0.45)34}3536func adjacentIslandBackground() color.Color {37 return theme.Blend(baseIslandBackground(), theme.Current().Highlight, 0.12)38}3940func solvedIslandBackground() color.Color {41 p := theme.Current()42 return theme.Blend(p.BG, p.SuccessBorder, 0.55)43}4445func solvedBoardBackground() color.Color {46 return theme.Current().SuccessBG47}4849func baseCellStyle(fg, bg color.Color, bold bool) lipgloss.Style {50 style := lipgloss.NewStyle().51 Width(cellWidth).52 AlignHorizontal(lipgloss.Center).53 Foreground(fg).54 Background(bg)55 if bold {56 style = style.Bold(true)57 }58 return style59}6061func bridgeColors() []color.Color {62 return theme.Current().ThemeColors()63}6465func bridgeColor(index int) color.Color {66 colors := bridgeColors()67 if index < 0 || len(colors) == 0 {68 return theme.TextOnBG(boardBackground())69 }70 return colors[index%len(colors)]71}7273// isHighlightedNeighbor returns true if islandID is directly connectable74// to the cursor (or selected) island in any cardinal direction.75func isHighlightedNeighbor(m Model, islandID int) bool {76 sourceID := m.cursorIsland77 if m.selectedIsland != nil {78 sourceID = *m.selectedIsland79 }80 if sourceID == islandID {81 return false82 }83 for _, dir := range [][2]int{{0, -1}, {0, 1}, {-1, 0}, {1, 0}} {84 adj := m.puzzle.FindAdjacentIsland(sourceID, dir[0], dir[1])85 if adj != nil && adj.ID == islandID {86 return true87 }88 }89 return false90}9192func resolveCellVisual(m Model, x, y int, solved bool) cellVisual {93 boardBG := boardBackground()94 cellBG := boardBG95 if solved {96 cellBG = solvedBoardBackground()97 }98 text := " "99 if !solved {100 text = " · "101 }102 visual := cellVisual{103 text: text,104 fg: theme.Current().TextDim,105 bg: cellBG,106 outerBG: cellBG,107 }108109 ci := m.puzzle.CellContent(x, y)110 switch ci.Kind {111 case cellIsland:112 isl := m.puzzle.FindIslandByID(ci.IslandID)113 if isl == nil {114 return visual115 }116117 label := fmt.Sprintf(" %d ", isl.Required)118 isCursor := m.cursorIsland == ci.IslandID119 current := m.puzzle.BridgeCount(ci.IslandID)120 islandBG := baseIslandBackground()121 visual.pill = true122 visual.outerBG = cellBG123124 switch {125 case solved:126 islandBG = solvedIslandBackground()127 visual.fg = theme.Current().SolvedFG128 visual.bg = islandBG129 visual.bold = true130 case m.selectedIsland != nil && *m.selectedIsland == ci.IslandID:131 visual.fg = theme.TextOnBG(theme.Current().SelectionBG)132 visual.bg = theme.Current().SelectionBG133 visual.bold = true134 case isCursor:135 visual.fg = game.CursorFG()136 visual.bg = game.CursorBG()137 visual.bold = true138 case current > isl.Required:139 visual.fg = theme.TextOnBG(theme.Current().ErrorBG)140 visual.bg = theme.Current().ErrorBG141 visual.bold = true142 case current == isl.Required:143 visual.fg = theme.TextOnBG(theme.Current().SuccessBG)144 visual.bg = theme.Current().SuccessBG145 visual.bold = true146 case isHighlightedNeighbor(m, ci.IslandID):147 adjacentBG := adjacentIslandBackground()148 visual.fg = theme.TextOnBG(adjacentBG)149 visual.bg = adjacentBG150 visual.bold = true151 default:152 visual.fg = theme.TextOnBG(islandBG)153 visual.bg = islandBG154 visual.bold = true155 }156157 if isCursor && !solved {158 label = game.CursorLeft + fmt.Sprintf("%d", isl.Required) + game.CursorRight159 }160 visual.text = label161162 case cellBridgeH:163 visual.text = horizontalBridgeGlyph(ci.BridgeCount)164 visual.fg = bridgeColor(ci.BridgeIdx)165 visual.bold = ci.BridgeCount == 2166 case cellBridgeV:167 visual.text = verticalBridgeGlyph(ci.BridgeCount)168 visual.fg = bridgeColor(ci.BridgeIdx)169 visual.bold = ci.BridgeCount == 2170 }171172 return visual173}174175func renderCellVisual(visual cellVisual) string {176 if visual.pill {177 sideWidth := max((cellWidth-islandPillWidth)/2, 0)178 left := lipgloss.NewStyle().Width(sideWidth).Background(visual.outerBG).Render("")179 right := lipgloss.NewStyle().Width(cellWidth - islandPillWidth - sideWidth).Background(visual.outerBG).Render("")180 center := lipgloss.NewStyle().181 Width(islandPillWidth).182 AlignHorizontal(lipgloss.Center).183 Foreground(visual.fg).184 Background(visual.bg)185 if visual.bold {186 center = center.Bold(true)187 }188 return left + center.Render(visual.text) + right189 }190 return baseCellStyle(visual.fg, visual.bg, visual.bold).Render(visual.text)191}192193func gridView(m Model, solved bool) string {194 return game.RenderDynamicGrid(game.DynamicGridSpec{195 Width: m.puzzle.Width,196 Height: m.puzzle.Height,197 CellWidth: cellWidth,198 Solved: solved,199 Cell: func(x, y int) string {200 return renderCellVisual(resolveCellVisual(m, x, y, solved))201 },202 ZoneAt: func(_, _ int) int {203 return 0204 },205 HasVerticalEdge: func(x, _ int) bool {206 return x <= 0 || x >= m.puzzle.Width207 },208 HasHorizontalEdge: func(_, y int) bool {209 return y <= 0 || y >= m.puzzle.Height210 },211 BridgeForeground: func(bridge game.DynamicGridBridge) color.Color {212 return bridgeForeground(m, bridge)213 },214 BridgeBold: func(bridge game.DynamicGridBridge) bool {215 return bridgeBold(m, bridge)216 },217 VerticalBridgeText: func(x, y int) string {218 return horizontalSeparatorBridge(m, x, y)219 },220 HorizontalBridgeText: func(x, y int) string {221 return verticalSeparatorBridge(m, x, y)222 },223 })224}225226func horizontalBridgeGlyph(count int) string {227 if count == 2 {228 return "═════"229 }230 return "━━━━━"231}232233func verticalBridgeGlyph(count int) string {234 if count == 2 {235 return " ║ "236 }237 return " ┃ "238}239240func horizontalSeparatorBridge(m Model, x, y int) string {241 count := horizontalBridgeCountAt(m.puzzle, x, y)242 if count == 0 {243 return ""244 }245 if count == 2 {246 return "═"247 }248 return "━"249}250251func verticalSeparatorBridge(m Model, x, y int) string {252 count := verticalBridgeCountAt(m.puzzle, x, y)253 if count == 0 {254 return ""255 }256 if count == 2 {257 return " ║ "258 }259 return " ┃ "260}261262func horizontalBridgeCountAt(p Puzzle, x, y int) int {263 if x <= 0 || x >= p.Width || y < 0 || y >= p.Height {264 return 0265 }266267 left := p.CellContent(x-1, y)268 right := p.CellContent(x, y)269270 switch {271 case left.Kind == cellBridgeH:272 return left.BridgeCount273 case right.Kind == cellBridgeH:274 return right.BridgeCount275 case left.Kind == cellIsland && right.Kind == cellIsland:276 if b := p.GetBridge(left.IslandID, right.IslandID); b != nil {277 return b.Count278 }279 }280281 return 0282}283284func verticalBridgeCountAt(p Puzzle, x, y int) int {285 if y <= 0 || y >= p.Height || x < 0 || x >= p.Width {286 return 0287 }288289 top := p.CellContent(x, y-1)290 bottom := p.CellContent(x, y)291292 switch {293 case top.Kind == cellBridgeV:294 return top.BridgeCount295 case bottom.Kind == cellBridgeV:296 return bottom.BridgeCount297 case top.Kind == cellIsland && bottom.Kind == cellIsland:298 if b := p.GetBridge(top.IslandID, bottom.IslandID); b != nil {299 return b.Count300 }301 }302303 return 0304}305306func bridgeForeground(m Model, bridge game.DynamicGridBridge) color.Color {307 return bridgeColor(bridgeIndexForDynamicBridge(&m.puzzle, bridge))308}309310func bridgeBold(m Model, bridge game.DynamicGridBridge) bool {311 return bridgeCountForDynamicBridge(&m.puzzle, bridge) == 2312}313314func bridgeIndexForDynamicBridge(p *Puzzle, bridge game.DynamicGridBridge) int {315 _, index := dynamicBridgeIdentity(p, bridge)316 return index317}318319func bridgeCountForDynamicBridge(p *Puzzle, bridge game.DynamicGridBridge) int {320 count, _ := dynamicBridgeIdentity(p, bridge)321 return count322}323324func dynamicBridgeIdentity(p *Puzzle, bridge game.DynamicGridBridge) (int, int) {325 for i := 0; i < bridge.Count; i++ {326 cell := p.CellContent(bridge.Cells[i].X, bridge.Cells[i].Y)327 switch cell.Kind {328 case cellBridgeH, cellBridgeV:329 return cell.BridgeCount, cell.BridgeIdx330 }331 }332333 ids := make([]int, 0, 2)334 for i := 0; i < bridge.Count; i++ {335 cell := p.CellContent(bridge.Cells[i].X, bridge.Cells[i].Y)336 if cell.Kind != cellIsland {337 continue338 }339 if !slices.Contains(ids, cell.IslandID) {340 ids = append(ids, cell.IslandID)341 }342 }343344 if len(ids) == 2 {345 if p.bridgeIndex == nil {346 p.buildBridgeIndex()347 }348 if idx, ok := p.bridgeIndex[bridgeKey(ids[0], ids[1])]; ok {349 if idx >= 0 && idx < len(p.Bridges) {350 return p.Bridges[idx].Count, idx351 }352 return 0, idx353 }354 }355356 return 0, -1357}358359func statusBarView(selected, showFullHelp bool) string {360 if selected {361 if showFullHelp {362 return game.StatusBarStyle().Render("arrows/wasd: build bridge enter/space: deselect esc: menu ctrl+r: reset ctrl+h: help")363 }364 return game.StatusBarStyle().Render("bridge mode arrows/wasd: build enter/space: deselect")365 }366 if showFullHelp {367 return game.StatusBarStyle().Render("arrows/wasd: move enter/space: select island esc: menu ctrl+r: reset ctrl+h: help")368 }369 return game.StatusBarStyle().Render("arrows/wasd: move enter/space: select island")370}371372func statusBarVariants() []string {373 return []string{374 statusBarView(false, false),375 statusBarView(false, true),376 statusBarView(true, false),377 statusBarView(true, true),378 game.StatusBarStyle().Render("arrows/wasd: build bridge\nenter/space: deselect"),379 }380}381382func infoView(p *Puzzle) string {383 pal := theme.Current()384385 satisfied := 0386 total := len(p.Islands)387 for _, isl := range p.Islands {388 if p.BridgeCount(isl.ID) == isl.Required {389 satisfied++390 }391 }392393 satisfiedStyle := lipgloss.NewStyle().Foreground(pal.SuccessBorder)394 infoStyle := lipgloss.NewStyle().Foreground(pal.Info)395396 var sb strings.Builder397 sb.WriteString(infoStyle.Render("Islands: "))398 sb.WriteString(satisfiedStyle.Render(fmt.Sprintf("%d", satisfied)))399 sb.WriteString(infoStyle.Render(fmt.Sprintf("/%d satisfied", total)))400 sb.WriteString("\n")401 sb.WriteString(infoStyle.Render(fmt.Sprintf("Bridges: %d", len(p.Bridges))))402 return sb.String()403}