漢字熟語陣取り合戦に漢字被りを多くする選択方法を実装した

# 前回のあらすじ
[漢字熟語陣取り合戦](https://qiita.com/unticrice/items/9f859ef1c0e6064d0d5d)というものを作りました。漢字を集めてきてバラバラに表示し、そこから熟語を見つけて取り合うゲームです。集めてくる漢字によって難易度・戦略性が変わるのですが、適当な20熟語を取ってきて漢字にばらすだけだったので工夫してみました。できたものが[これ](https://mt-experience.github.io/kanjiCampounding/index.html)です。遊び方などは[README](https://github.com/mt-experience/kanjiCampounding/blob/main/README.md)を参考にしてください。

まず、面白さが熟語同士の**漢字の被り**にあると考えました。
例えば [低 気 圧 熱 帯] という漢字が未取得で、Aに「熱帯」「気圧」を取られても、Bは「熱帯**低**気圧」と答えれば得点差-4から+5の大逆転です。
Aの獲得した熟語には**低**という穴があったんですね。
これによってオセロのような「これは取れるんだけど泳がせといて後で一気に取り返そう」という駆け引きが生まれてきます。

# どうやって漢字を被らせるか
現在、約36k語の熟語辞書で正解判定、漢字の選出を行っています。
ここで、36kの熟語全てに「スコア」を設定します。最初は0です。
また、「重複度」という値を考えます。熟語2つの間で共有している漢字の数です。
[亜熱帯],[亜寒帯] -> 2
[十人十色],[十中八九] -> 1
という指標です。
それを踏まえたうえで、こんな方法にしてみました。
最初にn語選び、それと近いm語を取ってきて合わせたものを選出語とする方法です。

1. 最初にn語ランダムな熟語を取ってくる。これを「ベース」と呼びます。
2. 全熟語の「スコア」に「ベース」との重複度を加算します。(36k*n語の計算)
3. 「スコア」の高い順でm位の「スコア」を合格点とする。
4. 合格点以上の熟語からランダムにm語選ぶ。
5. 「ベース」と4.のm語を合わせた(n+m)語を選出語とする。

わざわざ3.で合格点を定めているのは、スコア4が2語、ついでスコア2が200語など、スコアが偏るケースが多いため、同スコアのものの中からランダムに選ぶためです。

# jsで実装
[ソース](https://github.com/mt-experience/kanjiCampounding/tree/main/docs)がありますが、自分の経験が浅いためベストプラクティスとは言えなさそうです。ですが、思いついた最良の方法をお伝えします。  
スコアを表現する配列が必要ですが、インデックスも欲しいので2次元配列にします。スコアをsortやfilterした後にインデックスの情報が失われてしまうためです。
sort等の関数に対応するために、また、一般的なデータの形式にものっとって、縦長の配列にします。つまり、行が36kで列が2つです。
<details><summary>例(クリックで表示)</summary>
[行][インデックス,スコア]
[0][0,0]
[1][1,0]
[2][2,5]
[3][3,1]
[4][4,1]
.
並び替えると
[行][インデックス,スコア]
[0][2,5]
[1][3,1]
[2][4,1]
[3][0,0]
[4][1,0]
</details>
取り回しやすさからグローバルにしましたが、安全さを優先するなら`addScore()`関数がスコア配列を返すように改修するべきでしょうか。

```javascript:スコア
// [行番号][0:インデックス, 1:スコア]
let jukugoScore = Array(fullUniqsort.length).fill(0)
jukugoScore.map((v,i) => jukugoScore[i] = [i,0])

// 重複度(Multiplicity)を計算する。選定スコアに加算する。
// 「入力単語の各文字」に対して「全熟語」のスコアを計算する。
function addScore(str){
  uniqueChars(str).map( s => {
    fullUniqsort.map((word,i) => jukugoScore[i][1] += word.includes(s) ?1:0 )
  })
}
```

単語を選出する関数を少し分解して見やすくしてみました。

```javascript:単語選出関数
function smartSelect(fullJukugo, baseNum, bringNum){
  const base = randomArray(fullJukugo, baseNum)
  // base単語から全熟語のスコアを計算する。
  base.map( b => addScore(b))
  // 合格点以上のものをランダムにbringNum個取ってくる
  const pass = jukugoScore.concat()
                   .sort((a,b) => b[1] - a[1] )
                   .slice(bringNum-1, bringNum)[0][1]
  const passScores = jukugoScore.filter(v => v[1] >= pass)
  const juk = randomArray(passScores, bringNum)
                  .map(score => fullJukugo[score[0]])
  return base.concat(juk)
}
```

補足:
`uniqueChars(str)`は文字列を重複ない1文字の配列にする関数です。
例) [十人十色] => [十, 人, 色]  
`randomArray(array, num)`は配列arrayからランダムにnum個の要素を取ってきた配列を返す関数です。

# この手法の効果
選出した熟語の重複漢字数が1から3だったものが、20やら50やらに跳ね上がりました。漢字を被らせるという目的は達成しました。
肝心の漢字の奪い合いが実現できているかというと、体感でできてます。まぁ一人プレイなんですけどね。

# この手法の難点
n=5, m=25 くらいにすると30字から50字になるのですが、漢字数に偏りが多いのが気になります。「使えない漢字」を作らないために、熟語を選出してからバラすという手法をとっているので、漢字数の調整は少し難しそうです。
それと、「ベース」が難しい単語だと難しい問題になってしまうかもしれません。[齟齬]と漢字を共有してる熟語なんて思いつきません。  
この手法であれば、[阿呆陀羅経]などの絶対思いつかない熟語の[陀羅]のような難しい漢字が余ってしまう心配をなくしてくれると踏んでいました。確かに定性的にはリスクは減らせているはずです。しかし、難しい漢字を共有する熟語は難しいのです。[百万陀羅]とかやはり思いつかないものになります。これはこの手法というよりは漢字自身の難しさに由来する問題です。  
ということで改善点は「漢字数の調整機構」と「難漢字の排除」でしょうか。

# 思いつき
いっそ選出した熟語を表示してしまうというのはどうでしょう。これなら漢字数が多くても難しくても計30手で決着をつけることが可能です。
先攻が長い単語を獲得できるので、普通にやれば先攻の勝ちです。しかし、表示されていない熟語を思いつければ相手の点数を奪ったりできます。  
これなら、難しすぎて「漢字の山から熟語を見つける」というゲームになっていた状況から、「熟語というブロックを意識しながら、意外な熟語で盤面をひっくり返す」という本来狙っていた面白さを実現できそうです。公平さを犠牲にして面白さを取ります。
=> [実装した](https://qiita.com/unticrice/items/4827b03f0f7c17e8c072)  
実は勝敗判定機能を実装していないのですが、次の改修も熟語の表示とその効果の確認に費やしそうなので、一生実装しなさそうです。