233 lines
5.6 KiB
Go
233 lines
5.6 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"math/rand"
|
||
"strings"
|
||
"time"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
)
|
||
|
||
// TicTacToeModel — TUI поверх TicTacToeGameState.
|
||
type TicTacToeModel struct {
|
||
game *TicTacToeGameState
|
||
bot TicTacToeBot
|
||
rng *rand.Rand
|
||
cursor int
|
||
message string
|
||
isError bool
|
||
|
||
quitting bool
|
||
backToMenu bool
|
||
}
|
||
|
||
// NewTicTacToeModel начинает новую партию. Каким знаком играет
|
||
// человек — решается случайно (50/50): если человеку достаются
|
||
// нолики, первым ходом автоматически становится ход бота (крестики
|
||
// всегда идут первыми).
|
||
func NewTicTacToeModel(difficulty TicTacToeDifficulty) TicTacToeModel {
|
||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||
human := TicTacToeX
|
||
if rng.Intn(2) == 0 {
|
||
human = TicTacToeO
|
||
}
|
||
return TicTacToeModel{
|
||
game: NewTicTacToeGame(human),
|
||
bot: TicTacToeBot{Difficulty: difficulty},
|
||
rng: rng,
|
||
cursor: 4,
|
||
}
|
||
}
|
||
|
||
func (m TicTacToeModel) Init() tea.Cmd {
|
||
return m.maybeScheduleBot()
|
||
}
|
||
|
||
func (m TicTacToeModel) maybeScheduleBot() tea.Cmd {
|
||
if m.game.Phase != TicTacToePhasePlay || m.game.CurrentTurn == m.game.HumanMark {
|
||
return nil
|
||
}
|
||
return tea.Tick(botMoveDelay, func(time.Time) tea.Msg { return botMoveMsg{} })
|
||
}
|
||
|
||
func (m *TicTacToeModel) setInfo(key string) {
|
||
m.message = T(key)
|
||
m.isError = false
|
||
}
|
||
|
||
func (m *TicTacToeModel) setError(err error) {
|
||
m.message = err.Error()
|
||
m.isError = true
|
||
}
|
||
|
||
func (m TicTacToeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case botMoveMsg:
|
||
return m.handleBotMove()
|
||
case tea.KeyMsg:
|
||
return m.handleKey(msg)
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m TicTacToeModel) handleBotMove() (tea.Model, tea.Cmd) {
|
||
if m.game.Phase != TicTacToePhasePlay || m.game.CurrentTurn == m.game.HumanMark {
|
||
return m, nil
|
||
}
|
||
if err := m.bot.PlayFullTurn(m.game, m.rng); err != nil {
|
||
m.setError(err)
|
||
return m, nil
|
||
}
|
||
m.setInfo("tictactoe.msg.bot_moved")
|
||
return m, m.maybeScheduleBot()
|
||
}
|
||
|
||
func (m TicTacToeModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
key := msg.String()
|
||
if key == "ctrl+c" {
|
||
m.quitting = true
|
||
return m, tea.Quit
|
||
}
|
||
if key == "q" {
|
||
m.backToMenu = true
|
||
return m, nil
|
||
}
|
||
|
||
if m.game.Phase == TicTacToePhaseOver {
|
||
if key == "n" {
|
||
next := NewTicTacToeModel(m.bot.Difficulty)
|
||
return next, next.Init()
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
if m.game.CurrentTurn != m.game.HumanMark {
|
||
return m, nil
|
||
}
|
||
|
||
switch key {
|
||
case "up", "k":
|
||
if m.cursor >= 3 {
|
||
m.cursor -= 3
|
||
}
|
||
case "down", "j":
|
||
if m.cursor < 6 {
|
||
m.cursor += 3
|
||
}
|
||
case "left", "h":
|
||
if m.cursor%3 != 0 {
|
||
m.cursor--
|
||
}
|
||
case "right", "l":
|
||
if m.cursor%3 != 2 {
|
||
m.cursor++
|
||
}
|
||
case "enter", " ":
|
||
return m.place(m.cursor)
|
||
case "1", "2", "3", "4", "5", "6", "7", "8", "9":
|
||
pos := int(key[0] - '1')
|
||
m.cursor = pos
|
||
return m.place(pos)
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m TicTacToeModel) place(pos int) (tea.Model, tea.Cmd) {
|
||
if err := m.game.PlaceMark(pos); err != nil {
|
||
m.setError(err)
|
||
return m, nil
|
||
}
|
||
m.message = ""
|
||
m.isError = false
|
||
return m, m.maybeScheduleBot()
|
||
}
|
||
|
||
func ticTacToeMarkGlyph(mark TicTacToeMark) string {
|
||
switch mark {
|
||
case TicTacToeX:
|
||
return "X"
|
||
case TicTacToeO:
|
||
return "O"
|
||
default:
|
||
return " "
|
||
}
|
||
}
|
||
|
||
func (m TicTacToeModel) View() string {
|
||
if m.quitting {
|
||
return T("common.goodbye")
|
||
}
|
||
var b strings.Builder
|
||
fmt.Fprintln(&b, titleStyle.Render(T("tictactoe.title")))
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, Tf("tictactoe.your_mark", ticTacToeMarkGlyph(m.game.HumanMark)))
|
||
fmt.Fprintln(&b)
|
||
|
||
winSet := map[int]bool{}
|
||
if m.game.Result != nil && !m.game.Result.Draw {
|
||
for _, idx := range m.game.Result.Line {
|
||
winSet[idx] = true
|
||
}
|
||
}
|
||
|
||
for row := 0; row < 3; row++ {
|
||
cells := make([]string, 3)
|
||
for col := 0; col < 3; col++ {
|
||
idx := row*3 + col
|
||
cells[col] = m.renderCell(idx, winSet[idx])
|
||
}
|
||
fmt.Fprintln(&b, " "+strings.Join(cells, " │ ")+" ")
|
||
if row < 2 {
|
||
fmt.Fprintln(&b, "───┼───┼───")
|
||
}
|
||
}
|
||
fmt.Fprintln(&b)
|
||
|
||
switch {
|
||
case m.game.Phase == TicTacToePhaseOver && m.game.Result.Draw:
|
||
fmt.Fprintln(&b, infoStyle.Render(T("tictactoe.outcome.draw")))
|
||
case m.game.Phase == TicTacToePhaseOver && m.game.Result.Winner == m.game.HumanMark:
|
||
fmt.Fprintln(&b, winStyle.Render(T("tictactoe.outcome.win")))
|
||
case m.game.Phase == TicTacToePhaseOver:
|
||
fmt.Fprintln(&b, errorStyle.Render(T("tictactoe.outcome.lose")))
|
||
case m.game.CurrentTurn == m.game.HumanMark:
|
||
fmt.Fprintln(&b, T("tictactoe.hint.turn"))
|
||
default:
|
||
fmt.Fprintln(&b, T("common.thinking_generic"))
|
||
}
|
||
|
||
if m.message != "" {
|
||
if m.isError {
|
||
fmt.Fprintln(&b, errorStyle.Render("! "+m.message))
|
||
} else {
|
||
fmt.Fprintln(&b, infoStyle.Render(m.message))
|
||
}
|
||
}
|
||
fmt.Fprintln(&b)
|
||
if m.game.Phase == TicTacToePhaseOver {
|
||
fmt.Fprintln(&b, dimStyle.Render(T("tictactoe.hint.over")))
|
||
} else {
|
||
fmt.Fprintln(&b, dimStyle.Render(T("tictactoe.hint.play")))
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// renderCell отрисовывает одну клетку доски: цифра-подсказка (1-9),
|
||
// если пусто, X/O если занято, с подсветкой курсора (пока пусто и
|
||
// сейчас ход человека) или выигрышной линии.
|
||
func (m TicTacToeModel) renderCell(idx int, isWinCell bool) string {
|
||
mark := m.game.Board[idx]
|
||
if mark == TicTacToeEmpty {
|
||
text := fmt.Sprintf("%d", idx+1)
|
||
if m.game.Phase == TicTacToePhasePlay && m.game.CurrentTurn == m.game.HumanMark && idx == m.cursor {
|
||
return cursorStyle.Render(text)
|
||
}
|
||
return dimStyle.Render(text)
|
||
}
|
||
text := ticTacToeMarkGlyph(mark)
|
||
if isWinCell {
|
||
return winStyle.Render(text)
|
||
}
|
||
return text
|
||
}
|