LoginSignup
195
162

More than 5 years have passed since last update.

ドラクエ4風のAIバトルを深層学習で作ってみた

Last updated at Posted at 2019-05-08

はじめに

みなさんはRPG、そしてAIバトルと聞いて、何を想像しますか?
著者にとって真っ先に思い出されるのは、それは、1990(平成2年)に当時のエニックス社から発売されたドラゴンクエストIV(以下ドラクエ4)に他なりません。バトル上での経験がその後のプレイヤーの行動パターンに影響を与えるという、その斬新なシステムに衝撃を受けた記憶が今でも鮮明に残っています。

さて、ドラクエ4はバトル上の経験を学習して、次の行動に影響を与えるという点で、ある意味で現代の機械学習に通ずるものがあります。もちろん1983年(昭和58年)発売のファミリーコンピュータ(1.79MHzの8bit機)では、スペック上の理由により高度な推論は到底望めません。ですが令和の今なら深層学習(ディープラーニング)を使って、もっと思い切ったことが出来るよね、というモチベーションで本記事を書いてみました。

なお、私自身はRPGおよびゲームジャンルにおけるAIの業務経験はないため、的外れなことを書いているかもしれませんが、その点はご容赦ください。また国民的RPGであるドラクエを題材として選択したのは、ゲーム内容が広く知られているからであり、他意はございません。

備考

ソースコードを下記レポジトリで公開しております。Python 3.6, Tensorflow 1.13で動作検証しております。

stakemura/rpg-ai-battle: AI battle simulator for turn based RPG

また、Colaboratoryの再現サンプルになります。

RPG AI Battle.ipynb - Colaboratory

また本記事は、ぴぽや https://pipoya.net/の素材を利用しております。

強化学習か機械学習か?

DeepMind社のAlphaZeroに代表される通り、近年の強化学習の進歩は目覚しいものがありますが、本稿では説明が容易な(教師あり)機械学習によるAI実装を解説します。具体的にバトルの自動化を考えた場合に、以下の選択問題を解く必要が出てきます。
・「たたかう」べきか「にげる」べきか
・「たたかう」なら、どの敵を優先して倒すべきか
ここで前者を2値分類、後者を回帰と捉えることでシンプルな機械学習の問題に置き換えることができます。

なおシンプルな手法を採用することは、推論を軽くし、実際にAIをゲームに導入しやすくする上で重要なポイントとなります。なぜなら推論をサーバー側で実行するとしても、ゲームのように多人数の同時参加が見込まれるアプリの特性上、許容される計算量は必然的にシビアなものとなるからです。またエッジ(ゲーム端末)側で実行する場合は、計算に割り当てられるハードウェアリソースが限られる上にサーバーのようにスケールさせることができないため、なおさらシビアなものとなります。このような事情から、なるべくシンプルで軽量な手法から紹介します。

深層学習を使うべきか?

最初に弁解しなくてはならないのですが、本稿はTensorflowという深層学習フレームワークを使うものの、深層学習と言えるほどの深いニューラルネットワークは用いません。よってタイトルに偽りありと非難されても仕方ないのですが、もし本稿が好評で多くのフィードバックを頂けたら、将来的に例えばモンスター画像から強さを推定するといった、深層学習の強みを生かせる挑戦もしていきたいと考えておりますので、どうか温かい目で見てやってください。

ただ深層学習自体はともかく、深層学習フレームワークを使うメリットは大いにあると自分は考えます。まず、学習についてはTensorBoardのようなリッチなUI付きモニタリングツールが使えますし、モデリングはCloud AutoMLのような自動最適化サービスが将来的に味方してくれるでしょう。

またサーバー側の推論に関しては、gRPC/RESTに対応したC++実装の高速なRPCサーバーであるTensorFlow Servingが使えますし、エッジ側の推論についても、スマートフォンに最適化されたTensorflow Liteや、CPU(x86/ARM)やGPU(CUDA/ROCm/OpenGL/OpenCL/Metal)バックエンドに対応したApache TVMを活用することができます。なおTVMは過去にQiitaで取り上げたので、宜しければご覧ください。こういったエコシステムの存在こそ、深層学習フレームワークの強みと言えます。

深層学習コンパイラTVMと主要深層学習フレームワークをColaboratoryで使い倒そう

また最近では、Unity Inference Engineが発表されましたね。下記リンク先の記事では、Unity Inference Engineを組み込んだXbox Oneのゲームが動画付きで掲載されており、クロスプラットフォーム対応を今後の目標として掲げています。Unityの機械学習・強化学習技術への意気込みが伺えます。

Unity ML-Agents Toolkit v0.7 ― クロスプラットフォームな推論の実現に向けた飛躍

RPGのバトルを定義する

本稿では、以下のようにドラクエ4のバトルを思いっきり単純化したものを想定します。

用語集

日本語名 英語名
バトル Battle 戦闘のこと。2チーム以上の参加が前提。
チーム Team 複数のユニットで構成される。自チームを味方、他チームを敵とみなす。
ユニット Unit バトル上で独立して行動可能な最小単位
陣営 Side プレイヤー側とモンスター側の2種類に大別される
プレイヤー Player プレイヤー側に属すユニットのこと
モンスター Monster モンスター側に属すユニットのこと
ターン Turn 戦闘における区切り。全ユニットの行動が終わると次のターンへ。
コマンド Command 各ユニットの行動を決める指示のこと
戦闘AI Gambit コマンドを自動的に決めるプログラムのこと
攻撃 Attack 相手ユニットにダメージを与えること
ソース Source 攻撃元となるユニットのこと
ターゲット Target 攻撃対象となるユニットのこと
ステータス Status ユニットごとに存在する、以下4種類のパラメータ(能力値)の集合
HP HP ダメージを受けると下がり、0以下になると行動不能になるパラメータ
ちから ATK 敵への与ダメージに影響するパラメータ
みのまもり DEF 敵からの被ダメージに影響するパラメータ
すばやさ SPD 行動順序に影響するパラメータ

バトルの条件

  • 2つの「チーム」に分かれて、「バトル」が開始
  • 各チームに所属するすべてのユニットは、1ターン内に1回「行動」できる
  • ただし行動を決める「コマンド」は「たたかう」一択で、行動内容は固定
  • たたかうは、敵ユニット1体に対し、1回攻撃することを意味
  • ユニットのHPが0以下になると、以降行動できず、攻撃対象にならない
  • ユニットの行動順序は、ターンの頭に後述の「計算式1」で求めた値から決定
  • ユニットの行動内容によって、行動順序が変わることはない
  • ユニットに与えるダメージは、後述の「計算式2」で求める
  • モンスターの「戦闘AI」は、ランダムに行動可能なプレイヤーを選び攻撃
  • どちらかのチームが全滅(行動可能なユニットがいなくなる)すると、バトルは終了
  • バトルごとに、全ユニットの「ステータス」が「リセット」される
  • リセット直後の「HP」を、後述する「計算式3」で求める

計算式1

ユニットの行動順序は、以下の式で求めた値の大きい順で決まるものとします。

SPD × [0.5~1.0]

[0.5~1.0]は0.5以上1.0以下の一様分布に従う乱数を意味しています。すばやさSPDが高いほど、先に行動できる確率は高いことを意味します。なお内部計算は浮動小数点数を用い、得られた値も浮動小数点数として比較します。なお、求めた値が等しいケースは、発生確率が著しく低いためここでは考慮しません。

計算式2

続いてダメージの式です。

MAX(0, (ATK - DEF/2)/2 × [7/8~9/8])

ちからATKは攻撃するユニット、みのまもりDEFは攻撃されるユニットのパラメータに紐づいており、計算式1同様に一様分布に従う乱数を用います。なおダメージについては、小数を切り捨てたものを相手のHPから減算するものとし、ダメージの最小値は0とします。

計算式3

続いてHPの式です。モンスターの場合、本来のHPをBASE_HP、リセット直後のHPをMAX_HPとすると次のように可変となります。

MAX_HP = BASE_HP × [0.85~1.0]

なおプレイヤーのMAX_HPは、BASE_HPそのものとなり、固定値とします(本来RPGは、レベルアップの概念があるためこの値は可変ですが、本稿では単純化します)

各チームのステータス表

まず、モンスター側のチームから(HPはBASE_HPとみなしてください)

name HP ATK DEF SPD
devil 71 54 24 38
guardian 76 112 42 75
ogre 158 60 58 12

続いて、プレイヤー側のチームから

name HP ATK DEF SPD
knight 104 65 65 32
archer 76 78 40 50
thief 89 52 37 68

バトルのシミュレーション

上記の条件およびチーム構成で、仮にプレイヤーの「戦闘AI」をモンスター同様に「ランダムで攻撃対象を決める」としたら、どちらが優勢でしょうか?レポジトリに含まれる evaluate_battle.py のうち、下記のコードが該当するシミュレーションとなり、NaiveGambitクラスが戦闘AIの実体となります。

import argparse
import logging
from typing import List, Tuple, Counter as _Counter
from collections import Counter

import tensorflow as tf

from game import logger
from game.status import Side, Status, statuses
from game.unit import Unit
from game.team import Team
from game.log import TurnLog
from game.battle import Battle
from game.gambit import Gambit, NaiveGambit, CunningGambit, MLbasedGambit


def simulate_battle(teams: List[Team],
                    gambits: List[Gambit],
                    n_battle: int = 1000
                    ) -> Tuple[_Counter[int], List[TurnLog]]:
    win: _Counter[int] = Counter()
    logs: List[TurnLog] = []

    # バトルを複数回シミュレーション
    for n in range(0, n_battle):
        battle = Battle(n)
        result = battle.simulate(teams, gambits, logs)
        win[result] += 1

    return win, logs


if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('-v', '--verbose', action='count', default=0)
    parser.add_argument('-q', '--quiet', action='count', default=0)
    parser.add_argument('-d', '--debug', action='store_true', default=False)
    parser.add_argument('-n', '--n_battle', action='store', default=1000)
    args = parser.parse_args()

    dbg_format = '%(levelname)-8s %(module)-16s %(lineno)4s: %(message)s'
    logging.basicConfig(
        level=logging.WARN + 10 * (args.quiet - args.verbose),
        format=dbg_format if args.debug else None
    )
    tf.logging.set_verbosity(tf.logging.ERROR)

    teams: List[Team] = [
        # プレイヤーチームのユニット構成
        Team(0, [
            Unit(statuses[Side.PLAYER][0]),
            Unit(statuses[Side.PLAYER][1]),
            Unit(statuses[Side.PLAYER][2]),
        ]),
        # モンスターチームのユニット構成
        Team(1, [
            Unit(statuses[Side.MONSTER][0]),
            Unit(statuses[Side.MONSTER][1]),
            Unit(statuses[Side.MONSTER][2]),
        ]),
    ]

    gambits: List[Gambit] = [
        # プレイヤーの戦闘AI
        NaiveGambit(),
        # モンスターの戦闘AI
        NaiveGambit(),
    ]

    # バトルシミュレーション1 (プレイヤーの行動を、ランダムで決める)
    win, logs = simulate_battle(teams, gambits, args.n_battle)
    print("Player(Naive) win rate %7.5f%%" % (100.0 * win[0] / sum(win.values()),))

この例では1000回、同一条件でバトルをシミュレーションしていますが、一例として次のような出力結果になりました。本設定では何回繰り返しても、プレイヤー側の勝率は20%前後であることを確認しています。

Player(Naive) win rate 17.20000%

なお、本ソースコードは-vvオプションを付けることで、バトルの詳細を下記のように標準出力に流します。

## Battle No.0
### Turn No.1
      Attack guardian:1 ->   archer:0 damage=55 
      Attack    thief:0 ->    devil:1 damage=18 
      Attack   archer:0 ->    devil:1 damage=36 
      Attack    devil:1 ->    thief:0 damage=15 
      Attack   knight:0 -> guardian:1 damage=23 
      Attack     ogre:1 ->    thief:0 damage=21 
### Turn No.2
      Attack    thief:0 -> guardian:1 damage=14 
      Attack guardian:1 ->    thief:0 damage=55 defeated
      Attack   archer:0 ->     ogre:1 damage=27 
      Attack   knight:0 ->     ogre:1 damage=17 
      Attack    devil:1 ->   archer:0 damage=19 
      Attack     ogre:1 ->   archer:0 damage=22 defeated
### Turn No.3
      Attack guardian:1 ->   knight:0 damage=47 
      Attack    devil:1 ->   knight:0 damage=11 
      Attack   knight:0 -> guardian:1 damage=19 
      Attack     ogre:1 ->   knight:0 damage=15 
### Turn No.4
      Attack guardian:1 ->   knight:0 damage=44 defeated

それでは機械学習を使って、プレイヤー側の勝率を改善できるか考えてみましょう。

基本的な戦略を考える

機械学習を使って戦闘AIをどう改善するか?今回の設定では、攻撃対象の選定が唯一の修正可能な要素となりますので、取るべき戦略は「最も優先度が高い敵を選んで各個撃破する」の一言に尽きるでしょう。その優先度を求める評価関数をどう決めるかが攻略の鍵となります。

評価関数を模索する上で有用なアプローチは問題の単純化です。仮に、自軍のユニットは1体、敵軍のユニットは2体、攻撃は自分が先に行えるものとし、またダメージを固定値に置き換えた場合、プレイヤーはどちらの敵を優先して攻撃した方が、結果的に敵軍から受ける総ダメージを最小化できるでしょうか?

観測可能なパラメータを次のように定義します。

  • HP : 敵の最大HP
  • damage_given : 敵への与ダメージ
  • damage_taken : 敵からの被ダメージ

そして求めたいパラメータを次のように定義します。

  • priority : 敵の優先度

ケース1

以下のケースでバトルをシミュレーションすると、敵Aへの攻撃を優先した時の、最終的に自軍が受ける総ダメージは 16、一方敵Bの場合は 18 となります。

name HP damage_given damage_taken priority
EnemyA 10 4 4 1.33
EnemyB 6 2 3 1.00

ケース2

一方次のケースでは、敵Aの場合が 20、敵Bが19と、ケース1とは反対の結果になります。

name HP damage_given damage_taken priority
EnemyA 10 4 4 1.33
EnemyB 6 3 3 1.50

以上、ケース1においては敵Aを、ケース2においては敵Bを優先した方が自軍が受ける総ダメージは少ないということになります。それでは、優先度を上記のパラメータから直接求めることは可能でしょうか?まずキーとなるのは、敵からの被ダメージ、これが大きいほど脅威となりますので優先順位を上げるべきです。しかし、もし被ダメージが同じなら、早く倒せる敵を優先した方が、最終的に受ける総ダメージを最小化できます。この2点を考察すると、priorityは例えば次のように定義できます。

priority = damage_taken / (HP / damage_given)

すばやさが評価に入っていないなど、式自体の精度に課題は残されていますが、この点はまた機会を改めて考察したいと思います。

戦略が正しいか検証する

上記の戦略が正しいかどうか評価するため、「モンスターのパラメータは観測可能である」というチートを使って検証したいと思います。以下は、先ほどのランダムに敵を攻撃するNaiveGambitクラスと、戦略に基づいて敵を各個撃破するCunningGambitクラスのコードになります。

@dataclass
class NaiveGambit(Gambit):
    """
    ランダムに行動する
    """

    def select_command(self,
                       source: Unit,
                       units: List[Unit],
                       commands: List[Command]
                       ) -> Tuple[Command, List[Unit]]:
        command = commands[0]  # 一択のためコマンドは固定
        targets = command.targets(source, units)
        targets[:] = [random.choice(targets)]
        return command, targets


@dataclass
class CunningGambit(Gambit):
    """
    相手ステータス丸見えの状態で、最適な行動を行う
    """

    def estimate_priority(self, source: Unit, target: Unit):
        # 優先度の推定
        damage_taken = AttackCommand.estimate_damage(target.attack, source.defence)
        damage_given = AttackCommand.estimate_damage(source.attack, target.defence)
        return damage_taken / target.life_max * damage_given

    def select_command(self,
                       source: Unit,
                       units: List[Unit],
                       commands: List[Command]
                       ) -> Tuple[Command, List[Unit]]:
        command = commands[0]  # 一択のためコマンドは固定
        targets = command.targets(source, units)
        targets.sort(key=lambda t: self.estimate_priority(source, t))
        return command, targets[-1:]

CunningGambitをシミュレーションすると、安定してプレイヤー側は90%前後の勝率になることがわかります。

    # バトルシミュレーション2 (プレイヤーの行動を、チートして決める)
    gambits[Side.PLAYER] = CunningGambit()
    win, logs = simulate_battle(teams, gambits, args.n_battle)
    print("Player(Cunning) win rate %7.5f%%" % (100.0 * win[0] / sum(win.values()),))
Player(Cunning) win rate 90.90000%

機械学習で戦闘AIを組んでみる

それでは、機械学習による戦闘AIを実装してみましょう。まず、前提条件を次のように置きたいと思います。

  • 攻守ユニットのIDは観測可能である
  • ダメージおよび攻撃後のターゲットの状態(倒したかどうか)は観測可能である
  • プレイヤーのステータスは観測可能だが、モンスターのステータスは観測不能である
  • 計算式1-3についても未知とし、計算式に特化したモデル設計は行わない
  • 将来的に未知のモンスターに対応できるよう、モンスターIDごとに機械学習のモデルを分割しない

以下は、学習用のコードを一部抜粋したものです。モンスターのIDと、対応するプレイヤーのステータスパラメータから、被ダメージ、与ダメージ、最大HPを推定できるよう、バトルログを学習して3種類のモデルを出力する仕様となっています。

    # 学習を始める
    logger.info("# Training")

    EPOCHS = 50
    BATCH_SIZE = 128

    ai = MLbasedGambit(is_training=True)
    mb = master_bar(range(len(ai.estimators)))
    mb.names = [e.name for e in ai.estimators]
    n_class = len(statuses[Side.MONSTER])
    for i, reg in zip(mb, ai.estimators):
        logger.info('## Train %s %s' % (reg.name, reg.get_tag()))

        if i == 0:  # 被ダメージ
            _logs = list(filter(lambda l: l.target_side == Side.PLAYER, logs))
            y = np.array([[log.damage] for log in _logs], dtype=np.float32)
            Xv = np.array([[log.target_def] for log in _logs], dtype=np.float32)
            Xc = np.array([ai.id2vec(log.source_id) for log in _logs], dtype=np.float32)
        elif i == 1:  # 与ダメージ
            _logs = list(filter(lambda l: l.source_side == Side.PLAYER, logs))
            y = np.array([[log.damage] for log in _logs], dtype=np.float32)
            Xv = np.array([[log.source_atk] for log in _logs], dtype=np.float32)
            Xc = np.array([ai.id2vec(log.target_id) for log in _logs], dtype=np.float32)
        elif i == 2:  # 最大HP
            _logs = list(filter(lambda l: l.source_side == Side.PLAYER, logs))
            y = np.array([[log.damage_cumsum] for log in _logs], dtype=np.float32)
            Xv = np.array([[log.defeated] for log in _logs], dtype=np.float32)
            Xc = np.array([ai.id2vec(log.target_id) for log in _logs], dtype=np.float32)

        Xv_train, Xv_test, Xc_train, Xc_test, y_train, y_test = train_test_split(
            Xv, Xc, y, test_size=0.125, random_state=2)

        losses = list()
        epochs = list()
        for epoch in progress_bar(range(EPOCHS), parent=mb):
            result = reg.model.fit([Xv_train, Xc_train], y_train,
                                   batch_size=BATCH_SIZE,
                                   epochs=1, verbose=0)
            losses.append(result.history['loss'][0])
            epochs.append(epoch+1)
            graphs = [[epochs, losses]]
            x_bounds = [1, EPOCHS]
            y_bounds = [0, None]
            mb.update_graph(graphs, x_bounds, y_bounds)
            if epoch % 5 == 0:
                logger.info('Epoch %i: %.5f MSE' % (epoch + 1, losses[-1]))

        average_loss = reg.model.evaluate([Xv_test, Xc_test], y_test,
                                          batch_size=BATCH_SIZE,
                                          verbose=0)
        mb.write('%s: Average loss %f' % (reg.name, average_loss,))

        reg.save_model()

出力したモデルをNetronで表示すると、次のようになります。

image.png

バトルに参加するユニット構成が固定なら、そもそも学習するまでもなく、攻守ユニットIDごとにダメージの平均値を記録するだけでもよいのでは?という声もあるかと思います。しかし将来プレイヤーのステータスがレベルアップによって変動しても対応できるように、汎用的を意識してこのような実装になっています。

機械学習ベースの戦闘AIの性能を検証する

以下は、機械学習ベースの戦闘AIを反映した、MLBasedGambitのコードになります。

@dataclass
class MLbasedGambit(Gambit):
    """
    機械学習にもとづいて最適な行動を推測
    """

    is_training: bool = False
    source_side: Side = Side.PLAYER
    target_side: Side = Side.MONSTER
    estimators: List[Estimator] = field(default_factory=list)
    n_class: int = field(init=False)

    def __post_init__(self):
        self.n_class = len(statuses[self.target_side])
        self.estimators = [
            Estimator('TakenDamage', self.n_class),
            Estimator('GivenDamage', self.n_class),
            Estimator('MaxHP', self.n_class),
        ]

        for reg in self.estimators:
            if not self.is_training:
                reg.load_model()
            else:
                reg.build_model()

    def id2vec(self, _id: int):
        return np.eye(self.n_class)[_id]

    def estimate_priority(self, source: Unit, target: Unit):
        # 被ダメージの推定
        Xv = np.array([[source.defence]], dtype=np.float32)
        Xc = np.array([self.id2vec(target.id)], dtype=np.float32)
        damage_taken = self.estimators[0].model.predict([Xv, Xc])

        # 与ダメージの推定
        Xv = np.array([[source.attack]], dtype=np.float32)
        Xc = np.array([self.id2vec(target.id)], dtype=np.float32)
        damage_given = self.estimators[1].model.predict([Xv, Xc])

        # 最大HPの推定
        Xv = np.array([[1]], dtype=np.float32)
        Xc = np.array([self.id2vec(target.id)], dtype=np.float32)
        life_max = self.estimators[2].model.predict([Xv, Xc])

        # 優先度の推定
        return damage_taken / life_max * damage_given

    def select_command(self,
                       source: Unit,
                       units: List[Unit],
                       commands: List[Command]
                       ) -> Tuple[Command, List[Unit]]:
        command = commands[0]  # 一択のためコマンドは固定
        targets = command.targets(source, units)
        targets.sort(key=lambda t: self.estimate_priority(source, t))
        return command, targets[-1:]

同じようにシミュレーションすると、前述のチートAIとほぼ変わらない90%前後の勝率を収めることがわかりました。

    # バトルシミュレーション3 (プレイヤーの行動を、機械学習モデルで決める)
    gambits[Side.PLAYER] = MLbasedGambit()
    win, logs = simulate_battle(teams, gambits, args.n_battle)
    print("Player(MLbased) win rate %7.5f%%" % (100.0 * win[0] / sum(win.values()),))
Player(Cunning) win rate 90.80000%

おまけになりますが、プレイヤーのステータスを学習時とは異なる値に変えて推論しても、意図通りに動作します。

    # プレイヤーのステータスをいじる
    teams[0] = Team(0, [
        Unit(Status(Side.PLAYER, 0, "knight2", 104, 76, 65, 32)),
        Unit(Status(Side.PLAYER, 1, "archer2", 76, 52, 40, 50)),
        Unit(Status(Side.PLAYER, 2, "thief2", 89, 52, 54, 68)),
    ])

    # バトルシミュレーション4 (プレイヤーの行動を、ランダムで決める)
    gambits[Side.PLAYER] = NaiveGambit()
    win, logs = simulate_battle(teams, gambits, args.n_battle)
    print("Player(Naive) win rate %7.5f%%" % (100.0 * win[0] / sum(win.values()),))

    # バトルシミュレーション5 (プレイヤーの行動を、チートして決める)
    gambits[Side.PLAYER] = CunningGambit()
    win, logs = simulate_battle(teams, gambits, args.n_battle)
    print("Player(Cunning) win rate %7.5f%%" % (100.0 * win[0] / sum(win.values()),))

    # バトルシミュレーション6 (プレイヤーの行動を、機械学習モデルで決める)
    gambits[Side.PLAYER] = MLbasedGambit()
    win, logs = simulate_battle(teams, gambits, args.n_battle)
    print("Player(MLbased) win rate %7.5f%%" % (100.0 * win[0] / sum(win.values()),))
Player(Naive) win rate 15.40000%
Player(Cunning) win rate 92.30000%
Player(MLbased) win rate 92.30000%

最後に

深層学習フレームワークをゲームに組み込む上で、なにかとっつきやすいサンプルがあればという思いで、今回の記事を書いてみました。これをきっかけに、ゲームAIに関心を持って頂けたら幸いです。

195
162
2

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
195
162