- commit
- e3f4b9470ff02a37547a20e16ee0cd0727232857
- parent
- 3858d736b8e08493265601f0178ab2b5cb03a0da
- author
- Dami <dami@Damis-MacBook-Air.local>
| M | core.go | 4 | + |
| A | hashiwokakero/ | 29 | +++ |
| A | hashiwokakero/ | 34 | +++ |
| A | hashiwokakero/ | 187 | +++++++++++++++++ |
| A | hashiwokakero/ | 321 | ++++++++++++++++++++++++++++++ |
| A | hashiwokakero/ | 268 | +++++++++++++++++++++++++ |
| A | hashiwokakero/ | 48 | ++++ |
| A | hashiwokakero/ | 237 | ++++++++++++++++++++++ |
| M | model.go | 5 | + |
@@ -4,6 +4,7 @@ import (
44 "fmt"55 66 "github.com/FelineStateMachine/puzzletea/game"7+ "github.com/FelineStateMachine/puzzletea/hashiwokakero"78 "github.com/FelineStateMachine/puzzletea/sudoku"89 910 // Implementations of the Gamer interface should interface here.@@ -21,6 +22,9 @@ func (m model) SpawnGame(mode game.Mode) (game.Gamer, error) {
2122 case wordsearch.WordSearchMode:2223 grid, words := wordsearch.GenerateWordSearch(mode.Width, mode.Height, mode.WordCount, mode.MinWordLen, mode.MaxWordLen, mode.AllowedDirs)2324 return wordsearch.New(mode, grid, words), nil25+ case hashiwokakero.HashiMode:26+ puzzle := hashiwokakero.GeneratePuzzle(mode)27+ return hashiwokakero.New(mode, puzzle), nil2428 default:2529 return nil, fmt.Errorf("Faled to create game.\nUnimplemented game type `%v`.", mode)2630 }@@ -0,0 +1,29 @@
1+package hashiwokakero2+3+import (4+ "encoding/json"5+ "fmt"6+)7+8+type Save struct {9+ Solved bool `json:"solved"`10+ Width int `json:"width"`11+ Height int `json:"height"`12+ Islands []Island `json:"islands"`13+ Bridges []Bridge `json:"bridges"`14+}15+16+func (m Model) GetSave() ([]byte, error) {17+ save := Save{18+ Solved: m.puzzle.IsSolved(),19+ Width: m.puzzle.Width,20+ Height: m.puzzle.Height,21+ Islands: m.puzzle.Islands,22+ Bridges: m.puzzle.Bridges,23+ }24+ jsonData, err := json.Marshal(save)25+ if err != nil {26+ return nil, fmt.Errorf("unable to marshal save data: %v", err)27+ }28+ return jsonData, nil29+}@@ -0,0 +1,34 @@
1+package hashiwokakero2+3+import (4+ "github.com/FelineStateMachine/puzzletea/game"5+)6+7+// HashiMode defines a hashiwokakero difficulty/configuration.8+type HashiMode struct {9+ title string10+ description string11+ Width int12+ Height int13+ MinIslands int14+ MaxIslands int15+}16+17+var _ game.Mode = HashiMode{} // compile-time interface check18+19+func (h HashiMode) Title() string { return "hashiwokakero\t" + h.title }20+func (h HashiMode) Description() string { return h.description }21+func (h HashiMode) FilterValue() string {22+ return "hashiwokakero bridges hashi " + h.title + " " + h.description23+}24+25+func NewMode(title, description string, width, height, minIslands, maxIslands int) HashiMode {26+ return HashiMode{27+ title: title,28+ description: description,29+ Width: width,30+ Height: height,31+ MinIslands: minIslands,32+ MaxIslands: maxIslands,33+ }34+}@@ -0,0 +1,187 @@
1+package hashiwokakero2+3+import (4+ "fmt"5+6+ "github.com/FelineStateMachine/puzzletea/game"7+ "github.com/charmbracelet/bubbles/key"8+ tea "github.com/charmbracelet/bubbletea"9+ "github.com/charmbracelet/lipgloss"10+)11+12+type Model struct {13+ puzzle Puzzle14+ cursorIsland int // ID of island the cursor is on15+ selectedIsland *int // ID of island selected for bridge building, nil if none16+ keys KeyMap17+ modeName string18+}19+20+func New(mode HashiMode, puzzle Puzzle) game.Gamer {21+ cursorID := 022+ if len(puzzle.Islands) > 0 {23+ cursorID = puzzle.Islands[0].ID24+ }25+ return Model{26+ puzzle: puzzle,27+ cursorIsland: cursorID,28+ keys: DefaultKeyMap,29+ modeName: mode.title,30+ }31+}32+33+func (m Model) Init() tea.Cmd {34+ return nil35+}36+37+func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {38+ switch msg := msg.(type) {39+ case tea.KeyMsg:40+ if m.selectedIsland != nil {41+ m = m.handleBridgeMode(msg)42+ } else {43+ m = m.handleNavMode(msg)44+ }45+ }46+ return m, nil47+}48+49+func (m Model) handleNavMode(msg tea.KeyMsg) Model {50+ switch {51+ case key.Matches(msg, m.keys.Up):52+ m = m.moveCursor(0, -1)53+ case key.Matches(msg, m.keys.Down):54+ m = m.moveCursor(0, 1)55+ case key.Matches(msg, m.keys.Left):56+ m = m.moveCursor(-1, 0)57+ case key.Matches(msg, m.keys.Right):58+ m = m.moveCursor(1, 0)59+ case key.Matches(msg, m.keys.Select):60+ id := m.cursorIsland61+ m.selectedIsland = &id62+ }63+ return m64+}65+66+func (m Model) handleBridgeMode(msg tea.KeyMsg) Model {67+ switch {68+ case key.Matches(msg, m.keys.Up):69+ m = m.cycleBridge(0, -1)70+ case key.Matches(msg, m.keys.Down):71+ m = m.cycleBridge(0, 1)72+ case key.Matches(msg, m.keys.Left):73+ m = m.cycleBridge(-1, 0)74+ case key.Matches(msg, m.keys.Right):75+ m = m.cycleBridge(1, 0)76+ case key.Matches(msg, m.keys.Select), key.Matches(msg, m.keys.Cancel):77+ m.selectedIsland = nil78+ }79+ return m80+}81+82+// moveCursor moves the cursor to the nearest island in direction (dx, dy).83+func (m Model) moveCursor(dx, dy int) Model {84+ adj := m.puzzle.FindAdjacentIsland(m.cursorIsland, dx, dy)85+ if adj != nil {86+ m.cursorIsland = adj.ID87+ }88+ return m89+}90+91+// cycleBridge cycles the bridge from the selected island toward (dx, dy): 0→1→2→0.92+func (m Model) cycleBridge(dx, dy int) Model {93+ if m.selectedIsland == nil {94+ return m95+ }96+97+ adj := m.puzzle.FindAdjacentIsland(*m.selectedIsland, dx, dy)98+ if adj == nil {99+ return m100+ }101+102+ existing := m.puzzle.GetBridge(*m.selectedIsland, adj.ID)103+ currentCount := 0104+ if existing != nil {105+ currentCount = existing.Count106+ }107+108+ newCount := (currentCount + 1) % 3109+110+ if newCount > 0 && currentCount == 0 {111+ // Adding a new bridge — check for crossings112+ if m.puzzle.WouldCross(*m.selectedIsland, adj.ID) {113+ return m114+ }115+ }116+117+ m.puzzle.SetBridge(*m.selectedIsland, adj.ID, newCount)118+ return m119+}120+121+func (m Model) View() string {122+ solved := m.puzzle.IsSolved()123+124+ title := titleBarView(m.modeName, solved)125+ grid := gridView(m)126+ info := infoView(&m.puzzle)127+ status := statusBarView(m.selectedIsland != nil)128+129+ return lipgloss.JoinVertical(lipgloss.Left, title, grid, info, status)130+}131+132+func (m Model) GetDebugInfo() string {133+ solved := m.puzzle.IsSolved()134+ connected := m.puzzle.IsConnected()135+136+ status := "In Progress"137+ if solved {138+ status = "Solved"139+ }140+141+ connStr := "No"142+ if connected {143+ connStr = "Yes"144+ }145+146+ s := fmt.Sprintf(147+ "# Hashiwokakero\n\n"+148+ "## Game State\n\n"+149+ "| Property | Value |\n"+150+ "| :--- | :--- |\n"+151+ "| Status | %s |\n"+152+ "| Grid Size | %dx%d |\n"+153+ "| Islands | %d |\n"+154+ "| Bridges | %d |\n"+155+ "| Connected | %s |\n"+156+ "| Cursor Island | %d |\n",157+ status,158+ m.puzzle.Width, m.puzzle.Height,159+ len(m.puzzle.Islands),160+ len(m.puzzle.Bridges),161+ connStr,162+ m.cursorIsland,163+ )164+165+ selectedStr := "None"166+ if m.selectedIsland != nil {167+ selectedStr = fmt.Sprintf("%d", *m.selectedIsland)168+ }169+ s += fmt.Sprintf("| Selected Island | %s |\n", selectedStr)170+171+ s += "\n## Islands\n\n"172+ s += "| ID | Pos | Required | Current | Status |\n"173+ s += "| :--- | :--- | :--- | :--- | :--- |\n"174+ for _, isl := range m.puzzle.Islands {175+ current := m.puzzle.BridgeCount(isl.ID)176+ islStatus := "Unsatisfied"177+ if current == isl.Required {178+ islStatus = "Satisfied"179+ } else if current > isl.Required {180+ islStatus = "Over"181+ }182+ s += fmt.Sprintf("| %d | (%d,%d) | %d | %d | %s |\n",183+ isl.ID, isl.X, isl.Y, isl.Required, current, islStatus)184+ }185+186+ return s187+}@@ -0,0 +1,321 @@
1+package hashiwokakero2+3+import (4+ "math/rand"5+ "sort"6+)7+8+// GeneratePuzzle creates a solvable hashiwokakero puzzle for the given mode.9+func GeneratePuzzle(mode HashiMode) Puzzle {10+ islandCount := mode.MinIslands + rand.Intn(mode.MaxIslands-mode.MinIslands+1)11+12+ for {13+ p := tryGenerate(mode.Width, mode.Height, islandCount)14+ if p != nil {15+ return *p16+ }17+ }18+}19+20+func tryGenerate(width, height, islandCount int) *Puzzle {21+ islands := placeIslands(width, height, islandCount)22+ if islands == nil {23+ return nil24+ }25+26+ p := &Puzzle{27+ Width: width,28+ Height: height,29+ Islands: islands,30+ }31+32+ // Build a spanning tree to ensure connectivity33+ if !buildSpanningTree(p) {34+ return nil35+ }36+37+ // Add extra bridges for complexity38+ addExtraBridges(p)39+40+ // Set required counts from the solution41+ for i := range p.Islands {42+ p.Islands[i].Required = p.BridgeCount(p.Islands[i].ID)43+ }44+45+ // Verify all islands have Required >= 146+ for _, isl := range p.Islands {47+ if isl.Required < 1 {48+ return nil49+ }50+ }51+52+ // Clear bridges — player starts with none53+ p.Bridges = nil54+55+ return p56+}57+58+// placeIslands randomly places islands on the grid.59+// Islands must share rows or columns with at least one other island to allow bridges.60+func placeIslands(width, height, count int) []Island {61+ if count <= 0 {62+ return nil63+ }64+65+ type pos struct{ x, y int }66+ occupied := make(map[pos]bool)67+ islands := make([]Island, 0, count)68+69+ for i := 0; i < count; i++ {70+ placed := false71+ for attempts := 0; attempts < 200; attempts++ {72+ x := rand.Intn(width)73+ y := rand.Intn(height)74+ p := pos{x, y}75+76+ if occupied[p] {77+ continue78+ }79+80+ // Ensure minimum spacing of 2 cells from existing islands81+ // (unless they share a row/column, which is required for bridges)82+ tooClose := false83+ for _, other := range islands {84+ dx := abs(x - other.X)85+ dy := abs(y - other.Y)86+ // Adjacent diagonal — too close and can't bridge87+ if dx <= 1 && dy <= 1 && !(dx == 0 || dy == 0) {88+ tooClose = true89+ break90+ }91+ // Same position as existing (already checked by occupied map)92+ }93+ if tooClose {94+ continue95+ }96+97+ occupied[p] = true98+ islands = append(islands, Island{ID: i, X: x, Y: y})99+ placed = true100+ break101+ }102+ if !placed {103+ return nil // couldn't place enough islands104+ }105+ }106+107+ return islands108+}109+110+// buildSpanningTree connects all islands using a randomized approach.111+// Returns false if unable to connect all islands.112+func buildSpanningTree(p *Puzzle) bool {113+ if len(p.Islands) <= 1 {114+ return true115+ }116+117+ // Find all valid pairs (share row or column, no island in between)118+ type pair struct{ id1, id2 int }119+ var pairs []pair120+121+ for i := 0; i < len(p.Islands); i++ {122+ for j := i + 1; j < len(p.Islands); j++ {123+ a := p.Islands[i]124+ b := p.Islands[j]125+126+ if a.X != b.X && a.Y != b.Y {127+ continue // not aligned128+ }129+130+ // Check no island in between131+ if isDirectlyConnectable(p, a, b) {132+ pairs = append(pairs, pair{a.ID, b.ID})133+ }134+ }135+ }136+137+ // Shuffle pairs for randomness138+ rand.Shuffle(len(pairs), func(i, j int) {139+ pairs[i], pairs[j] = pairs[j], pairs[i]140+ })141+142+ // Union-Find to build spanning tree143+ parent := make(map[int]int)144+ rank := make(map[int]int)145+ for _, isl := range p.Islands {146+ parent[isl.ID] = isl.ID147+ }148+149+ var find func(int) int150+ find = func(x int) int {151+ if parent[x] != x {152+ parent[x] = find(parent[x])153+ }154+ return parent[x]155+ }156+ union := func(a, b int) bool {157+ ra, rb := find(a), find(b)158+ if ra == rb {159+ return false160+ }161+ if rank[ra] < rank[rb] {162+ ra, rb = rb, ra163+ }164+ parent[rb] = ra165+ if rank[ra] == rank[rb] {166+ rank[ra]++167+ }168+ return true169+ }170+171+ edgesAdded := 0172+ for _, pr := range pairs {173+ if find(pr.id1) == find(pr.id2) {174+ continue175+ }176+ // Check if this bridge would cross existing ones177+ if p.WouldCross(pr.id1, pr.id2) {178+ continue179+ }180+ count := 1 + rand.Intn(2) // 1 or 2 bridges181+ p.SetBridge(pr.id1, pr.id2, count)182+ union(pr.id1, pr.id2)183+ edgesAdded++184+ if edgesAdded == len(p.Islands)-1 {185+ break186+ }187+ }188+189+ return p.IsConnected()190+}191+192+// addExtraBridges adds additional bridges beyond the spanning tree for complexity.193+func addExtraBridges(p *Puzzle) {194+ var pairs []struct{ id1, id2 int }195+196+ for i := 0; i < len(p.Islands); i++ {197+ for j := i + 1; j < len(p.Islands); j++ {198+ a := p.Islands[i]199+ b := p.Islands[j]200+201+ if a.X != b.X && a.Y != b.Y {202+ continue203+ }204+205+ if !isDirectlyConnectable(p, a, b) {206+ continue207+ }208+209+ existing := p.GetBridge(a.ID, b.ID)210+ if existing != nil && existing.Count >= 2 {211+ continue // already maxed212+ }213+214+ pairs = append(pairs, struct{ id1, id2 int }{a.ID, b.ID})215+ }216+ }217+218+ rand.Shuffle(len(pairs), func(i, j int) {219+ pairs[i], pairs[j] = pairs[j], pairs[i]220+ })221+222+ // Add bridges to ~30% of eligible pairs223+ limit := len(pairs) / 3224+ added := 0225+ for _, pr := range pairs {226+ if added >= limit {227+ break228+ }229+230+ existing := p.GetBridge(pr.id1, pr.id2)231+ if existing != nil && existing.Count >= 2 {232+ continue233+ }234+235+ if existing == nil && p.WouldCross(pr.id1, pr.id2) {236+ continue237+ }238+239+ newCount := 1240+ if existing != nil {241+ newCount = existing.Count + 1242+ }243+244+ // Don't let any island exceed 8 bridges245+ isl1 := p.FindIslandByID(pr.id1)246+ isl2 := p.FindIslandByID(pr.id2)247+ addAmount := newCount248+ if existing != nil {249+ addAmount = newCount - existing.Count250+ }251+ if p.BridgeCount(pr.id1)+addAmount > 8 || p.BridgeCount(pr.id2)+addAmount > 8 {252+ continue253+ }254+ _ = isl1255+ _ = isl2256+257+ p.SetBridge(pr.id1, pr.id2, newCount)258+ added++259+ }260+}261+262+// isDirectlyConnectable checks if two aligned islands can be connected263+// (no other island sits between them).264+func isDirectlyConnectable(p *Puzzle, a, b Island) bool {265+ if a.X == b.X {266+ // Vertical alignment267+ minY, maxY := a.Y, b.Y268+ if minY > maxY {269+ minY, maxY = maxY, minY270+ }271+ for _, isl := range p.Islands {272+ if isl.X == a.X && isl.Y > minY && isl.Y < maxY {273+ return false274+ }275+ }276+ return true277+ }278+ if a.Y == b.Y {279+ // Horizontal alignment280+ minX, maxX := a.X, b.X281+ if minX > maxX {282+ minX, maxX = maxX, minX283+ }284+ for _, isl := range p.Islands {285+ if isl.Y == a.Y && isl.X > minX && isl.X < maxX {286+ return false287+ }288+ }289+ return true290+ }291+ return false292+}293+294+// abs returns the absolute value of an integer.295+func abs(x int) int {296+ if x < 0 {297+ return -x298+ }299+ return x300+}301+302+// sortedPair returns (a,b) with a <= b.303+func sortedPair(a, b int) (int, int) {304+ if a > b {305+ return b, a306+ }307+ return a, b308+}309+310+// islandsByRow sorts islands by Y then X for consistent ordering.311+func islandsByRow(islands []Island) []Island {312+ sorted := make([]Island, len(islands))313+ copy(sorted, islands)314+ sort.Slice(sorted, func(i, j int) bool {315+ if sorted[i].Y != sorted[j].Y {316+ return sorted[i].Y < sorted[j].Y317+ }318+ return sorted[i].X < sorted[j].X319+ })320+ return sorted321+}@@ -0,0 +1,268 @@
1+package hashiwokakero2+3+// Island represents a numbered node on the grid.4+type Island struct {5+ ID int6+ X, Y int7+ Required int // target bridge count (1-8)8+}9+10+// Bridge represents a connection between two islands.11+type Bridge struct {12+ Island1, Island2 int // island IDs (Island1 < Island2)13+ Count int // 1 or 214+}15+16+// cellKind describes what occupies a grid cell.17+type cellKind int18+19+const (20+ cellEmpty cellKind = iota21+ cellIsland22+ cellBridgeH // horizontal bridge segment23+ cellBridgeV // vertical bridge segment24+)25+26+// cellInfo is the rendering info for a single grid cell.27+type cellInfo struct {28+ Kind cellKind29+ IslandID int // valid when Kind == cellIsland30+ BridgeIdx int // index into Puzzle.Bridges, valid for bridge cells31+ BridgeCount int // 1 or 232+}33+34+// Puzzle holds the full game state.35+type Puzzle struct {36+ Width, Height int37+ Islands []Island38+ Bridges []Bridge39+}40+41+// FindIslandAt returns the island at (x,y), or nil.42+func (p *Puzzle) FindIslandAt(x, y int) *Island {43+ for i := range p.Islands {44+ if p.Islands[i].X == x && p.Islands[i].Y == y {45+ return &p.Islands[i]46+ }47+ }48+ return nil49+}50+51+// FindIslandByID returns the island with the given ID, or nil.52+func (p *Puzzle) FindIslandByID(id int) *Island {53+ for i := range p.Islands {54+ if p.Islands[i].ID == id {55+ return &p.Islands[i]56+ }57+ }58+ return nil59+}60+61+// GetBridge returns the bridge between two islands (by ID), or nil.62+func (p *Puzzle) GetBridge(id1, id2 int) *Bridge {63+ a, b := id1, id264+ if a > b {65+ a, b = b, a66+ }67+ for i := range p.Bridges {68+ if p.Bridges[i].Island1 == a && p.Bridges[i].Island2 == b {69+ return &p.Bridges[i]70+ }71+ }72+ return nil73+}74+75+// BridgeCount returns the total number of bridges connected to an island.76+func (p *Puzzle) BridgeCount(islandID int) int {77+ count := 078+ for _, b := range p.Bridges {79+ if b.Island1 == islandID || b.Island2 == islandID {80+ count += b.Count81+ }82+ }83+ return count84+}85+86+// FindAdjacentIsland casts a ray from the island at fromID in direction (dx,dy)87+// and returns the first island hit, or nil. dx/dy must be (0,1),(0,-1),(1,0),(-1,0).88+// Stops if a perpendicular bridge crosses the path.89+func (p *Puzzle) FindAdjacentIsland(fromID, dx, dy int) *Island {90+ from := p.FindIslandByID(fromID)91+ if from == nil {92+ return nil93+ }94+ x, y := from.X+dx, from.Y+dy95+ for x >= 0 && x < p.Width && y >= 0 && y < p.Height {96+ // Check for island at this position97+ if isl := p.FindIslandAt(x, y); isl != nil {98+ return isl99+ }100+ // Check if a perpendicular bridge crosses this cell101+ ci := p.CellContent(x, y)102+ if dx == 0 && ci.Kind == cellBridgeH {103+ return nil // horizontal bridge blocks vertical ray104+ }105+ if dy == 0 && ci.Kind == cellBridgeV {106+ return nil // vertical bridge blocks horizontal ray107+ }108+ x += dx109+ y += dy110+ }111+ return nil112+}113+114+// WouldCross checks if adding a bridge between id1 and id2 would cross any existing bridge.115+func (p *Puzzle) WouldCross(id1, id2 int) bool {116+ isl1 := p.FindIslandByID(id1)117+ isl2 := p.FindIslandByID(id2)118+ if isl1 == nil || isl2 == nil {119+ return true120+ }121+122+ // Determine direction of new bridge123+ horizontal := isl1.Y == isl2.Y124+ vertical := isl1.X == isl2.X125+ if !horizontal && !vertical {126+ return true // not a valid bridge direction127+ }128+129+ if horizontal {130+ y := isl1.Y131+ minX, maxX := isl1.X, isl2.X132+ if minX > maxX {133+ minX, maxX = maxX, minX134+ }135+ for x := minX + 1; x < maxX; x++ {136+ ci := p.CellContent(x, y)137+ if ci.Kind == cellIsland || ci.Kind == cellBridgeV {138+ return true139+ }140+ }141+ } else {142+ x := isl1.X143+ minY, maxY := isl1.Y, isl2.Y144+ if minY > maxY {145+ minY, maxY = maxY, minY146+ }147+ for y := minY + 1; y < maxY; y++ {148+ ci := p.CellContent(x, y)149+ if ci.Kind == cellIsland || ci.Kind == cellBridgeH {150+ return true151+ }152+ }153+ }154+ return false155+}156+157+// SetBridge sets or removes the bridge between two islands.158+// count=0 removes the bridge. count must be 0, 1, or 2.159+func (p *Puzzle) SetBridge(id1, id2, count int) {160+ a, b := id1, id2161+ if a > b {162+ a, b = b, a163+ }164+165+ if count == 0 {166+ // Remove bridge167+ for i := range p.Bridges {168+ if p.Bridges[i].Island1 == a && p.Bridges[i].Island2 == b {169+ p.Bridges = append(p.Bridges[:i], p.Bridges[i+1:]...)170+ return171+ }172+ }173+ return174+ }175+176+ // Update or create bridge177+ for i := range p.Bridges {178+ if p.Bridges[i].Island1 == a && p.Bridges[i].Island2 == b {179+ p.Bridges[i].Count = count180+ return181+ }182+ }183+ p.Bridges = append(p.Bridges, Bridge{Island1: a, Island2: b, Count: count})184+}185+186+// CellContent returns what occupies the given grid cell.187+func (p *Puzzle) CellContent(x, y int) cellInfo {188+ // Check islands first189+ for _, isl := range p.Islands {190+ if isl.X == x && isl.Y == y {191+ return cellInfo{Kind: cellIsland, IslandID: isl.ID}192+ }193+ }194+195+ // Check bridges196+ for i, b := range p.Bridges {197+ isl1 := p.FindIslandByID(b.Island1)198+ isl2 := p.FindIslandByID(b.Island2)199+ if isl1 == nil || isl2 == nil {200+ continue201+ }202+203+ if isl1.Y == isl2.Y && y == isl1.Y {204+ // Horizontal bridge205+ minX, maxX := isl1.X, isl2.X206+ if minX > maxX {207+ minX, maxX = maxX, minX208+ }209+ if x > minX && x < maxX {210+ return cellInfo{Kind: cellBridgeH, BridgeIdx: i, BridgeCount: b.Count}211+ }212+ } else if isl1.X == isl2.X && x == isl1.X {213+ // Vertical bridge214+ minY, maxY := isl1.Y, isl2.Y215+ if minY > maxY {216+ minY, maxY = maxY, minY217+ }218+ if y > minY && y < maxY {219+ return cellInfo{Kind: cellBridgeV, BridgeIdx: i, BridgeCount: b.Count}220+ }221+ }222+ }223+224+ return cellInfo{Kind: cellEmpty}225+}226+227+// IsConnected checks if all islands form a single connected component via bridges.228+func (p *Puzzle) IsConnected() bool {229+ if len(p.Islands) <= 1 {230+ return true231+ }232+ if len(p.Bridges) == 0 {233+ return false234+ }235+236+ visited := make(map[int]bool)237+ queue := []int{p.Islands[0].ID}238+239+ for len(queue) > 0 {240+ cur := queue[0]241+ queue = queue[1:]242+ if visited[cur] {243+ continue244+ }245+ visited[cur] = true246+247+ for _, b := range p.Bridges {248+ if b.Island1 == cur && !visited[b.Island2] {249+ queue = append(queue, b.Island2)250+ }251+ if b.Island2 == cur && !visited[b.Island1] {252+ queue = append(queue, b.Island1)253+ }254+ }255+ }256+257+ return len(visited) == len(p.Islands)258+}259+260+// IsSolved returns true if the puzzle is completely and correctly solved.261+func (p *Puzzle) IsSolved() bool {262+ for _, isl := range p.Islands {263+ if p.BridgeCount(isl.ID) != isl.Required {264+ return false265+ }266+ }267+ return p.IsConnected()268+}@@ -0,0 +1,48 @@
1+package hashiwokakero2+3+import (4+ "github.com/charmbracelet/bubbles/key"5+)6+7+type KeyMap struct {8+ Up key.Binding9+ Down key.Binding10+ Left key.Binding11+ Right key.Binding12+ Select key.Binding13+ Cancel key.Binding14+}15+16+var DefaultKeyMap = KeyMap{17+ Up: key.NewBinding(18+ key.WithKeys("k", "up", "w"),19+ key.WithHelp("↑/w/k", "Up"),20+ ),21+ Down: key.NewBinding(22+ key.WithKeys("j", "down", "s"),23+ key.WithHelp("↓/s/j", "Down"),24+ ),25+ Left: key.NewBinding(26+ key.WithKeys("h", "left", "a"),27+ key.WithHelp("←/a/h", "Left"),28+ ),29+ Right: key.NewBinding(30+ key.WithKeys("l", "right", "d"),31+ key.WithHelp("→/d/l", "Right"),32+ ),33+ Select: key.NewBinding(34+ key.WithKeys("enter", " "),35+ key.WithHelp("enter/space", "Select"),36+ ),37+ Cancel: key.NewBinding(38+ key.WithKeys("esc"),39+ key.WithHelp("esc", "Cancel"),40+ ),41+}42+43+func (m Model) GetFullHelp() [][]key.Binding {44+ return [][]key.Binding{45+ {m.keys.Up, m.keys.Down, m.keys.Left, m.keys.Right},46+ {m.keys.Select, m.keys.Cancel},47+ }48+}@@ -0,0 +1,237 @@
1+package hashiwokakero2+3+import (4+ "fmt"5+ "strings"6+7+ "github.com/charmbracelet/lipgloss"8+)9+10+const cellWidth = 311+12+var (13+ baseStyle = lipgloss.NewStyle()14+15+ islandDefaultStyle = baseStyle.16+ Foreground(lipgloss.Color("#ffffff")).17+ Background(lipgloss.Color("#4a4a4a")).18+ Bold(true)19+20+ islandSatisfiedStyle = baseStyle.21+ Foreground(lipgloss.Color("#00ff00")).22+ Background(lipgloss.Color("#1a3a1a")).23+ Bold(true)24+25+ islandOverStyle = baseStyle.26+ Foreground(lipgloss.Color("#ff0000")).27+ Background(lipgloss.Color("#3a1a1a")).28+ Bold(true)29+30+ islandCursorStyle = baseStyle.31+ Foreground(lipgloss.Color("#ffffff")).32+ Background(lipgloss.Color("#ff00ff")).33+ Bold(true)34+35+ islandSelectedStyle = baseStyle.36+ Foreground(lipgloss.Color("#ffffff")).37+ Background(lipgloss.Color("#ff8800")).38+ Bold(true)39+40+ bridgeHStyle = baseStyle.41+ Foreground(lipgloss.Color("#666666"))42+43+ bridgeVStyle = baseStyle.44+ Foreground(lipgloss.Color("#666666"))45+46+ emptyStyle = baseStyle.47+ Foreground(lipgloss.Color("#333333"))48+49+ titleStyle = lipgloss.NewStyle().50+ Bold(true).51+ Foreground(lipgloss.Color("#ffffff")).52+ Background(lipgloss.Color("#7B2FBE")).53+ Padding(0, 1)54+55+ solvedBadgeStyle = lipgloss.NewStyle().56+ Bold(true).57+ Foreground(lipgloss.Color("#00ff00"))58+59+ statusBarStyle = lipgloss.NewStyle().60+ Foreground(lipgloss.Color("#888888")).61+ MarginTop(1)62+)63+64+// gridView renders the puzzle using a display grid with gap cells between65+// logical cells so that bridges between directly adjacent islands are visible.66+// Display grid is (2*width-1) x (2*height-1): even coords are logical cells,67+// odd coords are gap cells showing bridge connectors.68+func gridView(m Model) string {69+ solved := m.puzzle.IsSolved()70+ dispW := 2*m.puzzle.Width - 171+ dispH := 2*m.puzzle.Height - 172+73+ var rows []string74+ for dy := 0; dy < dispH; dy++ {75+ var cells []string76+ for dx := 0; dx < dispW; dx++ {77+ cells = append(cells, displayCellView(m, dx, dy, solved))78+ }79+ rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, cells...))80+ }81+82+ return lipgloss.JoinVertical(lipgloss.Left, rows...)83+}84+85+// displayCellView renders a single cell in the display grid.86+func displayCellView(m Model, dx, dy int, solved bool) string {87+ evenX := dx%2 == 088+ evenY := dy%2 == 089+90+ if evenX && evenY {91+ // Logical cell position92+ lx, ly := dx/2, dy/293+ ci := m.puzzle.CellContent(lx, ly)94+ switch ci.Kind {95+ case cellIsland:96+ return islandView(m, ci.IslandID, solved)97+ case cellBridgeH:98+ return bridgeHView(ci.BridgeCount)99+ case cellBridgeV:100+ return bridgeVView(ci.BridgeCount)101+ default:102+ return emptyStyle.Width(cellWidth).Render(" ")103+ }104+ }105+106+ if !evenX && evenY {107+ // Horizontal gap between (dx/2, dy/2) and (dx/2+1, dy/2)108+ lx, ly := dx/2, dy/2109+ return hGapView(m, lx, ly)110+ }111+112+ if evenX && !evenY {113+ // Vertical gap between (dx/2, dy/2) and (dx/2, dy/2+1)114+ lx, ly := dx/2, dy/2115+ return vGapView(m, lx, ly)116+ }117+118+ // Odd x and odd y — intersection of gaps, always empty119+ return emptyStyle.Width(cellWidth).Render(" ")120+}121+122+// hGapView renders the horizontal gap between logical cell (lx,ly) and (lx+1,ly).123+// Shows a bridge connector if both neighbors are islands connected by a bridge.124+func hGapView(m Model, lx, ly int) string {125+ left := m.puzzle.FindIslandAt(lx, ly)126+ right := m.puzzle.FindIslandAt(lx+1, ly)127+ if left != nil && right != nil {128+ if b := m.puzzle.GetBridge(left.ID, right.ID); b != nil {129+ return bridgeHView(b.Count)130+ }131+ }132+ // Also check if a bridge passes through this gap (non-adjacent islands)133+ ci := m.puzzle.CellContent(lx, ly)134+ if ci.Kind == cellBridgeH {135+ return bridgeHView(ci.BridgeCount)136+ }137+ ciR := m.puzzle.CellContent(lx+1, ly)138+ if ciR.Kind == cellBridgeH {139+ return bridgeHView(ciR.BridgeCount)140+ }141+ return emptyStyle.Width(cellWidth).Render(" ")142+}143+144+// vGapView renders the vertical gap between logical cell (lx,ly) and (lx,ly+1).145+// Shows a bridge connector if both neighbors are islands connected by a bridge.146+func vGapView(m Model, lx, ly int) string {147+ top := m.puzzle.FindIslandAt(lx, ly)148+ bottom := m.puzzle.FindIslandAt(lx, ly+1)149+ if top != nil && bottom != nil {150+ if b := m.puzzle.GetBridge(top.ID, bottom.ID); b != nil {151+ return bridgeVView(b.Count)152+ }153+ }154+ // Also check if a bridge passes through this gap (non-adjacent islands)155+ ci := m.puzzle.CellContent(lx, ly)156+ if ci.Kind == cellBridgeV {157+ return bridgeVView(ci.BridgeCount)158+ }159+ ciB := m.puzzle.CellContent(lx, ly+1)160+ if ciB.Kind == cellBridgeV {161+ return bridgeVView(ciB.BridgeCount)162+ }163+ return emptyStyle.Width(cellWidth).Render(" ")164+}165+166+func islandView(m Model, islandID int, solved bool) string {167+ isl := m.puzzle.FindIslandByID(islandID)168+ if isl == nil {169+ return emptyStyle.Width(cellWidth).Render(" ")170+ }171+172+ style := islandDefaultStyle173+ current := m.puzzle.BridgeCount(islandID)174+175+ if solved {176+ style = islandSatisfiedStyle177+ } else if m.selectedIsland != nil && *m.selectedIsland == islandID {178+ style = islandSelectedStyle179+ } else if m.cursorIsland == islandID {180+ style = islandCursorStyle181+ } else if current == isl.Required {182+ style = islandSatisfiedStyle183+ } else if current > isl.Required {184+ style = islandOverStyle185+ }186+187+ label := fmt.Sprintf("%d", isl.Required)188+ return style.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(label)189+}190+191+func bridgeHView(count int) string {192+ r := "───"193+ if count == 2 {194+ r = "═══"195+ }196+ return bridgeHStyle.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(r)197+}198+199+func bridgeVView(count int) string {200+ r := "│"201+ if count == 2 {202+ r = "║"203+ }204+ return bridgeVStyle.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(r)205+}206+207+func titleBarView(modeName string, solved bool) string {208+ title := titleStyle.Render("Hashiwokakero " + modeName)209+ if solved {210+ badge := solvedBadgeStyle.Render(" SOLVED")211+ return title + badge212+ }213+ return title214+}215+216+func statusBarView(selected bool) string {217+ if selected {218+ return statusBarStyle.Render("arrows/wasd: build bridge enter/space/esc: deselect ctrl+n: menu ctrl+e: debug")219+ }220+ return statusBarStyle.Render("arrows/wasd: move enter/space: select island ctrl+n: menu ctrl+e: debug")221+}222+223+func infoView(p *Puzzle) string {224+ satisfied := 0225+ total := len(p.Islands)226+ for _, isl := range p.Islands {227+ if p.BridgeCount(isl.ID) == isl.Required {228+ satisfied++229+ }230+ }231+232+ var sb strings.Builder233+ sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Render(234+ fmt.Sprintf("Islands: %d/%d satisfied Bridges: %d", satisfied, total, len(p.Bridges)),235+ ))236+ return sb.String()237+}@@ -4,6 +4,7 @@ import (
44 "fmt"55 66 "github.com/FelineStateMachine/puzzletea/game"7+ "github.com/FelineStateMachine/puzzletea/hashiwokakero"78 "github.com/FelineStateMachine/puzzletea/nonogram"89 "github.com/FelineStateMachine/puzzletea/sudoku"910 "github.com/FelineStateMachine/puzzletea/wordsearch"@@ -29,6 +30,10 @@ var (
2930 3031 sudoku.NewMode("Easy - 38 Provided Cells", "A random sudoku with at least 38 cells provided to start.", 38),3132 sudoku.NewMode("Hard - 26 Provided Cells", "A random sudoku with at least 26 cells provided to start.", 26),33+34+ hashiwokakero.NewMode("Easy - 7x7", "Connect islands with bridges on a 7x7 grid.", 7, 7, 6, 8),35+ hashiwokakero.NewMode("Medium - 9x9", "Connect islands with bridges on a 9x9 grid.", 9, 9, 10, 14),36+ hashiwokakero.NewMode("Hard - 13x13", "Connect islands with bridges on a 13x13 grid.", 13, 13, 18, 24),3237 }3338 )3439