はじめに
みなさんWordleをご存知ですか?
フィードバックをもとに隠された5文字の英単語を当てるゲームです。
https://www.nytimes.com/games/wordle/index.html
こちらのサイトからプレー可能なのでぜひ試してみてください。
ただ課題なのは日次更新で一日一度しかプレーができないこと。
私はこのゲームが苦手で上手になりたいので練習用のWordleを自分で作ることにしました。
完成品
出来上がったものがこちらです。
だいぶ元の作りに寄せられたのではないでしょうか。
作り方
Wordleのルール
まずはWordleのルールの整理からです。
隠された単語は存在する5文字の英単語で、プレーヤーは6回のトライで正解にたどり着くことが出来れば成功です。
プレーヤーが入力する単語は実在する5文字の英単語である必要があります。(abcdeとかだとダメ)
入力をすると5文字のうち、正解単語と位置も含めて合っている文字と位置は合っていないが正解単語に含まれている文字を教えてくれます。
例
正解単語が「snake」の場合、プレーヤーが「skunk」と入力すると
- 「s」は位置も含めて合っている文字
- 「k」と「n」は位置は合っていないが含まれる文字
- 「u」はノーヒット
という判定になります。
以上を踏まえて実際にアプリケーションを作っていきます。
フレームワーク
本家がそうであるように、今回はウェブアプリケーションを作ります。
今回はSolidJSというフレームワークを使って作成しています。
https://www.solidjs.com/
SolidJSはReactに近い形で書けてなおかつ軽量で高速で動くフレームワークです。
細かな良いところは別途記事にするのでそちらをご参照ください。
今回は特にパフォーマンスを求めているわけではないですが、ちょっと興味があったので使ってみました。
環境セットアップ
https://www.solidjs.com/guides/getting-started
こちらにある手順からSolidJSが用意してくれているテンプレートを持っていきます。
個人的にはTypeScriptで実装することをお勧めします。
> npx degit solidjs/templates/ts my-app
> cd my-app
> npm i # or yarn or pnpm
> npm run dev # or yarn or pnpm
上記のコマンドを実行することで簡単にSolidJSのアプリケーションを立ち上げることができます。
今回は細かいことは気にせずApp.tsx
を編集していきます。
単語リストの収集
先ほどルールでも説明しましたが、このゲームでは5文字の英単語というのが鍵になります。
問題の正解が5文字の英単語で、プレーヤーが入力できるのも実在する5文字の英単語のみです。
プログラムでプレーヤーが入力した文字列が実在するかどうかを判定する必要があるので、どこからか5文字の英単語のリストを収集する必要があります。
私は本家のサイトのソースから拝借いたしましたが、やり方は皆さんにお任せします。
最終的には以下のようにwords.ts
というファイルからwords
が出力されている状態にします。
export const words = [...]
中身の実装
ここから早速App.tsx
を触っていきます。
今回はこんな感じの実装にしています。(細かい解説は後述します。)
コンポーネントはパネル、ボタン、キーボードという3つに分けて作成しています。
本家では日次更新で正解単語が更新されて、一日一回しかプレーが出来ない仕様でしたが、それだと練習にならないので単語リストからランダムで正解単語が抽出されるという仕様にしています。(restartボタンでいくらでもやり直せる)
ソース
import {
Component,
createMemo,
createSignal,
For,
onCleanup,
onMount
} from 'solid-js';
import styles from './App.module.css';
import { words } from './words';
const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
const LETTER_COUNT = 5;
const TRYS = 6;
const GREEN = '#6aaa64';
const YELLOW = '#c9b458';
const GRAY = '#86888a';
function isLetter(str: string) {
return str.length === 1 && str.match(/[a-z]/i);
}
function state2Color(state?: number) {
if (state === 0) {
return '#fff';
}
if (state === 1) {
return GREEN;
}
if (state === 2) {
return YELLOW;
}
if (state === 3) {
return GRAY;
}
return '#fff';
}
function initializePanels() {
return new Array(TRYS).fill([]).map(() =>
new Array(LETTER_COUNT).fill(null).map(() => ({
letter: '',
state: 0
}))
);
}
const [word, setWord] = createSignal('');
const [panels, setPanels] = createSignal<
{
letter: string;
state: number;
}[][]
>(initializePanels());
const [currentTry, setCurrentTry] = createSignal(0);
const letter2State = createMemo(() => {
const l2s: Record<string, number> = {};
LETTERS.split('').forEach((l) => {
l2s[l] = 0;
});
const p = panels();
p.forEach((row) => {
row.forEach((n) => {
if (l2s[n.letter] === 0) {
l2s[n.letter] = n.state;
} else if ([1, 2, 3].includes(n.state)) {
l2s[n.letter] = Math.min(l2s[n.letter], n.state);
}
});
});
return l2s;
});
function pickWord() {
const w = words[Math.floor(Math.random() * words.length)];
setWord(w);
}
function keydown(e: KeyboardEvent) {
console.log(e);
e.preventDefault();
e.stopPropagation();
let key = e.key;
const t = currentTry();
const p = panels();
if (key === 'Enter') {
test();
return;
}
if (key === 'Backspace') {
for (let i = 0; i < LETTER_COUNT; i++) {
if (p[t][LETTER_COUNT - 1 - i].letter !== '') {
p[t][LETTER_COUNT - 1 - i].letter = '';
break;
}
}
setPanels(JSON.parse(JSON.stringify(p)));
return;
}
key = key.toLowerCase();
if (!isLetter(key)) {
return;
}
for (let i = 0; i < LETTER_COUNT; i++) {
if (p[t][i].letter === '') {
p[t][i].letter = key;
break;
}
}
setPanels(JSON.parse(JSON.stringify(p)));
}
function test() {
const t = currentTry();
const p = panels();
const w = word();
const row = p[t];
const inputWord = row.map((n) => n.letter).join('');
if (inputWord.length !== 5) {
alert('Not enough letters');
return;
}
if (!words.includes(inputWord)) {
alert('Not in word list');
return;
}
for (let i = 0; i < LETTER_COUNT; i++) {
const l = inputWord[i];
if (l === w[i]) {
row[i].state = 1;
} else if (w.includes(l)) {
row[i].state = 2;
} else {
row[i].state = 3;
}
}
setCurrentTry(t + 1);
setPanels(JSON.parse(JSON.stringify(p)));
if (inputWord === w) {
alert('Success!');
} else if (t + 1 >= TRYS) {
alert(w);
}
}
function restart() {
pickWord();
setCurrentTry(0);
setPanels(initializePanels());
}
const Panel = () => {
return (
<For each={new Array(TRYS).fill(0)}>
{({ _ }, i) => {
return (
<div style={{}}>
<For each={new Array(LETTER_COUNT).fill(0)}>
{({ __ }, j) => {
return (
<div
class={styles.block}
style={{
background: state2Color(
panels()?.[i()]?.[j()]?.state
)
}}
>
<span class={styles.blockText}>
{panels()?.[i()]?.[j()]?.letter}
</span>
</div>
);
}}
</For>
</div>
);
}}
</For>
);
};
const Keyboard = () => {
return (
<div class={styles.keyboard}>
<div>
<For each={['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p']}>
{(l) => (
<div
class={styles.key}
style={{
background: state2Color(letter2State()[l])
}}
>
<span>{l}</span>
</div>
)}
</For>
</div>
<div>
<For each={['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l']}>
{(l) => (
<div
class={styles.key}
style={{
background: state2Color(letter2State()[l])
}}
>
<span>{l}</span>
</div>
)}
</For>
</div>
<div>
<For each={['z', 'x', 'c', 'v', 'b', 'n', 'm']}>
{(l) => (
<div
class={styles.key}
style={{
background: state2Color(letter2State()[l])
}}
>
<span>{l}</span>
</div>
)}
</For>
</div>
</div>
);
};
const Buttons = () => {
return (
<>
<div>
<button
onClick={() => {
test();
}}
>
Enter
</button>
<button
onClick={() => {
restart();
}}
>
Restart
</button>
<button
onClick={() => {
alert(word());
}}
>
Answer
</button>
</div>
</>
);
};
const Content = () => {
onMount(() => {
pickWord();
});
onMount(() => {
document.addEventListener('keydown', keydown);
});
onCleanup(() => {
document.removeEventListener('keydown', keydown);
});
return (
<div>
<Panel />
<Buttons />
<Keyboard />
</div>
);
};
const App: Component<{}> = ({}) => {
return (
<div class={styles.App}>
<Content />
</div>
);
};
export default App;
冒頭部分
冒頭部分は基本ライブラリのインポートと定数の宣言、便利関数の定義という感じですね。
今回は本家に合わせて5文字の英単語6トライという設定になっていますが、この辺の定数をいじって単語リストさえ集めれば違う設定でのプレーも可能になります。
ソース
import {
Component,
createMemo,
createSignal,
For,
onCleanup,
onMount
} from 'solid-js';
import styles from './App.module.css';
import { words } from './words';
const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
const LETTER_COUNT = 5;
const TRYS = 6;
const GREEN = '#6aaa64';
const YELLOW = '#c9b458';
const GRAY = '#86888a';
function isLetter(str: string) {
return str.length === 1 && str.match(/[a-z]/i);
}
function state2Color(state?: number) {
if (state === 0) {
return '#fff';
}
if (state === 1) {
return GREEN;
}
if (state === 2) {
return YELLOW;
}
if (state === 3) {
return GRAY;
}
return '#fff';
}
function initializePanels() {
return new Array(TRYS).fill([]).map(() =>
new Array(LETTER_COUNT).fill(null).map(() => ({
letter: '',
state: 0
}))
);
}
ステートと関数の宣言
ここからはステートの宣言や全体で使う関数を定義していきます。
createSignal
がReactでいうuseState
に当たるのですが、Reactと大きく違う点としてコンポーネントの外側でステートの宣言をしています。
今回はPanel
やKeyboard
といったコンポーネントのステートが密接に絡んでくるので、都度プロパティでやり取りするよりも上のレイヤーでステートを宣言することでどちらのコンポーネントからも参照できるようにした方が便利だったのでこのようにしています。
ReactでいうところのReduxやContextAPIの考え方に近いかと思います。
ソース
const [word, setWord] = createSignal('');
const [panels, setPanels] = createSignal<
{
letter: string;
state: number;
}[][]
>(initializePanels());
const [currentTry, setCurrentTry] = createSignal(0);
const letter2State = createMemo(() => {
const l2s: Record<string, number> = {};
LETTERS.split('').forEach((l) => {
l2s[l] = 0;
});
const p = panels();
p.forEach((row) => {
row.forEach((n) => {
if (l2s[n.letter] === 0) {
l2s[n.letter] = n.state;
} else if ([1, 2, 3].includes(n.state)) {
l2s[n.letter] = Math.min(l2s[n.letter], n.state);
}
});
});
return l2s;
});
function pickWord() {
const w = words[Math.floor(Math.random() * words.length)];
setWord(w);
}
function keydown(e: KeyboardEvent) {
console.log(e);
e.preventDefault();
e.stopPropagation();
let key = e.key;
const t = currentTry();
const p = panels();
if (key === 'Enter') {
test();
return;
}
if (key === 'Backspace') {
for (let i = 0; i < LETTER_COUNT; i++) {
if (p[t][LETTER_COUNT - 1 - i].letter !== '') {
p[t][LETTER_COUNT - 1 - i].letter = '';
break;
}
}
setPanels(JSON.parse(JSON.stringify(p)));
return;
}
key = key.toLowerCase();
if (!isLetter(key)) {
return;
}
for (let i = 0; i < LETTER_COUNT; i++) {
if (p[t][i].letter === '') {
p[t][i].letter = key;
break;
}
}
setPanels(JSON.parse(JSON.stringify(p)));
}
function test() {
const t = currentTry();
const p = panels();
const w = word();
const row = p[t];
const inputWord = row.map((n) => n.letter).join('');
if (inputWord.length !== 5) {
alert('Not enough letters');
return;
}
if (!words.includes(inputWord)) {
alert('Not in word list');
return;
}
for (let i = 0; i < LETTER_COUNT; i++) {
const l = inputWord[i];
if (l === w[i]) {
row[i].state = 1;
} else if (w.includes(l)) {
row[i].state = 2;
} else {
row[i].state = 3;
}
}
setCurrentTry(t + 1);
setPanels(JSON.parse(JSON.stringify(p)));
if (inputWord === w) {
alert('Success!');
} else if (t + 1 >= TRYS) {
alert(w);
}
}
function restart() {
pickWord();
setCurrentTry(0);
setPanels(initializePanels());
}
各コンポーネントの定義
必要な機能は出揃ったのであとはコンポーネントを書いていきます。
この辺の書き方は非常にReactに近いですね。(JSXで書くだけなので)
細かな違いとしてはJSX内でループをしたい際はSolidJSの場合は<For>
というコンポーネントのeach
というプロパティに配列を渡すことで実装します。
UIの部分は本家の仕様に寄せています。
位置が合っている文字は緑色に、位置は合っていないが含まれる文字は黄色にするなどしています。
ソース
const Panel = () => {
return (
<For each={new Array(TRYS).fill(0)}>
{({ _ }, i) => {
return (
<div style={{}}>
<For each={new Array(LETTER_COUNT).fill(0)}>
{({ __ }, j) => {
return (
<div
class={styles.block}
style={{
background: state2Color(
panels()?.[i()]?.[j()]?.state
)
}}
>
<span class={styles.blockText}>
{panels()?.[i()]?.[j()]?.letter}
</span>
</div>
);
}}
</For>
</div>
);
}}
</For>
);
};
const Keyboard = () => {
return (
<div class={styles.keyboard}>
<div>
<For each={['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p']}>
{(l) => (
<div
class={styles.key}
style={{
background: state2Color(letter2State()[l])
}}
>
<span>{l}</span>
</div>
)}
</For>
</div>
<div>
<For each={['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l']}>
{(l) => (
<div
class={styles.key}
style={{
background: state2Color(letter2State()[l])
}}
>
<span>{l}</span>
</div>
)}
</For>
</div>
<div>
<For each={['z', 'x', 'c', 'v', 'b', 'n', 'm']}>
{(l) => (
<div
class={styles.key}
style={{
background: state2Color(letter2State()[l])
}}
>
<span>{l}</span>
</div>
)}
</For>
</div>
</div>
);
};
const Buttons = () => {
return (
<>
<div>
<button
onClick={() => {
test();
}}
>
Enter
</button>
<button
onClick={() => {
restart();
}}
>
Restart
</button>
<button
onClick={() => {
alert(word());
}}
>
Answer
</button>
</div>
</>
);
};
const Content = () => {
onMount(() => {
pickWord();
});
onMount(() => {
document.addEventListener('keydown', keydown);
});
onCleanup(() => {
document.removeEventListener('keydown', keydown);
});
return (
<div>
<Panel />
<Buttons />
<Keyboard />
</div>
);
};
const App: Component<{}> = ({}) => {
return (
<div class={styles.App}>
<Content />
</div>
);
};
export default App;
スタイル
スタイルは正直適当です。なるべく本家に寄せていますが、お好みに合わせてよしなに変更していただければと。
ソース
.App {
text-align: center;
}
.block {
width: 60px;
height: 60px;
display: inline-flex;
margin: 4px;
border: 2px solid #86888a;
vertical-align: top;
}
.blockText {
color: #333;
font-size: 20px;
font-weight: 700;
margin: auto;
}
.key {
width: 40px;
height: 60px;
display: inline-flex;
margin: 4px;
background: #d3d6da;
border: 2px solid #d3d6da;
vertical-align: top;
}
.key > span {
margin: auto;
color: #333;
font-size: 20px;
font-weight: 700;
}
.keyboard {
margin-top: 40px;
}
完成
以下のコマンドでlocalhost:3000に開発環境が立ち上がるかと思います。
npm run dev
最後に
完成品をGithubPagesで公開!と思ったもののさすがに本家に怒られそう&一日一回しかプレーできない醍醐味が薄れてしまいそうなのでやめました。
そもそも知らない単語が多くて練習しても上手くはならなそうですね笑