LoginSignup
8
3

More than 3 years have passed since last update.

【JavaScript】パネルでポンを1ミリも知らないぷよらーが5日で作る話【ゲーム】

Last updated at Posted at 2021-03-10

こんなんなりましたけど

See the Pen PanePon by z0ero (@z0ero) on CodePen.


こちら完成品のサンプルとなっております。
デモ用にAI同士の対戦を延々と流すモードにしてますが実際にはちゃんとプレイできるモードもありますよ。
今回はこれを作る時にどんなことを考えて作ったかを綴っていこうと思います。
これを読んで「俺もなんか作ろうかな」とか思ってくれたらいいなと思います。

前置き

パネポンって知ってる?

パネルでポン(通称パネポン)は昔スーファミで出たパズルゲームです。
タイトルくらいは知ってたけどほとんどやったことないんで今回作る前にちょっと調べました。

基本的なルール

  1. 6 * 12のフィールドに6色のパネルが配置されている
  2. カーソルを操作して隣接した2枚のパネルを入れ替えることができる
  3. 同じ色のパネルが縦か横に3つ以上並ぶと消える
  4. パネルは重力が働いており自然落下する
  5. ある消滅の結果自然落下により新たな消滅が発生した場合連鎖したと言う
  6. パネルは時間とともに下から湧いてくる
  7. 画面上部までパネルが積み重なったら終わり

対戦もある

1人でひたすらパネルを消し続けるモードもあるが基本的にはCPUと対戦してストーリーを進めたり
対人戦で高め合ったりするのがメインらしい。

  • 対戦では消したパネルに応じて相手に『おじゃまパネル』を飛ばす攻撃ができる。
  • おじゃまパネルは複数個連結した状態で上から落下してきて入れ替え操作も並べて消すこともできない。
  • おじゃまは隣接した色パネルを消すことで『解凍』でき、これによって通常の色パネルに変身する。

ぷよぷよかな?

ぷよらーなら既視感のある単語がちらほら出てましたがパネポンがちょっと違うのは『アクティブ連鎖』というやつ。
落ちものパズルって連鎖中はそれ眺めてるのが普通だけどパネポンは常にパネル移動ができちゃいます!
パネルが消えてる間に別のパネルを動かして次の連鎖を作れてしまいます。
ぷよぷよは前に作ったことあったけどこれはまたチャレンジしがいのあるゲームだなということでで作ってることに。

実装編

コアとなるアルゴリズムを実装する

まず

  • 盤面にパネルを並べて
  • それを入れ換えできるようにして
  • 3つ並んだら消えて
  • 下が空白なら上のパネルが落下してくる

ところまで作ってしまいます。
描画等にライブラリの類は使いません。HTML5+JavaScriptでフルスクラッチします。

実装タスクは
パネルクラスを作成 > 6*12個生成して配列に格納 > canvas作ってパネル描画 > ゲームループ作成 >
キー入力作成 > カーソル描画 > パネル落下 > パネル入れ替え > パネル消去 > せり上がり

の順で進めていきました。実は最初一人プレイしか作るつもりなかったんでおじゃまのことは全く考えてません。なんならゲームオーバー処理も作る気ありませんでした(笑)
そして、思い立ってから40分。以下のものが出来上がりました。

See the Pen 40分 by z0ero (@z0ero) on CodePen.

十字キーでカーソルを移動してAでパネルを入れ替えます。
・・・うーんひどい(笑)一応それっぽい動きはできますがバグもあるし本家と違い過ぎるし粗削りですね。

パネル周りの処理

状態遷移

const NEUTRAL = 0;  // 静止状態
const EXTINCT = 1;  // 発火中
const VANISH = 2;   // 消滅中
const FALLING = 3;  // 落下中
const MOVEDOWN = 4; // 落下完了
const SWAPPING_R = 5; // 入れ替え中
const SWAPPING_L = 6; // 入れ替え中
const SWAPPED = 7; // 入れ替え完了

パネルオブジェクトは状態カウンタパネルタイプ(色)だけ持っています。タイプが-1のパネルは空白マスを表します。
パネルには上記に示したような状態があり、空白マスを含めたすべてのパネルがいずれかの状態にあります。
フィールド処理による外的状態遷移と、パネル個別の内的状態遷移があり、前者は他のパネルとの作用によるもので、後者はパネルの内部カウンタが一定に達することによって起こります。

フィールド処理

各パネルの状態更新とパネル同士の相互作用による状態遷移を処理します。
毎フレーム全パネルに対して更新処理を実行するんですが、左下から右上へ向かって処理するようにしてます。
そうすると1回の走査で連鎖判定を除く落下・入れ替え等のすべての状態遷移処理が完了します。

例えば落下処理では、あるパネルが落下状態になった時に列ごと一緒に落下するためには上にあるパネルも
同時に落下状態にしなくてはなりませんが、落下状態かどうかは下のパネルを見てから判断するので
先に下のパネルから更新されていれば同一フレームで落下状態になれるわけです。
↓赤パネルが落下すると同時に上の青と紫も落下しないとパネルの間に隙間ができる
fall.png
入れ替えはやや特殊な操作で、左側のパネルが「スワップ完了フレーム」だったら右パネルと入れ替えしてしまいます。入れ替え操作は必ず左右同時に開始され、入れ替え中のパネルは決して他の状態に遷移しない仕様なので、このタイミングで入れ替えても良いことが保障されています。むしろこれによって右パネルに対しては入れ替え後の特別な処理が不要になり、普通のパネルとして更新処理をすればよくなります。
swap.png

消滅処理

同色で3つ以上連結しているパネルをフィールドから探します。全パネルに対して右2マス下2マスに同色が揃っているかチェックをしているだけですね。消えるパネルさえ検出できればいいのでこれでいけますが、後々連鎖数カウントなどを実装するときにそれではまずいことが分かってきます・・・。

1人プレイ完成

See the Pen 1人プレイ by z0ero (@z0ero) on CodePen.


最初のバグだらけバージョンを実装後に本家の動画を見たりルールを調べたりしているうちに「もうちょいちゃんと作ってみるか」と思い、試行錯誤の末、再現度の上がった1人プレイモードが完成しました。
断っておくと今回はリポジトリすら作らず全部勢いで作ってしまったためあまり途中のコードが残っておりません!マイクロコミット厨も真っ青のチェンジリストは以下です。
  • せり上がり制御
  • Sキーでせり上がりスピードアップ
  • css背景
  • パネルの立体感&柄
  • ゲームオーバー演出&コンティニュー
  • 初期配置&湧きパネルが勝手に連結しないよう配置
  • パネル消滅時に1枚ずつ消えていく演出

確かここまでが1日の成果だったと思います。実装が楽しくて一番モチベがあった段階ですね(笑)

せり上がり

せり上がりは最下段の下から次の1段がじわじわ湧いてくる処理です。次の牌の予告、いわゆるネクストの役割を果たしています。パネルが消えている間などはこのせり上がりが停止するので、その間に次の手を用意できれば盤面が埋まってても意外と復活出来ます。
それとは別にユーザー操作によって高速にせり上げることもできます。この操作は常に可能なので、どんどんパネルを補充してアグレッシブに攻めることができます。
そして予告パネルが完全にせり上がるとすぐに次の予告パネルを抽選するんですが、この時パネルが天井に着いてしまっていたらその時点で負けというルールにしました。この負けの条件が本家見てもよくわからなかったんですよね。合ってるのかな?

ゲームオーバー演出

gameover.png
ゲームオーバー画面です。
上まで積み重なった盤面がボヤ~っとボケていくと同時に手前に「GAME OVER」の文字がボヤ~っと浮かび上がってきます。この演出にはcssのfilter:blur()を使用しています。こういうエフェクトが気軽に使えちゃうのがhtmlで作るメリットですよねぇ。トランジションもcssの機能で行っているのでコードではゲームオーバー時にスタイルを設定しているだけであとは勝手にアニメーションしてくれます。便利だ・・・

あと、この英字フォントなんですがGoogle Fontsさんからお借りしています。最初はデフォルトフォントでやったんですがそうすると幅広すぎて横がはみ出ちゃうし、そもそも線が細いのでダサい。
そういう時に以下のようにcssに1行追加するだけでいい感じのフォントが使えちゃうので非常に便利です。フォントのライセンスは個別で違うので確認してから使用しましょう。

style.css
@import url('https://fonts.googleapis.com/css?family=Anton');

勝手に連結しない配置

ゲーム開始時に盤面の下半分にパネルが敷き詰められた状態で開始する仕様になっていますが、完全なランダム配置だと普通に連結しちゃって勝手にパネルが消えてしまいます。
それを避けるためにパネル配置時に縦と横2マス隣を調べて連結が発生しない色になるよう抽選しています。せり上がりで出てくるパネルも抽選時の盤面を見て連結しないよう抽選してます。一切パネルを操作せず放置すれば連結が起きずにそのままゲームオーバーになるはずです。

CPU対戦対応

See the Pen CPU対戦 by z0ero (@z0ero) on CodePen.


ここから開始時にメニュー画面が付きます。
最大4人まで同時プレイが可能になります。が、対CPUのみしかないのとおじゃまが実装されていないのでただただ他のCPUが自滅するのを待つだけのサバイバルゲームになっています。
ここまでで4日近くかかったと思います。面白さよりめんどくささが勝つ処理が多かったのでモチベと実装速度が落ちてます(笑)
  • エフェクト実装
  • 複数対戦実装
  • CPU実装
  • 連鎖カウント・得点計算・おじゃま計算

エフェクト

ここでのエフェクトはパネルを消した時に飛び散る星や相手のフィールドに飛ぶ攻撃などの賑やかしのことです。
そんなに重要じゃあない癖に見た目の調整が面倒で結構時間とられちゃう箇所なんですよね・・・。
ここまで実装を避けてましたが、対戦モードとなるとやはり派手なエフェクトは必須!(個人の感想です)なので重い腰を上げて作っていきましょう。

まず、表示物1つ1つに対応しているエフェクトクラスというのを作りました。
エフェクトクラスがやっているのは対象となるオブジェクトに対して指定されたプロパティを指定時間かけて指定した値に遷移させていくという単純なものです。対象オブジェクトはどんなオブジェクトでもいいように作ってはいたんですが、結局表示物の位置を動かしたりするのに使うので、今回実装したエフェクトでは全てdomのstyleオブジェクトを指定しました。
styleオブジェクトの値はやっかいで、160pxなどの単位付き数値文字列が多いです。補間したいのは数値の部分だけなので特殊対応が必要になります。全てのstyle値で補間処理を使えるようにしようと思うとrgba(255,128,0,0.5)solid 0px #fffなどが面倒なので今回は「文字列+数値+文字列」というパターンのプロパティだけ対応しました。

        const t = this.ease(this.counter / this.frame);
        for(let p in this.properties){
            const parts = /([^-\d.]*)([-\d.]+)(\D*)/.exec(this.startProperties[p]);
            const value = parseFloat(parts[2]);
            const property = this.properties[p];
            this.target[p] = parts[1] + (isNaN(property) ? property(t, value) : value + t * (property - value)) + parts[3];
        }

これが補間処理です。元のstyle値をいったん正規表現でバラシてから数値部分だけを補間して再結合するという処理をしてます。うーん、イケてない・・・w

エフェクトアニメーションはゲーム側の更新と同期させたかったのでゲームループから更新しています。一方描画は盤面と違ってdom要素で描画しています。最初はエフェクトも素直にcanvas描画で作ろうとしていましたが、canvasで作っていたのは盤面の部分だけ、しかしエフェクトは余裕で盤面外まで飛び出るので、そうすると全画面canvas化しないといけないなぁと思い結局domを使ってしまいました。(今にして思えば全画面canvasありだったな:thinking:
なので、「アニメーションはjs」で「表示はdom」というちょっと古臭い感じに(今と違って昔はこれが当たり前だったのだよ・・・)
これで一応エフェクトは作れたんですがとにかく設計が中途半端に・・・:sob:誰かいい作り方教えて・・・

複数対戦実装

multi.png
さて、めんどくさそうな複数対戦です。
が、実はJavaScriptの仕様のおかげで図らずもここまでの実装だけでマルチプレイヤーは実現できています。
ここまでのゲームコードは大体以下のような構造で実装していました。

function start() { // エントリ関数
    // canvas等dom要素作成
    document.body.append(...);
    ...
    // ゲーム変数初期化
    ...
    setInterval(() => {
        // ゲームループ
        ...
    }, 16);
}

ゲーム変数は1プレイヤー分しか宣言してないですし、ゲームループも1人分の更新処理しかしていません。しかし、それらはstart関数を呼び出すたびにインスタンス化されsetIntervalに渡しているクロージャによって束縛されるため実質ゲームインスタンスの生成になっているわけです。つまり以下のようなGameクラスを実装しているのにほぼ等しいことになります。

class Game() {
    constructor() {
        // dom作成/ゲーム変数初期化
        ...
    }
    update() {
        // ゲームループ
        ...
    }
}
// start()の呼び出しはnew Game()に相当する
let game = new Game();
setInterval(game.update.bind(game), 16);

そのため複数人対戦にするためにあと必要なことは、インスタンス間の調停を行うゲームコントローラと横並べにするレイアウト処理の実装だけになります。
そこで、これまでの処理を以下のように作り変えます。(実際のコードとは多少異なります)

function start(controller, panel) { // エントリ関数
    // canvas等dom要素作成
    panel.append(...);
    ...
    // ゲーム変数初期化
    ...
    controller.addEventListener('frame', () => {
        // ゲームループ
        ...
    });
}
// コントローラにゲームインスタンスを追加
let controller = new GameController();
start(controller, createElement('div'));   // プレイヤー1
start(controller, createElement('div'));   // プレイヤー2
...

ゲームコントローラクラスを作成して各インスタンスの勝敗状況などを監視したり、インスタンスからのメッセージ(攻撃)などを受け付けることにしました。ゲームループもsetIntervalではなくコントローラのフレームイベントで処理するようにします。

また、直接document.bodyに追加していたcanvasなどを与えられた親要素に追加するようにして、各インスタンスのレイアウト処理をコントローラに管理させることにしました。

GameControllerクラス

  • 各インスタンスとそれぞれのルートdiv要素を管理したりそのレイアウト処理をする
    • レイアウト処理では拡縮にstyle.transform.scaleを使用しています。そのため、canvasがドットバイドットで表示されないので、無駄に重かったり(縮小時)見た目が汚くなったり(拡大時)しちゃいます。ははは(放棄)
  • あるプレイヤーの攻撃を別のプレイヤーへ飛ばす
    • 攻撃を飛ばす処理ですが、本家ではどういうアルゴリズムで攻撃を飛ばしてるのか全然わかりませんでした。ぷよぷよでは1つの攻撃は他プレイヤー全員に攻撃力が分配されるんですが、パネポンでは1撃は誰か1人に飛んでいるっぽかったので今回は完全にランダムで1人選んで飛ばしています。
  • 各インスタンスの死亡イベントを受け取ってゲーム全体の勝敗判定を行う
    • まず1人プレイだった場合、死亡時の「GAME OVER」判定だけ行います。2人以上の場合「WIN」「LOSE」と同一フレーム決着の「DRAW」を判定します。判定結果はsettleイベントで各インスタンスに通知しています。
  • コンティニューや毎フレーム更新のイベント
    • GameControllerはEventTargetを継承しているのでdispatchEventメソッドで各インスタンスにイベントを投げることができます。これによって同期をとることができます。
  • エフェクト管理
    • エフェクト管理は各インスタンスでは行っておらずコントローラが一括して行います。攻撃エフェクトはコントローラが発火していますし、ゲームインスタンスは勝敗決定後に毎フレーム更新をしなくなるので、インスタンスでエフェクトの更新を行うと表示途中のエフェクトが画面に残ってしまうためです。

CPU実装

さぁ対戦を実装したからには戦う相手が必要です。対人戦ってサクッと作れないかなと思い調べてみたんですが、ブラウザ上でP2P対戦は無理(?)っぽいし、PlayFabとかのサービス使う方法もよくわかんなかったんで、、、もうCPU戦でいいやと。(対人誰か作って)

AIアルゴリズムが思いつかないのでまずはゲーム操作処理の抽象化を行いました。
今まではユーザーのキー入力を見て直接カーソル移動と入れ替え操作をしていたんですが、間にPlayerクラスというのを挟んでこれを通して操作することにします。

    class Player {
        moveUp = false;       // カーソル上移動
        moveDown = false;     // カーソル下移動
        moveRight = false;    // カーソル右移動
        moveLeft = false;     // カーソル左移動
        swap = false;         // パネル入れ替え
        haste = false;        // スピードアップ
        reset = () => {};     // 状態初期化
        update = () => {};    // 更新関数
    }
    player = new Player();
    player.update = /*任意の操作処理*/

ゲームの操作は上6つのフラグで行えます。そしてこれらのフラグを設定するupdate関数にどんな処理を割り当てるかでユーザー操作とAI操作を切り替えることができます。ゲームインスタンス生成時の引数としてプレイヤータイプが渡されるのでそれに応じてupdateの内容を切り替えています。

AIのアルゴリズム

適当にAI実装していきます。基本的には自分がプレイしてるときに考えてることをコード化する感じになります。

サーチフェーズ

消せそうなパネル列を見つけるフェーズです。フローをmermaidで書いてみたんですがレイアウトが見づらすぎて草生えました。
flowchart.png
ある程度高く積み上がってたら先にそれを均して、それが無ければ消しに行くシンプルなAIです。しかも縦の連結しかサーチしてません笑。基本的に落ちものパズルのAIって考え方は比較的簡単だけど実装が面倒なんですよね(言い訳)
そして最終的に入れ替え操作をする座標をリスト化した「コマンド」を作成して移動フェーズへ遷移します。ちなみに取れる行動が見つからなかった場合ランダムな座標で入れ替え操作を行いまくる発狂状態になります。
あとチャートに入れ忘れてたんですが一定以上積み上がってなかった場合には常にスピードアップ操作を行ってアグレッシブにパネルをツモりにいきます。

ムーブフェーズ

サーチフェーズで決定したコマンドを実行するフェーズです。

コマンド
(3,9)
(4,8)
(0,7)

コマンドは上記のような座標リストです。
コマンドから1つずつ座標を取り出してカーソルがその位置へ移動するようmove~系のフラグを設定します。コマンドの座標にたどり着いたらそこで入れ替え操作が可能になるまで待機して、可能だったら入れ替え操作を実行します。これをコマンドが空になるまで繰り返します。全ての移動が完了したら再びサーチフェーズへ遷移します。

CPUの強さ

CPUには強さに関わる2つのオプション設定があり1つはウェイトでもう1つは貪欲さです。
ウェイトはムーブフェーズにおける待機フレームを意味します。つまり操作の速さを調整することができます。
貪欲さはサーチフェーズで消せるパネルを探すときに最低何連結で探すかを調整するものになります。パネポンは3連結で消しただけでは攻撃が発生しません。なので貪欲値を4または5に設定しておくことで常に攻撃的なアクションを取ることになります。ただしピンチなときは3連結も許容することで保身行動を取らせます。

連鎖カウント・得点計算・おじゃま計算

同時消しは同時に消えたパネルの数でボーナス点が発生することです。連鎖はあるパネルの消滅で落下したパネルが更に消滅することを言います。ここまでの実装では同時消しのパネル数が正しくカウントできていないのでまずそこを修正します。
この修正は簡単で消えたパネルにフラグを付けておくことで同じパネルを2度カウントしないようにすればいいだけです。
同時消し.gif

↑赤のパネルがクロスしていて真ん中のパネルは2つの結合で共有されているが同時消しカウントは5。

連鎖はちょっとややこしいです。ちなみに今回本家の仕様をちゃんと調査せずに独自解釈で実装したので本家と挙動が異なります。
連鎖.gif
上の例では2連鎖目が同時消しになっています。2連鎖ボーナス×1と6個消しボーナス×1がつきます。
3連鎖は黄色と紫の2回あります。どちらも連鎖の原因が2連鎖目のパネルだったためタイミングや場所が異なっても3連鎖目として判断されます。
同様に4連鎖目の青は3連鎖目の黄色が原因となるため4連鎖目として判断されています。
この挙動を実装するためにパネルにコンボIDコンボカウントを持たせました。全ての1連鎖目にはコンボIDの新規割り振りが行われ、それを起点とする連鎖にはこのIDが受け継がれていきます。これにより一連の連鎖を区別できるようになります。同時にコンボカウントも1増やしながら継承していくことで正しく連鎖数をカウントできるようになります。

おじゃま実装

カミングスーン

小ネタ

エフェクト3種

エフェクトの動きやcssの作り方について

星エフェクト

See the Pen star by z0ero (@z0ero) on CodePen.


星はsvgで四芒星を作ってエフェクト発生時にsvg.cloneNode(true);をした要素を動かしています。元となるSVGは1ピクセルのサイズで作っているので実際に欲しいサイズにスケールをかけて使います。スケールするとストロークの太さも太くなってしまうのでstrokeWidth = 1/sizeを設定して常に1ピクセル幅のストロークになるようにしています。

コンボエフェクト

2連鎖以上すると消えたパネルの辺りに連鎖数の数字が現れて何度かバウンドします。このバウンドはイージング関数で実装しています。

See the Pen bounce by z0ero (@z0ero) on CodePen.


放物線自体は正弦曲線を用いています。バウンドするたびにその高さが1つ前の半分になり、ぴったりN回バウンドしたところで終了します。上のデモでは左からバウンド回数が2~6回となっています。バウンド回数が違っても必ず同じ時間で終了するように各バウンドにかかる時間の比率を計算しています。
このイージング関数は実はここ以外にもおじゃま落下時の盤面の揺れにも使用しています。汎用性が高く制御もしやすいので非常に便利ですね。

攻撃エフェクト

なんらかの攻撃が発生すると画面を縦横無尽に飛んでいく光の玉です。

See the Pen orb by z0ero (@z0ero) on CodePen.


見た目自体はcssのbox-shadowプロパティなどを組み合わせて作っています。動きは2次ベジェ関数を使用しています。
2次ベジェ
t => (1 - t) * (1 - t) * from + 2 * (1 - t) * t * mid + t * t * to;

Bezier.gif
tを0~1に変化させたとき座標fromからtomid付近を通りつつ曲線移動する軌跡を得られます。
今回の攻撃エフェクトはフィールド上で発生してから誰かのフィールド上部へ飛んでいく動きなのでfromとtoはそれになります。midですが以下の式で求めました。

    const vx = toX - fromX;
    const vy = toY - fromY;
    const midX = toX - vx;
    const midY = toY - vy * 0.5;

fromから見たtoと逆の位置に設定しています。こうすることで飛んでいくときに少しだけ"溜め"が入り攻撃に重みが感じられるようになります。

おじゃま予告

stock.png

控えてるおじゃまパネルの量と数を予告するフィールド上部に出るやつですね。本家では3~5個までのおじゃまは小さいブロックをとかみたいに並べて表示して、6個以上の場合は上図のようにでかいパネルの上に×段数を表示していました。
なので最初同じように作ろうと思ったんですが、フィールドサイズを変更したときにうまくいかないことに気づいたため上図左のように細いバーとして表示することにしました。うまくいかないというのはおじゃま実装のところでも書いたようにおじゃまのパケットはフィールド幅/2~フィールド幅-1フィールド幅 * 段数単位であるのでフィールド幅が大きい時に本家の表示方法では細かい四角が大量に重なってしまい訳が分からなくなります。
この細いバーはその長さでおじゃま量を表しています。(ただしその微妙な違いを読み取るのは至難の業:sweat_smile:

パネルの細かいアニメーション

パネルにはいくつか細かい動きが付いています。

感想

今回の製作を通して初めてパネポンの面白さに気づけましたね。これまで1回くらいしか触ったことなかったんですが、デバッグプレイして上達してくるといろいろテクニックがあることに気づいて奥が深いなという印象でした。更にこの独特なリアルタイム性のおかげで連鎖作るのがぷよぷよより難しいです!ぜひ皆さんに遊んでみてほしいですねー(ただ個人的に延々パネルを繋げる作業が辛くなってきて最終的にはAI同士の対戦眺めてるのが一番おもしれーなってなっちゃいましたテヘ)
あとゲーム製作的な面でいうと1ファイルjsゲームの面白さっていうのを伝えたいですね。ランタイムを入れる必要も無ければソースと実行ファイルの配布が同時に行えるので、ゲーム作り初心者から中級者以上までゲームプログラミングの根本的な面白さを楽しめると思います。(例:7行テトリスなど)みんなも腕試しでゲームプログラミング、しよう!

あとがき:
この記事書くのめっちゃ時間かかりました・・・。前回の記事からすごい久々だったんでリハビリがてらライトな内容で気軽に書こうと思ったんですけど、すごい長くなっちゃって途中で後悔してました(ヽ''ω`)一応今後もこんな感じでネタがあれば記事書いていこうとは思いますが短くまとめていきたいですね。

8
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
3