はじめに

平素は大変お世話になっております。
クイックガードのパー子です。

突然ですが、私は 女神転生シリーズ が大好きです。
(と言いつつ、一番好きなタイトルは女神転生ではなく 魔神転生II SPIRAL NEMESIS です。)

いつでもどこでも仕事中でも女神転生を感じられるように、同シリーズに登場するミニゲーム「コードブレイカー」を TUI で再現してみました。

リポジトリ

コードブレイカーとは

「ヒット&ブロー」や「マスターマインド」とも呼ばれる数当てゲームです。

プレイヤーは、ゲーム開始時に生成された指定桁数の数列 (= 正解) を見破ることを目的とします。
プレイヤーが提出した解答と正解を比較して、数字と桁位置が一致するものがあれば “ヒット (H)” 、桁位置が異なる (= その数字は正解に含まれるが、桁が違う) ようであれば “ブロー (B)” と判定されます。

例えば正解が “123” のときに解答が “324” であれば、判定は “H=1/B=1” となります。
(= “2” がヒットして、“3” がブローしている。)

このヒントを参考にしながら、正解を当てるまで試行を繰り返します。

なお、正解はすべて異なる数字から構成されますが、解答では同じ数字を重複して選択できます。
“111” のように数字を重複して解答した場合、このとき正解が “123” ならば、判定は “H=1/B=2” となります。

画面構成

真・女神転生II 版のコードブレイカーを模して、ゲーム画面は以下の構成にします。

画面はいくつかの区画に分かれており、それをウィジェットと呼ぶことにします。

ウィジェット役割
1ヘッダゲームのタイトルを表示する。
2解答入力フォームプレイヤーの解答を受け付ける。
3ルールゲームのルールを掲示する。
4メニューゲームの操作メニューを配置する。
5解答履歴解答履歴を表示する。
6判定解答に対する判定を表示する。
7ヒントそれまでの判定から導き出されたヒントをわかりやすく表示する。

操作メニューとして、リセットボタンと終了ボタンを配置します。

実装

方針

幅広い環境でお手軽にプレイしたいので、Golang で実装します。

TUIライブラリとして tview を使用します。
tview には様々な種類のウィジェットが用意されており、それらを組み合わせて画面を構築します。

ゲーム難易度の選択機能として、数列の桁数と使用する数字を起動時の引数で変更できるようにします。
(今回は使用数字 1〜9 の 9桁を最高難度とします。)

ディレクトリ構造

以下のディレクトリ構造で実装することにします。

code-breaker/
├── gamestate/
│   └── state.go
├── main.go
└── widget/
    ├── <widget_1>.go
    ├── ...
    └── <widget_n>.go

gamestate/state.go にゲーム全体の状態を持たせて、widget/配下にウィジェットごとの定義を配置します。

ゲーム全体の状態

プレイ中、ゲーム全体の状態として以下の項目を保持します。

// ゲーム全体にまつわる状態
type state struct {
	Length     int   // 数列の桁数
	MaxNumber  int   // 使用可能な数字 (1〜MaxNumber)
	IsRevealed bool  // 正解を開示するか否か (正解を見破った or 試行回数を使い切った場合に true)
	Code       []int // 正解
	tryCount   int   // 試行した回数 (上限に達したらチャレンジ終了)
}

試行回数の上限は単純に定数として定義しておきます。

// 最大試行回数
const maxTry = 9

正解の数列は以下の手順で生成します。

  1. an = n (n = 1, 2, …, MaxNumber) の等差数列を作成する。
  2. シャッフルする。
  3. 先頭から指定桁だけ切り取る。
// ランダムな正解を生成する。
func (s *state) generateCode() []int {
	code := make([]int, s.MaxNumber)

	for i := range s.MaxNumber {
		code[i] = i + 1
	}

	rand.Shuffle(
		s.MaxNumber,
		func(i, j int) {
			code[i], code[j] = code[j], code[i]
		},
	)

	return code[0:s.Length]
}

ゲームの状態はコード中の様々な場所で参照したいものなので、stateインスタンスを Singleton として確保してしまうことにします。

var stateInstance *state

// stateインスタンスを初期化する。
func Initialize(length int, maxNumber int) {
	stateInstance = &state{
		Length:    length,
		MaxNumber: maxNumber,
	}

	stateInstance.Reset()
}

// stateインスタンスを取得する。
func GetState() *state {
	return stateInstance
}

// stateインスタンスを初期状態にリセットする。
func (s *state) Reset() {
	s.IsRevealed = false

	s.Code = s.generateCode()

	s.tryCount = 0
}

試行回数のカウントアップと正解の開示メソッドも実装しておきます。
与えられた試行回数を使い果たしたら正解を開示するようにします。

// 試行回数をカウントアップする。
func (s *state) Try() {
	s.tryCount++

	if maxTry <= s.tryCount {
		s.Reveal()
	}
}

// 正解を開示する。
func (s *state) Reveal() {
	s.IsRevealed = true
}

ウィジェット

共通設計

各ウィジェットごとに専用の構造体を用意し、New<ウィジェット名>() という関数でインスタンスを生成することにします。
構造体は Section というフィールドに tviewウィジェットを保持します。

また、リセットボタンを押したときに状態を初期化するため、リセット対象のウィジェットに持たせる Resettableインタフェイスを定義しておきます。

// リセット可能なウィジェットを表すインタフェイス
type Resettable interface {
	reset()
}

同様に、入力された解答を受け取って自身の状態を更新するウィジェットのための Updatableインタフェイスも定義します。

// 解答を受け取って自身の状態を更新するウィジェットを表すインタフェイス
type Updatable interface {
	update(guess []int)
}

さらに、文言の見栄えを良くするためのユーティリティとして、任意の文字列の両端に余白を設ける関数を用意します。

// 値を空白で囲む。
func withPadding(a ...any) string {
	return " " + fmt.Sprint(a...) + " "
}

ヘッダ

ヘッダはゲームのタイトルを表示するだけの静的なウィジェットなので、今回作成するウィジェットの中でも最もシンプルな実装になります。

使用する tviewコンポーネントは、文字列をただ描画するだけの TextView です。

// ゲームのタイトルを表示するヘッダ・ウィジェット
type header struct {
	Section *tview.TextView // tview
}

// headerウィジェットを生成する。
func NewHeader() *header {
	section := tview.NewTextView()

	h := &header{
		Section: section,
	}

	h.Section.
		SetTextAlign(tview.AlignCenter).
		SetText(withPadding("Code Breaker")).
		SetBorder(true)

	return h
}

解答入力フォーム

入力フォームには Formコンポーネントを使用します。

// プレイヤーの解答を入力するウィジェット
type try struct {
	Section *tview.Form // tview
}

// tryウィジェットを生成する。
func NewTry(widgets []Updatable) *try {
	section := tview.NewForm()

	t := &try{
		Section: section,
	}

	s := gamestate.GetState()

	t.Section.
		... // 後述

	return t
}

AddInputField() で単一行のテキスト入力欄を配置できます。
入力欄の幅は、桁数 + 1文字くらいがちょうどよいでしょう。

また、このとき、入力値のバリデータ関数を引数として渡すことができます。
そのバリデータで

  • 入力桁数を超過していないか?
  • 使用可能な範囲を超えた数字を入力していないか?

の 2点をチェックします。

数字 (= 0〜9 という文字) の Rune は連続しているので、入力した数字が使用可能な範囲に収まっているかどうかは Rune の単純な大小で判断できます。

func NewTry(widgets []Updatable) *try {
	...

	t.Section.
		AddInputField("Numbers:", "", s.Length+1, func(textToCheck string, lastChar rune) bool {
			if s.Length < len(textToCheck) {
				return false
			}

			if (lastChar < '1') || (rune('0'+s.MaxNumber) < lastChar) {
				return false
			}

			return true
		}, nil)

	...
}

さらにコントロールボタンを 2つ配置します。
OKボタンで解答を投稿、Clearボタンで入力をやり直します。
なお、OKボタンを押下した際、すでに正解が開示されている or 入力した値が指定桁数と一致しない場合は処理を中断させます。

func NewTry(widgets []Updatable) *try {
	...

	t.Section.
		AddInputField("Numbers:", "", s.Length+1, func(textToCheck string, lastChar rune) bool {
			/* 略 */
		}, nil).
		AddButton("OK", func() {
			if s.IsRevealed {
				return
			}

			if len(t.getInputField().GetText()) != s.Length {
				return
			}

			s.Try()

			guess := t.getAnswer()

			for _, w := range widgets {
				w.update(guess)
			}

			t.reset()
		}).
		AddButton("Clear", func() {
			t.reset()
		}).
		SetBorder(true).
		SetTitle(withPadding("Try"))

	...
}

// tryウィジェットを初期化する。
func (t *try) reset() {
	inputField := t.getInputField()

	inputField.SetText("")
}

// 入力フィールドのインスタンスを取得する。
func (t *try) getInputField() *tview.InputField {
	return t.Section.GetFormItemByLabel("Numbers:").(*tview.InputField)
}

// 入力された解答を取得する。
func (t *try) getAnswer() []int {
	var numbers []int

	text := t.getInputField().GetText()

	for _, char := range text {
		digit, _ := strconv.Atoi(string(char))

		numbers = append(numbers, digit)
	}

	return numbers
}

ルール

ゲームのルールとして、以下を表示します。

  • 数列の桁数
  • 使用する数字の範囲
// ゲームのルールを表示するウィジェット
type rule struct {
	Section *tview.TextView // tview
}

// ruleウィジェットを生成する。
func NewRule() *rule {
	section := tview.NewTextView()

	r := &rule{
		Section: section,
	}

	s := gamestate.GetState()

	r.Section.
		SetText(
			withPadding("Length: ", s.Length) + "\n" +
				withPadding("Range: 1-", s.MaxNumber),
		).
		SetBorder(true).
		SetTitle(withPadding("Rule"))

	return r
}

メニュー

Resetボタンはゲームのリセット、Quitボタンはゲーム終了です。

// ゲームのメニューを表示するウィジェット
type menu struct {
	Section *tview.Form // tview
}

// menuウィジェットを生成する。
func NewMenu(app *tview.Application, widgets []Resettable) *menu {
	section := tview.NewForm()

	m := &menu{
		Section: section,
	}

	m.Section.
		AddButton("Reset", func() {
			gamestate.GetState().Reset()

			for _, w := range widgets {
				w.reset()
			}
		}).
		AddButton("Quit", func() {
			app.Stop()
		}).
		SetBorder(true).
		SetTitle(withPadding("Menu"))

	return m
}

解答履歴

表形式で列挙するため、Tableコンポーネントを使用します。

構造体には、履歴を保持するための historyフィールドを持たせます。

// これまでの解答を表示するウィジェット
type answer struct {
	Section *tview.Table // tview
	history [][]int      // 解答履歴
}

// answerウィジェットを生成する。
func NewAnswer() *answer {
	section := tview.NewTable()

	a := &answer{
		Section: section,
	}

	a.Section.
		SetBorders(true).
		SetBorder(true).
		SetTitle(withPadding("Answer"))

	a.reset()

	return a
}

// answerウィジェットを初期化する。
func (a *answer) reset() {
	a.history = [][]int{}

	a.render()
}

セルを 1つずつ愚直に描画していきます。

正解欄には、すでに正解が開示済みの場合はその数列を、そうでなければ代わりに ? を描画します。

// これまでの解答を表示する。
// 正解を見破った場合は正解も表示する。
func (a *answer) render() {
	s := gamestate.GetState()

	a.Section.Clear()

	a.Section.SetCell(0, 0, tview.NewTableCell(withPadding("")))

	for c := range s.Length {
		a.Section.SetCell(0, c+1,
			tview.NewTableCell(withPadding("#", c+1)).
				SetTextColor(tcell.ColorBlue).
				SetAlign(tview.AlignCenter),
		)
	}

	tries := len(a.history)

	for r := range tries {
		a.Section.SetCell(r+1, 0,
			tview.NewTableCell(withPadding("@", r+1)).
				SetTextColor(tcell.ColorBlue).
				SetAlign(tview.AlignLeft),
		)

		for c := range s.Length {
			a.Section.SetCell(r+1, c+1,
				tview.NewTableCell(withPadding(a.history[r][c])).
					SetTextColor(tcell.ColorWhite).
					SetAlign(tview.AlignRight),
			)
		}
	}

	a.Section.SetCell(tries+1, 0,
		tview.NewTableCell(withPadding("Answer")).
			SetTextColor(tcell.ColorBlue).
			SetAlign(tview.AlignLeft),
	)

	for c := range s.Length {
		symbol := "?"

		if s.IsRevealed {
			symbol = fmt.Sprint(s.Code[c])
		}

		cell := tview.NewTableCell(withPadding(symbol)).
			SetTextColor(tcell.ColorLightGreen).
			SetAlign(tview.AlignRight)

		if s.IsRevealed {
			cell.SetAttributes(tcell.AttrBlink)
		}

		a.Section.SetCell(tries+1, c+1, cell)
	}
}

履歴に新たな解答を追加するための、Updatableインタフェイスを満たすメソッド update() を実装します。
このとき、見事に正解を言い当てているならば正解を開示するようにします。

// 新たな解答を受け取り、履歴に追加する。
func (a *answer) update(guess []int) {
	s := gamestate.GetState()

	a.history = append(a.history, guess)

	if reflect.DeepEqual(guess, s.Code) {
		s.Reveal()
	}

	a.render()
}

判定

解答に対するヒット&ブロー数の履歴を表形式で列挙します。

answerウィジェットと同じく、履歴を保持するための historyフィールドを持たせます。

// これまでのヒット数とブロー数を表示するウィジェット
type judge struct {
	Section *tview.Table // tview
	history [][]int      // ヒット数とブロー数の履歴
}

// judgeウィジェットを生成する。
func NewJudge() *judge {
	section := tview.NewTable()

	j := &judge{
		Section: section,
	}

	j.Section.
		SetBorders(true).
		SetBorder(true).
		SetTitle(withPadding("Judge"))

	j.reset()

	return j
}

// judgeウィジェットを初期化する。
func (j *judge) reset() {
	j.history = [][]int{}

	j.render()
}

これまでの各試行について、ヒット数とブロー数を数値で表示します。

// これまでのヒット数とブロー数を表示する。
func (j *judge) render() {
	j.Section.Clear()

	j.Section.SetCell(0, 0, tview.NewTableCell(withPadding("  ")))

	for c, v := range []string{"Hit", "Blow"} {
		j.Section.SetCell(0, c+1,
			tview.NewTableCell(withPadding(v)).
				SetTextColor(tcell.ColorBlue).
				SetAlign(tview.AlignCenter),
		)
	}

	tries := len(j.history)

	for r := range tries {
		j.Section.SetCell(r+1, 0,
			tview.NewTableCell(withPadding("@", r+1)).
				SetTextColor(tcell.ColorBlue).
				SetAlign(tview.AlignLeft),
		)

		for c, v := range j.history[r] {
			j.Section.SetCell(r+1, c+1,
				tview.NewTableCell(withPadding(v)).
					SetTextColor(tcell.ColorWhite).
					SetAlign(tview.AlignRight),
			)
		}
	}
}

update()メソッドではヒット数とブロー数を算出して履歴に格納しますが、

// 新たな解答を受け取り、ヒット数とブロー数を算出して履歴に追加する。
func (j *judge) update(guess []int) {
	hit, blow := hitAndBlow(guess, gamestate.GetState().Code)

	j.history = append(j.history, []int{hit, blow})

	j.render()
}

その算出関数は他ウィジェットでも使用するので、特定の構造体に紐付かない独立した関数として実装します。

// ヒット数とブロー数を算出する。
func hitAndBlow(guess []int, code []int) (int, int) {
	hit := 0
	blow := 0

	digitMap := map[int]int{}

	for i, v := range code {
		digitMap[v] = i
	}

	for i, v := range guess {
		if pos, ok := digitMap[v]; ok {
			if i == pos {
				hit++
			} else {
				blow++
			}
		}
	}

	return hit, blow
}

ヒント

これまでの判定から導出されたヒントを、各数字 x 各桁のマトリクスでプレイヤーにわかりやすく表示します。

マトリクス中のシンボルの凡例は以下のとおりです。

シンボル意味
@ヒット。その数字はその桁で確定した。
O桁は不明だが、その数字は正解に含まれる。
Xその数字はその桁には出現しない。
(空白)未確定。判断するには情報が足りない。

これを定数として定義しておきます。

// ヒントの凡例
const (
	symbolHit           = '@' // ヒット
	symbolExist         = 'O' // 正解には当該数字が含まれる
	symbolExcluded      = 'X' // 正解には当該数字が含まれない
	symbolNotDetermined = ' ' // 未確定 (= 情報が足りず、判断できない)
)

今回の実装では、マトリクスは腕力による全件探索で炙り出すことにします。

  1. 正解として考えられる数列 (= 正解候補群) を全件生成する。
  2. それまでの判定履歴に反するものを刈り落とす。
  3. 残った候補群に適合するようにマトリクスを構築する。

今回のゲームの規模は 1〜9 の 9つの数字からなる 9桁が最大であり、そのときに初回の候補群は 9P9 = 9 * 8 * … * 1 = 362,880個となります。
解答するたびに残存する候補群の全件に対してヒット&ブローを判定しますが、現代のコンピュータの性能なら問題ない個数かと思います。

構造体は、マトリクスを表す matrixフィールドと、判定履歴に適合する候補群を保持する candidatesフィールドを持ちます。

// これまでの試行から得られた情報をヒントとして表示するウィジェット
type hint struct {
	Section    *tview.Table // tview
	matrix     [][]rune     // ヒント表
	candidates [][]int      // これまでの結果に合致する、正解の候補群 (ヒントの導出に使用する)
}

// hintウィジェットを生成する。
func NewHint() *hint {
	section := tview.NewTable()

	h := &hint{
		Section: section,
	}

	h.Section.
		SetBorders(true).
		SetBorder(true).
		SetTitle(withPadding("Hint"))

	h.reset()

	return h
}

// hintウィジェットを初期化する。
func (h *hint) reset() {
	h.initializeCandidates()

	h.initializeMatrix()

	h.render()
}

正解候補群は以下の手順で生成します。

  1. an = n (n = 1, 2, …, MaxNumber) の等差数列を作成する。
  2. 数字を 1つピックして、元の数列から削除する。ピックした数字は手元に控えておく。
  3. ピック分を削除した数列に対して、手順2 を再帰的に適用する。
  4. ピックした数字が指定桁数だけ集まれば、それを正解候補として採用する。
  5. 以上の探索を、数字総当たりで繰り返す。
// 正解候補群に初期値 (= 使用可能な数字から成る順列) をセットする。
func (h *hint) initializeCandidates() {
	s := gamestate.GetState()

	digits := make([]int, s.MaxNumber)

	for i := range digits {
		digits[i] = i + 1
	}

	h.candidates = h.generatePermutations([]int{}, digits)
}

// 正解の候補となる順列を生成する。
func (h *hint) generatePermutations(permutation []int, rest []int) [][]int {
	result := [][]int{}

	s := gamestate.GetState()

	if len(permutation) == s.Length {
		candidate := make([]int, len(permutation))

		copy(candidate, permutation)

		return append(result, candidate)
	}

	for i, v := range rest {
		result = slices.Concat(
			result,
			h.generatePermutations(
				append(permutation, v),
				slices.Concat(rest[:i], rest[i+1:]),
			),
		)
	}

	return result
}

マトリクスの初期値として、すべてのセルを “未確定"シンボルで埋めておきます。

// ヒント表に初期値 (= すべてのマスが未確定) をセットする。
func (h *hint) initializeMatrix() {
	s := gamestate.GetState()

	matrix := make([][]rune, s.MaxNumber)

	for r := range s.MaxNumber {
		matrix[r] = make([]rune, s.Length)

		for c := range s.Length {
			matrix[r][c] = symbolNotDetermined
		}
	}

	h.matrix = matrix
}

使用可能な数字それぞれについて、各桁の出現状況をシンボルで描画します。

// ヒントを表示する。
func (h *hint) render() {
	s := gamestate.GetState()

	h.Section.Clear()

	h.Section.SetCell(0, 0, tview.NewTableCell(withPadding("")))

	for c := range s.Length {
		h.Section.SetCell(0, c+1,
			tview.NewTableCell(withPadding("#", c+1)).
				SetTextColor(tcell.ColorBlue).
				SetAlign(tview.AlignCenter),
		)
	}

	for r := range s.MaxNumber {
		h.Section.SetCell(r+1, 0,
			tview.NewTableCell(withPadding(r+1)).
				SetTextColor(tcell.ColorBlue).
				SetAlign(tview.AlignLeft),
		)

		for c := range s.Length {
			cellValue := h.matrix[r][c]

			cell := tview.NewTableCell(withPadding(string(cellValue))).
				SetAlign(tview.AlignRight)

			switch cellValue {
			case symbolHit:
				cell.SetTextColor(tcell.ColorLightGreen)
			case symbolExist:
				cell.SetTextColor(tcell.ColorYellow)
			case symbolExcluded:
				cell.SetTextColor(tcell.ColorRed)
			default:
				cell.SetTextColor(tcell.ColorWhite)
			}

			h.Section.SetCell(r+1, c+1, cell)
		}
	}
}

最後に、プレイヤーが解答するたびにマトリクスが更新されるようにします。

まず、現在の正解候補群から、新たな解答と判定結果に合致するものだけを抽出します。
具体的には、候補群の 1つ1つについて、解答に対するヒット&ブロー数を算出します。
この数が、同解答に対する真の正解についてのヒット&ブロー数と一致するものだけ残します。

候補群が絞り込まれたら、これに矛盾しないようにマトリクスを構築します。
このときに用いるルールは以下 3つです。

  1. 各桁において、候補群に出現しない数字は X である。
  2. 各桁において、候補群に出現する数字がただ 1つならば、その数字は @ である。
  3. 候補群のすべてに必ず含まれる数字があるならば、その数字は O である。

具体例を示します。

使用する数字が 1〜5 で桁数 3 のときに、以下の候補群が得られたとします。

  • 213
  • 241
  • 251

各ルールを適用する前に、まず、各桁に出現した数字を桁ごとにグルーピングします。

#1#2#3
正解候補に出現した数字

{2}{1, 4, 5}{1, 3}

ルール1 に照らすと、各桁のこれら以外の数字は X となります。

#1#2#3
1X
2XX
3XX
4XX
5XX

続けてルール2 を適用します。
1桁目の数字が 2 のみなので @ が確定します。

#1#2#3
1X
2@XX
3XX
4XX
5XX

さらにルール3 を適用します。
すべての正解候補に含まれている数字は {1, 2} の 2つなので、これらを O で埋めます。
(ただし、2 はすでに全桁のシンボルが確定しているため、1 のみ適用します。)

#1#2#3
1XOO
2@XX
3XX
4XX
5XX

これでマトリクスの完成です。

実装上のポイントとして、ルール3 の判定にビット演算を採用しました。
ゲームで使用する数字の数だけビットを並べて、正解候補群すべてについて論理積 (= AND演算) を計算します。

例えば、上記の例の場合だと、使用数字は 1〜5 なので、まず 5桁のビット列 11111 を用意します。
このとき、先頭ビットが数字5 の有無を、末尾のビットが数字1 の有無を示します。
同様に、各正解候補に対応するビット列を生成します。

  • 213: 00111
  • 241: 01011
  • 251: 10011

これらすべてと 11111 の論理積を計算すると結果は 00011 となり、「すべての正解候補に含まれている数字は {1, 2} である」と判断できるわけです。

// 新たな解答とその判定結果を受け取り、ヒントを更新する。
func (h *hint) update(guess []int) {
	h.updateCandidates(guess)

	h.updateMatrix()

	h.render()
}

// 正解候補群を更新する。
// (受け取った解答と判定結果に合致する候補のみを残す。)
func (h *hint) updateCandidates(guess []int) {
	newCandidates := [][]int{}

	hitA, blowA := hitAndBlow(guess, gamestate.GetState().Code)

	for _, candidate := range h.candidates {
		hitB, blowB := hitAndBlow(guess, candidate)

		if hitA == hitB && blowA == blowB {
			newCandidates = append(newCandidates, candidate)
		}
	}

	h.candidates = newCandidates
}

// ヒント表を更新する。
func (h *hint) updateMatrix() {
	s := gamestate.GetState()

	digitPossibilities := make([]map[int]struct{}, s.Length)

	for i := range s.Length {
		digitPossibilities[i] = map[int]struct{}{}
	}

	exists := 0

	for i := range s.MaxNumber {
		exists = exists + (1 << i)
	}

	for _, candidate := range h.candidates {
		bits := 0

		for i, v := range candidate {
			digitPossibilities[i][v] = struct{}{}

			bits = bits + (1 << (v - 1))
		}

		exists = exists & bits
	}

	for c, possibility := range digitPossibilities {
		possibles := []int{}

		for r := range s.MaxNumber {
			if _, ok := possibility[r+1]; ok {
				possibles = append(possibles, r)
			} else {
				// ルール1: 当該桁において候補に含まれない数字は、正解から除外できる。
				h.matrix[r][c] = symbolExcluded
			}
		}

		if len(possibles) == 1 {
			// ルール2: 当該桁において候補数が唯一つの数字は、ヒットであることが確定する。
			h.matrix[possibles[0]][c] = symbolHit
		}
	}

	for v := range s.MaxNumber {
		if exists&(1<<v) != 0 {
			for c := range s.Length {
				if h.matrix[v][c] == symbolNotDetermined {
					// ルール3: 正解候補群のすべてに含まれる数字は、正解のいずれかの桁に存在する。
					h.matrix[v][c] = symbolExist
				}
			}
		}
	}
}

main

最後に main関数です。
コマンドライン引数をパースしつつ、各ウィジェットを然るべき位置関係で配置します。

使用する数字も桁数も 1〜9 の範囲で、かつ、桁数は使用する数字以下でなければいけません。

ウィジェットは Flex (Flexbox) で並べます。
(または、Grid でも構わないのでお好みで。)

起動直後のフォーカスは解答入力フォームに当たるようにしておきます。

また、Application.EnableMouse() でマウス操作を受け付けるようにします。

func main() {
	if err := run(); err != nil {
		fmt.Println(err)

		os.Exit(1)
	} else {
		os.Exit(0)
	}
}

// ゲームを開始する。
func run() error {
	length, maxNumber, err := parseFlags()

	if err != nil {
		return err
	}

	app := tview.NewApplication()

	gamestate.Initialize(length, maxNumber)

	header := widget.NewHeader()

	rule := widget.NewRule()

	answer := widget.NewAnswer()

	judge := widget.NewJudge()

	hint := widget.NewHint()

	try := widget.NewTry([]widget.Updatable{answer, judge, hint})

	menu := widget.NewMenu(app, []widget.Resettable{try, answer, judge, hint})

	root := tview.NewFlex().
		SetDirection(tview.FlexRow).
		AddItem(header.Section, 3, 0, false).
		AddItem(
			tview.NewFlex().
				AddItem(try.Section, 0, 4, true).
				AddItem(rule.Section, 0, 3, false).
				AddItem(menu.Section, 0, 3, false),
			7, 0, true,
		).
		AddItem(
			tview.NewFlex().
				AddItem(answer.Section, 0, 2, false).
				AddItem(judge.Section, 0, 1, false).
				AddItem(hint.Section, 0, 2, false),
			0, 1, false,
		)

	app.
		SetRoot(root, true).
		SetFocus(root).
		EnableMouse(true)

	if err := app.Run(); err != nil {
		return err
	}

	return nil
}

// コマンドライン引数をパースする。
func parseFlags() (int, int, error) {
	length := flag.Int("l", 3, "Number of digits. [1-9]")
	maxNumber := flag.Int("n", 5, "Maximum number to be used, ranging from 1 up to this value. [1-9]")

	flag.Parse()

	if (*length < 1) || (9 < *length) {
		return 0, 0, fmt.Errorf("number of digits must be between 1 and 9")
	}

	if (*maxNumber < 1) || (9 < *maxNumber) {
		return 0, 0, fmt.Errorf("maximum number must be between 1 and 9")
	}

	if *maxNumber < *length {
		return 0, 0, fmt.Errorf("number of digits must be less than or equal to maximum number")
	}

	return *length, *maxNumber, nil
}

まとめ

女神転生シリーズのミニゲーム「コードブレイカー」を Golang & tview を用いて TUI で再現してみました。

現代のマシン性能に物を言わせてヒント・エンジンをブン回しているので、女神転生シリーズのものよりもゲーム難易度は低いかもしれません。

こんごとも よろしく・・・