Help us understand the problem. What is going on with this article?

ローグライクゲームを作ってみるその5 ダンジョンのサイズ

過去記事一覧

現在のコードについては前回の記事の最後の項を参照してください。

プログラム

今回作ったプログラムはhttp://yurinya.net/roguelike2019/5/srl.htmlで公開しています。良かったらアクセスしてみてください!

ダンジョンのサイズ

今までダンジョンのフロアのサイズは25マスx25マスで固定でした。

今回はより広いサイズのフロアを生成したり、描画したりできるようにしたいと思います。

ゲーム画面のサイズは変更しませんので、より広いサイズのフロアを生成すると必然的にゲーム画面には一度にフロアの一部しか表示できないということになります。

そのため、プレイヤーの位置に応じてフロアのどの部分を描画するかを決めなければならなくなります。


まずフロアの生成処理を変更しましょう。

create_field関数を下のように変更します。

nx変数とny変数がフロアの大きさを表します。0階は25マスx25マスのままとすることにしますが、1階以降は50マスx50マスに変更してみることにします。

そうすると、マスの生成処理はnx変数とny変数を使用するようにしなければなりません。また、他の幾つかの部分でもnx変数とny変数を使用するようにしなければならなくなります。

また、create_field関数の返り値となるフロアを表すオブジェクトにはnxプロパティとnyプロパティとしてフロアのサイズを含めることにします。

function create_field (depth, upstairs, base_seed) {
    var random = new Random(base_seed + ',' + depth.toString(10));

    var nx = 25;
    var ny = 25;
    if (depth > 0) {
        nx = 50;
        ny = 50;
    }

    var blocks = [];
    for (var i = 0; i < nx; i++) {
        blocks[i] = [];
        for (var j = 0; j < ny; j++) {
            if ((i === 0 || j === 0) || (i === nx - 1 || j === ny - 1)) {
                blocks[i][j] = {
                    base: B_WALL
                };
            }
            else {
                blocks[i][j] = {
                    base: B_FLOOR
                };
            }
        }
    }

    if (depth === 0) {
        blocks[12][5] = {
            base: B_DOWNSTAIR
        };

        return {
            nx: nx,
            ny: ny,
            blocks: blocks
        };
    }

    var rs = [{
        x1: 1,
        x2: nx - 2,
        y1: 1,
        y2: ny - 2
    }];
    var ers = [];
    var dps = [1, 1, 1, 1, 1, 1, 0.5, 0.5, 0.5, 0.5];
    while (rs.length > 0 && dps.length > 0) {
        var r = rs.shift();
        var nrs = split_room(blocks, r.x1, r.x2, r.y1, r.y2, dps.shift(), random);
        for (var i = 0; i < nrs.length; i++) {
            rs.push(nrs[i]);
        }
        if (nrs.length === 0) {
            ers.push(r);
        }
    }
    while (rs.length > 0) {
        ers.push(rs.shift());
    }

    var nds = 1;
    while (nds > 0) {
        var x = random.num(nx - 2) + 1;
        var y = random.num(ny - 2) + 1;
        var f = true;
        for (var i = 0; i < upstairs.length; i++) {
            if (x === upstairs[i].x && y === upstairs[i].y) {
                f = false;
                break;
            }
        }
        if (f) {
            blocks[x][y].base = B_DOWNSTAIR;
            nds--;
        }
    }

    for (var i = 0; i < upstairs.length; i++) {
        if (blocks[upstairs[i].x][upstairs[i].y].base = B_WALL) {
            blocks[upstairs[i].x][upstairs[i].y].base = B_FLOOR;
        }
    }

    return {
        nx: nx,
        ny: ny,
        blocks: blocks
    };
}

次にキー入力の処理を変更します。

2つ目のkeydownイベントハンドラを下のように変更します。

矢印キーが押された場合にはまずプレイヤーが現在いるフロアのサイズを取得し、nx変数とny変数に格納します。

そして、これらの変数の値をプレイヤーがフロアの端にいるかを判定するために使用するようにします。

    c.on('keydown', function (e) {
(省略)
        if (e.keyCode >= 37 && e.keyCode <= 40) {
            var nx = fields[player.depth].nx;
            var ny = fields[player.depth].ny;
            var x = player.x;
            var y = player.y;
            if (e.shiftKey) {
                if (keyl && keyu) {
                    if (x === 0 || y === 0) {
                        return;
                    }
                    x--;
                    y--;
                }
                else if (keyr && keyu) {
                    if (x === nx - 1 || y === 0) {
                        return;
                    }
                    x++;
                    y--;
                }
                else if (keyl && keyd) {
                    if (x === 0 || y === ny - 1) {
                        return;
                    }
                    x--;
                    y++;
                }
                else if (keyr && keyd) {
                    if (x === nx - 1 || y === ny - 1) {
                        return;
                    }
                    x++;
                    y++;
                }
                else {
                    return;
                }
            }
            else {
                if (e.keyCode === 37) {
                    if (x === 0) {
                        return;
                    }
                    x--;
                }
                else if (e.keyCode === 38) {
                    if (y === 0) {
                        return;
                    }
                    y--;
                }
                else if (e.keyCode === 39) {
                    if (x === nx - 1) {
                        return;
                    }
                    x++;
                }
                else if (e.keyCode === 40) {
                    if (y === ny - 1) {
                        return;
                    }
                    y++;
                }
            }
(省略)
        }
        else if (e.keyCode === 32) {
(省略)
        }
        else {
            return;
        }

        draw(con, env);
    });

次に描画処理を変更します。

描画処理においてはゲーム画面でフロアのマスを何マスx何マス描画するかという情報が必要となりますので、下のようなSX変数とSY変数を定義します。

var SX = 25;
var SY = 25;

これらの値は25とします。つまり、ゲーム画面では25マスx25マスを一度に描画することにします。これは前回までと同じサイズです。

今まではフロアのサイズも25マスx25マスだったのでゲーム画面にフロア全体を一度に描画できていましたが、今後25マスx25マスより広いフロアを描画する場合には一度にフロアの一部しか描画できないということになります。

フロアは基本的にプレイヤーを中心としてプレイヤーの周りの25マスx25マスを描画することにします。

ただし、プレイヤーがフロアの端の方にいる場合にはプレイヤーを中心とした描画を行うとゲーム画面に余白が現れてしまいます(たとえば、プレイヤーがフロアの左端にいる場合にはプレイヤーを中心とした描画を行った場合、ゲーム画面のフロアを描画する部分の左半分に描画するものがなく、余白となってしまいます)ので、余白が現れないように描画します。

つまり、ゲーム画面にはプレイヤーのフロア内での位置に拘わらず常に25マスx25マスが描画されます。

このような描画を実現するにはdraw関数を下のように変更します。

プレイヤーが現在いるフロアのサイズを取得し、nx変数とny変数に格納します。

そして、ox変数とoy変数にゲーム画面において描画を行うフロアのマスの左上端の位置を格納します。

描画を行うフロアのマスの左上端の位置が分かれば、マスの描画は二重for文で簡単に行うことができます。

そして、プレイヤーのゲーム画面における描画位置はプレイヤーのフロアにおける位置からゲーム画面において描画を行うフロアのマスの左上端の位置を差し引くことによって求められます。

function draw (con, env) {
(省略)
    var nx = fields[player.depth].nx;
    var ny = fields[player.depth].ny;

    var ox = 0;
    if (player.x <= Math.floor(SX / 2)) {
        ox = 0;
    }
    else if (player.x >= nx - Math.floor(SX / 2)) {
        ox = nx - SX;
    }
    else {
        ox = player.x - Math.floor(SX / 2);
    }

    var oy = 0;
    if (player.y <= Math.floor(SY / 2)) {
        oy = 0;
    }
    else if (player.y >= ny - Math.floor(SY / 2)) {
        oy = ny - SY;
    }
    else {
        oy = player.y - Math.floor(SY / 2);
    }

    for (var i = 0; i < SX; i++) {
        for (var j = 0; j < SY; j++) {
            var block = fields[player.depth].blocks[ox + i][oy + j];
(省略)
        }
    }

    var px = player.x - ox;
    var py = player.y - oy;

    con.textBaseline = 'middle';
    con.textAlign = 'center';
    con.fillStyle = 'red';
    con.fillText('🚶\uFE0E', px * PX + (PX / 2), py * PY + (PY / 2));

    if (env.diagonal) {
        con.save();

        con.strokeStyle = 'white';
        con.translate(px * PX + (PX / 2), py * PY + (PY / 2));
        con.rotate(Math.PI / 4);
(省略)
    }
}

今回はここまで

今回はここまでです。

game.jsは下のようになりました。

var TITLE = 'シンプルローグライク';

var TEXT_START = 'はじめる';

var SCREEN_X = 1600;
var SCREEN_Y = 800;

var SX = 25;
var SY = 25;
var PX = 32;
var PY = 32;

var B_FLOOR = 0;
var B_WALL = 1;
var B_DOWNSTAIR = 2;

var B_CAN_STAND = [];
B_CAN_STAND[B_FLOOR] = true;
B_CAN_STAND[B_WALL] = false;
B_CAN_STAND[B_DOWNSTAIR] = true;

var img = new Image();
img.src = 'Dungeon_B_Freem7.png';

var seed = Date.now().toString(10);

var startf = false;

var fields = null;
var player = null;

$(function(){
    var canvas = document.getElementById('game');
    var con = canvas.getContext('2d');

    var keyl = false;
    var keyu = false;
    var keyr = false;
    var keyd = false;

    var env = {
        diagonal: false
    };

    var c = $('body');
    c.on('keydown', function (e) {
        if (e.keyCode === 37) {
            keyl = true;
        }
        else if (e.keyCode === 38) {
            keyu = true;
        }
        else if (e.keyCode === 39) {
            keyr = true;
        }
        else if (e.keyCode === 40) {
            keyd = true;
        }
        else {
            keyl = false;
            keyu = false;
            keyr = false;
            keyd = false;
        }
    });
    c.on('keyup', function (e) {
        if (e.keyCode === 37) {
            keyl = false;
        }
        else if (e.keyCode === 38) {
            keyu = false;
        }
        else if (e.keyCode === 39) {
            keyr = false;
        }
        else if (e.keyCode === 40) {
            keyd = false;
        }
    });
    c.on('keydown', function (e) {
        if (!startf) {
            if (e.keyCode === 90) {
                startf = true;

                init();

                draw(con, env);
            }

            return;
        }

        if (e.keyCode === 16) {
            if (!env.diagonal) {
                env.diagonal = true;

                draw(con, env);
            }

            return;
        }

        if (e.keyCode >= 37 && e.keyCode <= 40) {
            var nx = fields[player.depth].nx;
            var ny = fields[player.depth].ny;
            var x = player.x;
            var y = player.y;
            if (e.shiftKey) {
                if (keyl && keyu) {
                    if (x === 0 || y === 0) {
                        return;
                    }
                    x--;
                    y--;
                }
                else if (keyr && keyu) {
                    if (x === nx - 1 || y === 0) {
                        return;
                    }
                    x++;
                    y--;
                }
                else if (keyl && keyd) {
                    if (x === 0 || y === ny - 1) {
                        return;
                    }
                    x--;
                    y++;
                }
                else if (keyr && keyd) {
                    if (x === nx - 1 || y === ny - 1) {
                        return;
                    }
                    x++;
                    y++;
                }
                else {
                    return;
                }
            }
            else {
                if (e.keyCode === 37) {
                    if (x === 0) {
                        return;
                    }
                    x--;
                }
                else if (e.keyCode === 38) {
                    if (y === 0) {
                        return;
                    }
                    y--;
                }
                else if (e.keyCode === 39) {
                    if (x === nx - 1) {
                        return;
                    }
                    x++;
                }
                else if (e.keyCode === 40) {
                    if (y === ny - 1) {
                        return;
                    }
                    y++;
                }
            }

            if (x !== player.x || y !== player.y) {
                var block = fields[player.depth].blocks[x][y];
                if (B_CAN_STAND[block.base]) {
                    player.x = x;
                    player.y = y;
                }
                else {
                    return;
                }
            }
            else {
                return;
            }
        }
        else if (e.keyCode === 32) {
            var block = fields[player.depth].blocks[player.x][player.y];
            if (block.base === B_DOWNSTAIR) {
                player.depth++;
                if (!fields[player.depth]) {
                    fields[player.depth] = create_field(player.depth, [{
                        x: player.x,
                        y: player.y
                    }], seed);
                }
            }
        }
        else {
            return;
        }

        draw(con, env);
    });
    c.on('keyup', function (e) {
        if (e.keyCode === 16) {
            if (env.diagonal) {
                env.diagonal = false;

                draw(con, env);
            }
        }
    });
    $(window).on('blur', function (e) {
        if (env.diagonal) {
            env.diagonal = false;

            draw(con, env);
        }
    });

    draw(con, env);
});

function init () {
    fields = [];
    fields[0] = create_field(0, [], seed);
    player = {
        depth: 0,
        x: 12,
        y: 17
    };
}

function create_field (depth, upstairs, base_seed) {
    var random = new Random(base_seed + ',' + depth.toString(10));

    var nx = 25;
    var ny = 25;
    if (depth > 0) {
        nx = 50;
        ny = 50;
    }

    var blocks = [];
    for (var i = 0; i < nx; i++) {
        blocks[i] = [];
        for (var j = 0; j < ny; j++) {
            if ((i === 0 || j === 0) || (i === nx - 1 || j === ny - 1)) {
                blocks[i][j] = {
                    base: B_WALL
                };
            }
            else {
                blocks[i][j] = {
                    base: B_FLOOR
                };
            }
        }
    }

    if (depth === 0) {
        blocks[12][5] = {
            base: B_DOWNSTAIR
        };

        return {
            nx: nx,
            ny: ny,
            blocks: blocks
        };
    }

    var rs = [{
        x1: 1,
        x2: nx - 2,
        y1: 1,
        y2: ny - 2
    }];
    var ers = [];
    var dps = [1, 1, 1, 1, 1, 1, 0.5, 0.5, 0.5, 0.5];
    while (rs.length > 0 && dps.length > 0) {
        var r = rs.shift();
        var nrs = split_room(blocks, r, dps.shift(), random);
        for (var i = 0; i < nrs.length; i++) {
            rs.push(nrs[i]);
        }
        if (nrs.length === 0) {
            ers.push(r);
        }
    }
    while (rs.length > 0) {
        ers.push(rs.shift());
    }

    var nds = 1;
    while (nds > 0) {
        var x = random.num(nx - 2) + 1;
        var y = random.num(ny - 2) + 1;
        var f = true;
        for (var i = 0; i < upstairs.length; i++) {
            if (x === upstairs[i].x && y === upstairs[i].y) {
                f = false;
                break;
            }
        }
        if (f) {
            blocks[x][y].base = B_DOWNSTAIR;
            nds--;
        }
    }

    for (var i = 0; i < upstairs.length; i++) {
        if (blocks[upstairs[i].x][upstairs[i].y].base = B_WALL) {
            blocks[upstairs[i].x][upstairs[i].y].base = B_FLOOR;
        }
    }

    return {
        nx: nx,
        ny: ny,
        blocks: blocks
    };
}

function split_room (blocks, r, dp, random) {
    var ap = random.fraction();
    if (ap <= dp) {
        var dir = random.num(2);
        if (r.x2 - r.x1 > (r.y2 - r.y1) * 2) {
            dir = 0;
        }
        else if ((r.x2 - r.x1) * 2 < r.y2 - r.y1) {
            dir = 1;
        }

        if (dir === 0) {
            if (r.x2 - r.x1 <= 6) {
                return [];
            }

            var x = random.num(r.x2 - r.x1 - 6) + 3 + r.x1;
            if (blocks[x][r.y1 - 1].base !== B_WALL) {
                return [];
            }
            if (blocks[x][r.y2 + 1].base !== B_WALL) {
                return [];
            }
            var y = random.num(r.y2 - r.y1) + r.y1;
            for (var i = r.y1; i <= r.y2; i++) {
                if (i !== y) {
                    blocks[x][i].base = B_WALL;
                }
            }

            var r1 = {
                x1: r.x1,
                x2: x - 1,
                y1: r.y1,
                y2: r.y2
            };
            var r2 = {
                x1: x + 1,
                x2: r.x2,
                y1: r.y1,
                y2: r.y2
            };
            var ord = random.num(2);
            if (ord === 0) {
                return [r1, r2];
            }
            else {
                return [r2, r1];
            }
        }
        else if (dir === 1) {
            if (r.y2 - r.y1 <= 6) {
                return [];
            }

            var y = random.num(r.y2 - r.y1 - 6) + 3 + r.y1;
            if (blocks[r.x1 - 1][y].base !== B_WALL) {
                return [];
            }
            if (blocks[r.x2 + 1][y].base !== B_WALL) {
                return [];
            }
            var x = random.num(r.x2 - r.x1) + r.x1;
            for (var i = r.x1; i <= r.x2; i++) {
                if (i !== x) {
                    blocks[i][y].base = B_WALL;
                }
            }

            var r1 = {
                x1: r.x1,
                x2: r.x2,
                y1: r.y1,
                y2: y - 1
            };
            var r2 = {
                x1: r.x1,
                x2: r.x2,
                y1: y + 1,
                y2: r.y2
            };
            var ord = random.num(2);
            if (ord === 0) {
                return [r1, r2];
            }
            else {
                return [r2, r1];
            }
        }
    }
    return [];
}

function draw (con, env) {
    con.fillStyle = 'black';
    con.fillRect(0, 0, SCREEN_X, SCREEN_Y);

    if (!startf) {
        con.textBaseline = 'alphabetic';
        con.textAlign = 'center';
        con.fillStyle = 'white';

        con.font = "48px consolas";
        con.fillText(TITLE, SCREEN_X / 2, SCREEN_Y / 4);

        con.font = "32px consolas";
        con.fillText('> ' + TEXT_START, SCREEN_X / 2, SCREEN_Y / 4 * 3);

        return;
    }

    var nx = fields[player.depth].nx;
    var ny = fields[player.depth].ny;

    var ox = 0;
    if (player.x <= Math.floor(SX / 2)) {
        ox = 0;
    }
    else if (player.x >= nx - Math.floor(SX / 2)) {
        ox = nx - SX;
    }
    else {
        ox = player.x - Math.floor(SX / 2);
    }

    var oy = 0;
    if (player.y <= Math.floor(SY / 2)) {
        oy = 0;
    }
    else if (player.y >= ny - Math.floor(SY / 2)) {
        oy = ny - SY;
    }
    else {
        oy = player.y - Math.floor(SY / 2);
    }

    for (var i = 0; i < SX; i++) {
        for (var j = 0; j < SY; j++) {
            var block = fields[player.depth].blocks[ox + i][oy + j];
            if (block.base === B_FLOOR) {
                con.fillStyle = 'white';
                con.beginPath();
                con.arc((i + 0.5) * PX, (j + 0.5) * PY, 1, 0, Math.PI * 2);
                con.closePath();
                con.fill();
            }
            else if (block.base === B_WALL) {
                con.strokeStyle = 'white';
                con.strokeRect(i * PX, j * PY, PX, PY);
                con.beginPath();
                con.moveTo(i * PX, j * PY);
                con.lineTo((i + 1) * PX, (j + 1) * PY);
                con.moveTo((i + 1) * PX, j * PY);
                con.lineTo(i * PX, (j + 1) * PY);
                con.closePath();
                con.stroke();
            }
            else if (block.base === B_DOWNSTAIR) {
                con.drawImage(img, 4 * 32, 5 * 32, 32, 32, i * PX, j * PY, PX, PY);
            }
        }
    }

    var px = player.x - ox;
    var py = player.y - oy;

    con.textBaseline = 'middle';
    con.textAlign = 'center';
    con.fillStyle = 'red';
    con.font = '24px consolas';
    con.fillText('🚶\uFE0E', px * PX + (PX / 2), py * PY + (PY / 2));

    if (env.diagonal) {
        con.save();

        con.strokeStyle = 'white';
        con.translate(px * PX + (PX / 2), py * PY + (PY / 2));
        con.rotate(Math.PI / 4);
        con.beginPath();
        con.moveTo((PX / 2) + 4, -4);
        con.lineTo((PX / 2) + 4 + 8, -4);
        con.lineTo((PX / 2) + 4 + 8, -4 - 4);
        con.lineTo((PX / 2) + 4 + 8 + 8, 0);
        con.lineTo((PX / 2) + 4 + 8, 4 + 4);
        con.lineTo((PX / 2) + 4 + 8, 4);
        con.lineTo((PX / 2) + 4, 4);
        con.closePath();
        con.stroke();
        con.rotate(Math.PI / 4 * 2);
        con.beginPath();
        con.moveTo((PX / 2) + 4, -4);
        con.lineTo((PX / 2) + 4 + 8, -4);
        con.lineTo((PX / 2) + 4 + 8, -4 - 4);
        con.lineTo((PX / 2) + 4 + 8 + 8, 0);
        con.lineTo((PX / 2) + 4 + 8, 4 + 4);
        con.lineTo((PX / 2) + 4 + 8, 4);
        con.lineTo((PX / 2) + 4, 4);
        con.closePath();
        con.stroke();
        con.rotate(Math.PI / 4 * 2);
        con.beginPath();
        con.moveTo((PX / 2) + 4, -4);
        con.lineTo((PX / 2) + 4 + 8, -4);
        con.lineTo((PX / 2) + 4 + 8, -4 - 4);
        con.lineTo((PX / 2) + 4 + 8 + 8, 0);
        con.lineTo((PX / 2) + 4 + 8, 4 + 4);
        con.lineTo((PX / 2) + 4 + 8, 4);
        con.lineTo((PX / 2) + 4, 4);
        con.closePath();
        con.stroke();
        con.rotate(Math.PI / 4 * 2);
        con.beginPath();
        con.moveTo((PX / 2) + 4, -4);
        con.lineTo((PX / 2) + 4 + 8, -4);
        con.lineTo((PX / 2) + 4 + 8, -4 - 4);
        con.lineTo((PX / 2) + 4 + 8 + 8, 0);
        con.lineTo((PX / 2) + 4 + 8, 4 + 4);
        con.lineTo((PX / 2) + 4 + 8, 4);
        con.lineTo((PX / 2) + 4, 4);
        con.closePath();
        con.stroke();

        con.restore();
    }
}

function hash (seed) {
    var sha256 = new jsSHA('SHA-256', 'TEXT');
    sha256.update(seed);
    return sha256.getHash('HEX');
}

class Random {
    constructor (seed) {
        this.seed = seed;
        this.hash = hash(seed);
        this.pointer = 0;
    }

    byte () {
        if (this.pointer === 64) {
            this.hash = hash(this.hash);
            this.pointer = 0;
        }
        var value = this.hash.substring(this.pointer, this.pointer + 2);
        this.pointer += 2;
        return parseInt(value, 16);
    }

    num (max) {
        if (max <= 0) {
            throw new Error('max of random.num must be positive.');
        }
        else if (max <= 256) {
            return this.byte() % max;
        }
        else {
            throw new Error('not supported.');
        }
    }

    fraction () {
        return this.byte() / 256;
    }
}

function test_random_class_byte () {
    var a = [68, 9, 150, 66, 71, 184, 42, 152,
        84, 31, 148, 195, 79, 121, 253, 235,
        87, 142, 108, 87, 64, 95, 18, 186,
        184, 92, 200, 43, 179, 155, 117, 136,
        209, 241, 173, 107, 190, 11, 178, 50];
    var r = new Random('yurina');
    for (var i = 0; i < a.length; i++) {
        if (r.byte() !== a[i]) {
            throw new Error('test_random_class_byte');
        }
    }
}

function test_random_class_num () {
    var a = [0, 1, 0, 2, 1, 4, 0, 0, 3, 1, 5, 3, 1, 9, 13, 11];
    var r = new Random('yurina');
    for (var i = 0; i < a.length; i++) {
        if (r.num(i + 1) !== a[i]) {
            throw new Error('test_random_class_num');
        }
    }
}

次回はゲームメッセージとプレイヤーのステータスの描画について考えたいと思います。

pizyumi
トランスジェンダー。性同一性障害(MtF)診断済。睾丸摘出済。歌舞伎町のニューハーフヘルスに在籍していた元風俗嬢です。風俗嬢をしたら精神がボロボロになってどん底です。プログラミング歴20年超。プログラマ。ITエンジニア。投資家。当面は趣味の形式化数学の記事を書きます。
http://info-i.net/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした