1
0
Fork 0
mirror of https://github.com/imjasonh/chessh synced 2026-07-07 00:33:53 +00:00

initial commit

Signed-off-by: Jason Hall <jason@chainguard.dev>
This commit is contained in:
Jason Hall 2025-09-12 19:41:59 -04:00
commit 57b0263707
4 changed files with 942 additions and 0 deletions

580
chess.go Normal file
View file

@ -0,0 +1,580 @@
package main
import "fmt"
type Color int
const (
White Color = iota
Black
)
func (c Color) String() string {
if c == White {
return "White"
}
return "Black"
}
type PieceType int
const (
Empty PieceType = iota
Pawn
Rook
Knight
Bishop
Queen
King
)
type Piece struct {
Type PieceType
Color Color
}
func (p Piece) String() string {
if p.Type == Empty {
return " "
}
symbols := map[PieceType]string{
Pawn: "♟",
Rook: "♜",
Knight: "♞",
Bishop: "♝",
Queen: "♛",
King: "♚",
}
if p.Color == White {
whiteSymbols := map[PieceType]string{
Pawn: "♙",
Rook: "♖",
Knight: "♘",
Bishop: "♗",
Queen: "♕",
King: "♔",
}
return whiteSymbols[p.Type]
}
return symbols[p.Type]
}
type Position struct {
Row, Col int
}
func (p Position) Valid() bool {
return p.Row >= 0 && p.Row < 8 && p.Col >= 0 && p.Col < 8
}
func (p Position) String() string {
if !p.Valid() {
return "invalid"
}
return fmt.Sprintf("%c%d", 'a'+p.Col, p.Row+1)
}
type Move struct {
From, To Position
Piece Piece
}
type Board [8][8]Piece
func NewBoard() Board {
var board Board
board[0] = [8]Piece{
{Rook, White}, {Knight, White}, {Bishop, White}, {Queen, White},
{King, White}, {Bishop, White}, {Knight, White}, {Rook, White},
}
board[1] = [8]Piece{
{Pawn, White}, {Pawn, White}, {Pawn, White}, {Pawn, White},
{Pawn, White}, {Pawn, White}, {Pawn, White}, {Pawn, White},
}
for col := 0; col < 8; col++ {
for row := 2; row < 6; row++ {
board[row][col] = Piece{Empty, White}
}
}
board[6] = [8]Piece{
{Pawn, Black}, {Pawn, Black}, {Pawn, Black}, {Pawn, Black},
{Pawn, Black}, {Pawn, Black}, {Pawn, Black}, {Pawn, Black},
}
board[7] = [8]Piece{
{Rook, Black}, {Knight, Black}, {Bishop, Black}, {Queen, Black},
{King, Black}, {Bishop, Black}, {Knight, Black}, {Rook, Black},
}
return board
}
func (b *Board) At(pos Position) Piece {
if !pos.Valid() {
return Piece{Empty, White}
}
return b[pos.Row][pos.Col]
}
func (b *Board) Set(pos Position, piece Piece) {
if pos.Valid() {
b[pos.Row][pos.Col] = piece
}
}
func (b *Board) Move(from, to Position) bool {
if !from.Valid() || !to.Valid() {
return false
}
piece := b.At(from)
if piece.Type == Empty {
return false
}
b.Set(to, piece)
b.Set(from, Piece{Empty, White})
return true
}
type Game struct {
Board Board
CurrentTurn Color
MoveHistory []Move
KingMoved [2]bool
RookMoved [2][2]bool
EnPassantTarget *Position
}
func NewGame() *Game {
return &Game{
Board: NewBoard(),
CurrentTurn: White,
MoveHistory: make([]Move, 0),
KingMoved: [2]bool{false, false},
RookMoved: [2][2]bool{{false, false}, {false, false}},
EnPassantTarget: nil,
}
}
func (g *Game) IsValidMove(from, to Position) bool {
if !from.Valid() || !to.Valid() {
return false
}
piece := g.Board.At(from)
if piece.Type == Empty || piece.Color != g.CurrentTurn {
return false
}
target := g.Board.At(to)
if target.Type != Empty && target.Color == piece.Color {
return false
}
return g.isValidPieceMove(piece, from, to)
}
func (g *Game) isValidPieceMove(piece Piece, from, to Position) bool {
dx := to.Col - from.Col
dy := to.Row - from.Row
switch piece.Type {
case Pawn:
return g.isValidPawnMove(piece, from, to, dx, dy)
case Rook:
return g.isValidRookMove(from, to, dx, dy)
case Knight:
return g.isValidKnightMove(dx, dy)
case Bishop:
return g.isValidBishopMove(from, to, dx, dy)
case Queen:
return g.isValidQueenMove(from, to, dx, dy)
case King:
return g.isValidKingMove(piece, dx, dy)
}
return false
}
func (g *Game) isValidPawnMove(piece Piece, from, to Position, dx, dy int) bool {
direction := 1
startRow := 1
if piece.Color == Black {
direction = -1
startRow = 6
}
if dx == 0 && g.Board.At(to).Type == Empty {
if dy == direction {
return true
}
if from.Row == startRow && dy == 2*direction {
return true
}
}
if abs(dx) == 1 && dy == direction {
target := g.Board.At(to)
if target.Type != Empty && target.Color != piece.Color {
return true
}
if g.EnPassantTarget != nil && *g.EnPassantTarget == to {
return true
}
}
return false
}
func (g *Game) isValidRookMove(from, to Position, dx, dy int) bool {
if dx != 0 && dy != 0 {
return false
}
return g.isPathClear(from, to)
}
func (g *Game) isValidKnightMove(dx, dy int) bool {
return (abs(dx) == 2 && abs(dy) == 1) || (abs(dx) == 1 && abs(dy) == 2)
}
func (g *Game) isValidBishopMove(from, to Position, dx, dy int) bool {
if abs(dx) != abs(dy) {
return false
}
return g.isPathClear(from, to)
}
func (g *Game) isValidQueenMove(from, to Position, dx, dy int) bool {
return g.isValidRookMove(from, to, dx, dy) || g.isValidBishopMove(from, to, dx, dy)
}
func (g *Game) isValidKingMove(piece Piece, dx, dy int) bool {
if abs(dx) <= 1 && abs(dy) <= 1 {
return true
}
if abs(dx) == 2 && dy == 0 && !g.KingMoved[piece.Color] {
return g.canCastle(piece.Color, dx > 0)
}
return false
}
func (g *Game) canCastle(color Color, kingSide bool) bool {
row := 0
if color == Black {
row = 7
}
rookCol := 0
if kingSide {
rookCol = 7
}
if g.RookMoved[color][rookCol/7] {
return false
}
if g.IsInCheck(color) {
return false
}
start, end := 1, 3
if kingSide {
start, end = 5, 6
}
for col := start; col <= end; col++ {
if g.Board.At(Position{row, col}).Type != Empty {
return false
}
g.Board.Set(Position{row, 4}, Piece{Empty, White})
g.Board.Set(Position{row, col}, Piece{King, color})
inCheck := g.IsInCheck(color)
g.Board.Set(Position{row, 4}, Piece{King, color})
g.Board.Set(Position{row, col}, Piece{Empty, White})
if inCheck {
return false
}
}
return true
}
func (g *Game) isPathClear(from, to Position) bool {
dx := sign(to.Col - from.Col)
dy := sign(to.Row - from.Row)
current := Position{from.Row + dy, from.Col + dx}
for current != to {
if g.Board.At(current).Type != Empty {
return false
}
current.Row += dy
current.Col += dx
}
return true
}
func (g *Game) MakeMove(from, to Position) bool {
if !g.IsValidMove(from, to) {
return false
}
piece := g.Board.At(from)
move := Move{from, to, piece}
originalTarget := g.Board.At(to)
g.executeMove(from, to, piece)
if g.IsInCheck(g.CurrentTurn) {
g.undoMove(from, to, piece, originalTarget)
return false
}
g.updateGameState(from, to, piece)
g.MoveHistory = append(g.MoveHistory, move)
g.CurrentTurn = 1 - g.CurrentTurn
return true
}
func (g *Game) executeMove(from, to Position, piece Piece) {
if piece.Type == King && abs(to.Col-from.Col) == 2 {
g.executeCastle(from, to)
} else if piece.Type == Pawn && g.EnPassantTarget != nil && *g.EnPassantTarget == to {
g.executeEnPassant(from, to)
} else {
g.Board.Move(from, to)
}
}
func (g *Game) executeCastle(from, to Position) {
g.Board.Move(from, to)
rookFromCol := 0
rookToCol := 3
if to.Col > from.Col {
rookFromCol = 7
rookToCol = 5
}
rookFrom := Position{from.Row, rookFromCol}
rookTo := Position{from.Row, rookToCol}
g.Board.Move(rookFrom, rookTo)
}
func (g *Game) executeEnPassant(from, to Position) {
g.Board.Move(from, to)
capturedPawnRow := from.Row
g.Board.Set(Position{capturedPawnRow, to.Col}, Piece{Empty, White})
}
func (g *Game) undoMove(from, to Position, piece Piece, originalTarget Piece) {
if piece.Type == King && abs(to.Col-from.Col) == 2 {
g.undoCastle(from, to, piece.Color)
} else if piece.Type == Pawn && g.EnPassantTarget != nil && *g.EnPassantTarget == to {
g.undoEnPassant(from, to)
} else {
g.Board.Set(from, piece)
g.Board.Set(to, originalTarget)
}
}
func (g *Game) undoCastle(from, to Position, color Color) {
g.Board.Set(from, Piece{King, color})
g.Board.Set(to, Piece{Empty, White})
rookFromCol := 0
rookToCol := 3
if to.Col > from.Col {
rookFromCol = 7
rookToCol = 5
}
rookFrom := Position{from.Row, rookFromCol}
rookTo := Position{from.Row, rookToCol}
g.Board.Set(rookFrom, Piece{Rook, color})
g.Board.Set(rookTo, Piece{Empty, White})
}
func (g *Game) undoEnPassant(from, to Position) {
g.Board.Set(from, Piece{Pawn, g.CurrentTurn})
g.Board.Set(to, Piece{Empty, White})
capturedPawnRow := from.Row
enemyColor := 1 - g.CurrentTurn
g.Board.Set(Position{capturedPawnRow, to.Col}, Piece{Pawn, enemyColor})
}
func (g *Game) updateGameState(from, to Position, piece Piece) {
g.EnPassantTarget = nil
if piece.Type == King {
g.KingMoved[piece.Color] = true
} else if piece.Type == Rook {
switch from.Col {
case 0:
g.RookMoved[piece.Color][0] = true
case 7:
g.RookMoved[piece.Color][1] = true
}
} else if piece.Type == Pawn && abs(to.Row-from.Row) == 2 {
enPassantRow := (from.Row + to.Row) / 2
g.EnPassantTarget = &Position{enPassantRow, from.Col}
}
}
func (g *Game) FindKing(color Color) Position {
for row := 0; row < 8; row++ {
for col := 0; col < 8; col++ {
pos := Position{row, col}
piece := g.Board.At(pos)
if piece.Type == King && piece.Color == color {
return pos
}
}
}
return Position{-1, -1}
}
func (g *Game) IsInCheck(color Color) bool {
kingPos := g.FindKing(color)
if !kingPos.Valid() {
return false
}
enemyColor := 1 - color
for row := 0; row < 8; row++ {
for col := 0; col < 8; col++ {
pos := Position{row, col}
piece := g.Board.At(pos)
if piece.Type != Empty && piece.Color == enemyColor {
if g.canPieceAttack(piece, pos, kingPos) {
return true
}
}
}
}
return false
}
func (g *Game) canPieceAttack(piece Piece, from, to Position) bool {
dx := to.Col - from.Col
dy := to.Row - from.Row
switch piece.Type {
case Pawn:
direction := 1
if piece.Color == Black {
direction = -1
}
return abs(dx) == 1 && dy == direction
case Rook:
return (dx == 0 || dy == 0) && g.isPathClear(from, to)
case Knight:
return (abs(dx) == 2 && abs(dy) == 1) || (abs(dx) == 1 && abs(dy) == 2)
case Bishop:
return abs(dx) == abs(dy) && g.isPathClear(from, to)
case Queen:
return ((dx == 0 || dy == 0) || (abs(dx) == abs(dy))) && g.isPathClear(from, to)
case King:
return abs(dx) <= 1 && abs(dy) <= 1
}
return false
}
func (g *Game) IsCheckmate(color Color) bool {
if !g.IsInCheck(color) {
return false
}
for row := 0; row < 8; row++ {
for col := 0; col < 8; col++ {
from := Position{row, col}
piece := g.Board.At(from)
if piece.Type != Empty && piece.Color == color {
for toRow := 0; toRow < 8; toRow++ {
for toCol := 0; toCol < 8; toCol++ {
to := Position{toRow, toCol}
if g.isValidMoveIgnoringCheck(from, to) {
originalPiece := g.Board.At(to)
g.Board.Move(from, to)
inCheck := g.IsInCheck(color)
g.Board.Set(from, piece)
g.Board.Set(to, originalPiece)
if !inCheck {
return false
}
}
}
}
}
}
}
return true
}
func (g *Game) isValidMoveIgnoringCheck(from, to Position) bool {
if !from.Valid() || !to.Valid() {
return false
}
piece := g.Board.At(from)
if piece.Type == Empty {
return false
}
target := g.Board.At(to)
if target.Type != Empty && target.Color == piece.Color {
return false
}
return g.isValidPieceMove(piece, from, to)
}
func (g *Game) GameStatus() string {
if g.IsCheckmate(g.CurrentTurn) {
winner := "White"
if g.CurrentTurn == White {
winner = "Black"
}
return fmt.Sprintf("Checkmate! %s wins!", winner)
}
if g.IsInCheck(g.CurrentTurn) {
return fmt.Sprintf("%s is in check!", g.CurrentTurn)
}
return ""
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
func sign(x int) int {
if x > 0 {
return 1
}
if x < 0 {
return -1
}
return 0
}

26
go.mod Normal file
View file

@ -0,0 +1,26 @@
module chess-tui
go 1.25.1
require github.com/charmbracelet/bubbletea v1.3.9
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.3.8 // indirect
)

43
go.sum Normal file
View file

@ -0,0 +1,43 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.9 h1:OBYdfRo6QnlIcXNmcoI2n1NNS65Nk6kI2L2FO1puS/4=
github.com/charmbracelet/bubbletea v1.3.9/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=

293
main.go Normal file
View file

@ -0,0 +1,293 @@
package main
import (
"fmt"
"log"
"slices"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
game *Game
cursorRow int
cursorCol int
selected *Position
validMoves []Position
}
func initialModel() model {
return model{
game: NewGame(),
cursorRow: 0,
cursorCol: 0,
selected: nil,
validMoves: make([]Position, 0),
}
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
case tea.KeyEscape:
m.selected = nil
m.validMoves = make([]Position, 0)
}
switch msg.String() {
case "q":
return m, tea.Quit
case "up", "k":
if m.cursorRow < 7 {
m.cursorRow++
}
case "down", "j":
if m.cursorRow > 0 {
m.cursorRow--
}
case "left", "h":
if m.cursorCol > 0 {
m.cursorCol--
}
case "right", "l":
if m.cursorCol < 7 {
m.cursorCol++
}
case "enter", " ":
currentPos := Position{m.cursorRow, m.cursorCol}
if m.selected == nil {
piece := m.game.Board.At(currentPos)
if piece.Type != Empty && piece.Color == m.game.CurrentTurn {
m.selected = &currentPos
m.validMoves = m.getValidMoves(currentPos)
}
} else {
if *m.selected == currentPos {
m.selected = nil
m.validMoves = make([]Position, 0)
} else if m.game.MakeMove(*m.selected, currentPos) {
m.selected = nil
m.validMoves = make([]Position, 0)
}
}
}
}
return m, nil
}
func (m model) getValidMoves(from Position) []Position {
var moves []Position
for row := range 8 {
for col := range 8 {
to := Position{row, col}
if m.game.IsValidMove(from, to) {
moves = append(moves, to)
}
}
}
return moves
}
func (m model) View() string {
var s strings.Builder
s.WriteString("Use arrow keys to move cursor, ENTER/SPACE to select/move, ESC to deselect, Q to quit\n\n")
status := m.game.GameStatus()
if status != "" {
s.WriteString(fmt.Sprintf("*** %s ***\n\n", status))
}
s.WriteString(m.renderBoardWithInfo())
return s.String()
}
func (m model) renderBoardWithInfo() string {
boardLines := m.getBoardLines()
infoLines := m.getInfoLines()
var s strings.Builder
maxLines := len(boardLines)
if len(infoLines) > maxLines {
maxLines = len(infoLines)
}
for i := 0; i < maxLines; i++ {
if i < len(boardLines) {
s.WriteString(boardLines[i])
} else {
s.WriteString(strings.Repeat(" ", 27)) // Board width padding
}
s.WriteString(" ")
if i < len(infoLines) {
s.WriteString(infoLines[i])
}
s.WriteString("\n")
}
return s.String()
}
func (m model) getBoardLines() []string {
var lines []string
lines = append(lines, " a b c d e f g h ")
for row := 7; row >= 0; row-- {
var line strings.Builder
line.WriteString(fmt.Sprintf("%d ", row+1))
for col := 0; col < 8; col++ {
pos := Position{row, col}
piece := m.game.Board.At(pos)
cellChar := piece.String()
if piece.Type == Empty {
if (row+col)%2 == 0 {
cellChar = "·"
} else {
cellChar = " "
}
}
if m.cursorRow == row && m.cursorCol == col {
line.WriteString(fmt.Sprintf("[%s]", cellChar))
} else if m.selected != nil && m.selected.Row == row && m.selected.Col == col {
line.WriteString(fmt.Sprintf("<%s>", cellChar))
} else if slices.Contains(m.validMoves, pos) {
line.WriteString(fmt.Sprintf("*%s*", cellChar))
} else {
line.WriteString(fmt.Sprintf(" %s ", cellChar))
}
}
line.WriteString(fmt.Sprintf(" %d", row+1))
lines = append(lines, line.String())
}
lines = append(lines, " a b c d e f g h ")
return lines
}
func (m model) getInfoLines() []string {
var lines []string
lines = append(lines, "┌─────────────────────┐")
lines = append(lines, "│ GAME INFO │")
lines = append(lines, "├─────────────────────┤")
lines = append(lines, fmt.Sprintf("│ Turn: %-13s │", m.game.CurrentTurn))
lines = append(lines, "│ │")
cursorPos := Position{m.cursorRow, m.cursorCol}
piece := m.game.Board.At(cursorPos)
lines = append(lines, fmt.Sprintf("│ Cursor: %-11s │", cursorPos.String()))
if piece.Type == Empty {
lines = append(lines, "│ Piece: Empty │")
} else {
pieceName := m.getPieceName(piece)
lines = append(lines, fmt.Sprintf("│ Piece: %-12s │", pieceName))
}
lines = append(lines, "│ │")
if m.selected != nil {
lines = append(lines, "├─────────────────────┤")
selectedPiece := m.game.Board.At(*m.selected)
selectedName := m.getPieceName(selectedPiece)
lines = append(lines, fmt.Sprintf("│ Selected: %-10s │", selectedName))
lines = append(lines, fmt.Sprintf("│ At: %-15s │", m.selected.String()))
if len(m.validMoves) > 0 {
lines = append(lines, "│ │")
lines = append(lines, "│ Valid moves: │")
// Show up to 6 valid moves
moveCount := len(m.validMoves)
if moveCount > 6 {
moveCount = 6
}
for i := 0; i < moveCount; i += 2 {
var moveLine strings.Builder
moveLine.WriteString("│ ")
moveLine.WriteString(m.validMoves[i].String())
if i+1 < moveCount {
moveLine.WriteString(fmt.Sprintf(" %s", m.validMoves[i+1].String()))
}
// Pad to fit box width
for moveLine.Len() < 20 {
moveLine.WriteString(" ")
}
moveLine.WriteString(" │")
lines = append(lines, moveLine.String())
}
if len(m.validMoves) > 6 {
lines = append(lines, fmt.Sprintf("│ ... and %d more │", len(m.validMoves)-6))
}
}
}
lines = append(lines, "└─────────────────────┘")
if len(m.game.MoveHistory) > 0 {
lastMove := m.game.MoveHistory[len(m.game.MoveHistory)-1]
lines = append(lines, fmt.Sprintf("Last move: %s -> %-12s", lastMove.From.String(), lastMove.To.String()))
}
return lines
}
func (m model) getPieceName(piece Piece) string {
if piece.Type == Empty {
return "Empty"
}
color := "White"
if piece.Color == Black {
color = "Black"
}
pieceType := ""
switch piece.Type {
case Pawn:
pieceType = "Pawn"
case Rook:
pieceType = "Rook"
case Knight:
pieceType = "Knight"
case Bishop:
pieceType = "Bishop"
case Queen:
pieceType = "Queen"
case King:
pieceType = "King"
}
return fmt.Sprintf("%s %s", color, pieceType)
}
func main() {
if _, err := tea.NewProgram(initialModel()).Run(); err != nil {
log.Fatal(err)
}
}