愛しのjQueryでぬるぬる動くスネークゲームを作る


実物：https://mogamoga1024.github.io/SnakeGame/

矢印キーで移動
スペースキーで一時停止
IE11でも動作可能
PC上以外での動作は未想定

ちなみに頭が体に当たってもゲームオーバーにはなりません。

# 前書き

Vanilla.js（生JS） + jQueryの構成です。
ES6やaltJSやp5.jsやVue.js、Babel、Polyfillなどは使いません。
Vanilla.js（生JS）とjQueryが嫌いな人は見ない方がいいでしょう。
裏テーマはIE11対応です。

ソースはこちら：https://github.com/mogamoga1024/SnakeGame

# 描画

SVGタグを利用します。以上。

# SceneManager + Scene

ゲームの状態（以後、Sceneと呼ぶ）としてスタート、ゲーム中、ゲームオーバーなどがあり、Sceneごとに画面レイアウト、キー操作が異なります。

Sceneが切り替わったときに、例えばキー操作を切り替えるとして共通のキー操作メソッド内部で○○Sceneは○○をする。△△Sceneは△△をするといった風に、Sceneごとに処理を分岐するのはif文がだらだらと長くなり可読性、保守性が下がります。

```javascript:だらだらした分岐
$(window).keydown(function(event) {
    if (scene === "start") {
        startSceneKeyDownEvent(event);
    }
    else if (scene === "playing") {
        playingSceneKeyDownEvent(event);
    }
    else if (scene === "game over") {
        gameOverSceneKeyDownEvent(event);
    }
    else if (scene === "pause") {
        pauseSceneKeyDownEvent(event);
    }
    else if (scene === "hogehoge") {
        hogehogeSceneKeyDownEvent(event);
    }
    // 略
});
```

それを避けるためにSceneはオブジェクトとし、描画処理、キー操作などは個々のSceneオブジェクトで管理するようにし、Sceneの切り替えはSceneManagerオブジェクトに任せるようにします。

Sceneオブジェクトはstartメソッドで初期表示処理、updateメソッドで1フレーム単位の描画を想定しています。

```javascript:scene.js
function Scene() {}
Scene.prototype.start = function($canvas) {};
Scene.prototype.update = function($canvas) {};
Scene.prototype.keydown = function(keyCode) {};
Scene.prototype.keyup = function(keyCode) {};
```

```javascript:sceneManager.js
const SceneManager = (function() {
    const $window = $(window);
    const $svg = $("svg");
    let timer = null;
    let currentScene = new Scene();

    window.GAME_FIELD_WIDTH = $svg.width();
    window.GAME_FIELD_HEIGHT = $svg.height();

    $window.keydown(function(e) {
        return currentScene.keydown(e.keyCode);
    });
	
    $window.keyup(function(e) {
        return currentScene.keyup(e.keyCode);
    });

    function canvasToCenter() {
        $svg.css("top", ($window.height() - GAME_FIELD_HEIGHT) / 2);
        $svg.css("left", ($window.width() - GAME_FIELD_WIDTH) / 2);
    };
    
    canvasToCenter();

    $window.resize(function() {
        canvasToCenter()  
    });

    return {
        start: function(scene, _shouldUpdate) {
            const shouldUpdate = (_shouldUpdate === undefined) ? true : _shouldUpdate;
            currentScene = scene;
            currentScene.start($svg);
            clearInterval(timer);
            if (shouldUpdate) {
                timer = setInterval(function() {
                    currentScene.update($svg);
                }, 1000 / 60);
            }
        }
    };
})();
```

<details><summary>おまけ index.html</summary><div>

```html:index.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SVG de スネークゲーム</title>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
    <style>
    body {
        margin: 0;
        padding: 0;
    }
    #svg-container {
        position: relative;
    }
    svg {
        position: absolute;
        background-color: whitesmoke;
    }
    </style>
</head>
<body>
    <div id="svg-container">
        <svg width="1100" height="600" xmlns="http://www.w3.org/2000/svg"></svg>
    </div>
    <script src="js/common/common.js"></script>
    <script src="js/common/position.js"></script>
    <script src="js/common/regular4nPolygoDegreee.js"></script>
    <script src="js/constants/keyCodeConstants.js"></script>
    <script src="js/feed/feed.js"></script>
    <script src="js/feed/feeder.js"></script>
    <script src="js/snake/snake.js"></script>
    <script src="js/scene/scene.js"></script>
    <script src="js/scene/sceneManager.js"></script>
    <script src="js/scene/gameStartScene.js"></script>
    <script src="js/scene/gamePlayScene.js"></script>
    <script src="js/scene/gameOverScene.js"></script>
    <script src="js/main.js"></script>
</body>
</html>
```

</div></details>

使い方としてはSceneオブジェクトを継承し独自のSceneオブジェクトを作成し、SceneManager.startメソッドでSceneを切り替えます。具体例として実際のプログラムを張っておきます。

```gameStartScene.js
function GameStartScene() {};

GameStartScene.prototype = Object.create(Scene.prototype);
GameStartScene.prototype.constructor = GameStartScene;

GameStartScene.prototype.start = function($canvas) {
    $canvas.empty();

    const text1 = document.createElementNS("http://www.w3.org/2000/svg", "text");
    const $text1 = $(text1);
    $canvas.append(text1);
    $text1.attr({
        "text-anchor": "middle",
        "x": GAME_FIELD_WIDTH / 2,
        "y": GAME_FIELD_HEIGHT * 2 / 5,
        "font-size": 50
    });
    $text1.text("SVG de スネークゲーム");

    const text2 = document.createElementNS("http://www.w3.org/2000/svg", "text");
    const $text2 = $(text2);
    $canvas.append(text2);
    $text2.attr({
        "text-anchor": "middle",
        "x": GAME_FIELD_WIDTH / 2,
        "y": GAME_FIELD_HEIGHT * 3 / 5,
        "font-size": 30
    });
    $text2.text("press any key");
};

GameStartScene.prototype.keyup = function(keyCode) {
    if (keyCode === KEY_CODE.F5) return;
    if (keyCode === KEY_CODE.F12) return;

    SceneManager.start(new GamePlayScene());
};
```

```javascript:main.js
SceneManager.start(new GameStartScene(), false);
```

# GamePlayScene

GameStartScene、GameOverSceneは単純なのでスルーしますが、GamePlaySceneはスネークゲームの根幹なので解説します。コードは次の通りですが、真面目に見なくていいです。

```javascript:gamePlayScene.js
function GamePlayScene() {
    this.snake = null;
    this.feeder = null;
    this.feedList = [];

    this.canLoop = true;
    this.feedMaxCount = Feed.UNTIL_NOURISH_COUNT;
    this.firstKeyCode = null;
    this.secondeKeyCode = null;
};

GamePlayScene.prototype = Object.create(Scene.prototype);
GamePlayScene.prototype.constructor = GamePlayScene;

GamePlayScene.prototype.start = function($canvas) {
    $canvas.empty();

    const feedCanvas = document.createElementNS("http://www.w3.org/2000/svg", "g");
    $canvas.append(feedCanvas);
    this.feeder = new Feeder($(feedCanvas));

    const snakeCanvas = document.createElementNS("http://www.w3.org/2000/svg", "g");
    $canvas.append(snakeCanvas);
    this.snake = new Snake($(snakeCanvas));
}

GamePlayScene.prototype.update = function() {
    if (this.canLoop === false) return;

    if (this.firstKeyCode !== null) {
        this.snake.headAngleChangeByKeyCode(this.firstKeyCode);
    }
    
    if (this.snake.isHittingWall()) {
        SceneManager.start(new GameOverScene(this.snake.eatCount), false);
        return;
    }

    if (this.feedList.length === 0) {
        for (let i = 0; i < this.feedMaxCount; i++) {
            this.feedList.push(this.feeder.sowFeed(this.snake));
        }
    }

    for (let i = this.feedList.length - 1; i >= 0; i--) {
        const feed = this.feedList[i];
        if (this.snake.canEatFeed(feed)) {
            this.snake.eatFeed(feed);
            this.feedList.splice(i, 1);
        }
    }

    this.snake.move();
};

GamePlayScene.prototype.keydown = function(keyCode) {
    if (keyCode === KEY_CODE.SPACE) {
        this.canLoop = !this.canLoop;
        return;
    }
    
    if (keyCode !== this.firstKeyCode) {
        this.secondeKeyCode = this.firstKeyCode;
    }
    this.firstKeyCode = keyCode;
};

GamePlayScene.prototype.keyup = function(keyCode) {
    if (keyCode === KEY_CODE.SPACE) return;

    if (keyCode === this.firstKeyCode) {
        this.firstKeyCode = this.secondeKeyCode;
        this.secondeKeyCode = null;
    }
    else if (keyCode === this.secondeKeyCode) {
        this.secondeKeyCode = null;
    }
};
```

startメソッドは画面の生成、updateメソッドはメインとなるスネークゲームの処理、keydownメソッドとkeyupメソッドはキーの入力を制御しています。
updateメソッド内部はだいたいこんな感じのフローとなっています。

<img width="800" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/649673/32196097-c30e-9495-9274-a052f4d85cd8.png">

ちなみに、プロパティのfirstKeyCode、secondeKeyCodeは

1. 上キーを押して蛇を上へ移動させる。
1. 上キーを押したまま右キーを押して蛇を右へ移動させる。
1. 右キーを離すと蛇が上へ移動する。

という挙動を実現させるために存在しています。

# Snake

ソースは長いので読まなくていいです。
描画、キー入力による方向転換、移動、壁、餌のあたり判定処理を行っています。

```javascript:snake.js
function Snake($canvas) {
    this.headPosition = new Position(100, 100);
    this.tailPosition = this.headPosition.clone();
    this.radius = 25;
    this.eyeRadius = 5;
    this.eatCount = 0;
    this.headDegree = new Regular4nPolygonDegree(25);
    this.speed = 5;
    this.trace = [];

    this.trace.push(this.headPosition.clone());

    this.scale = 1;

    const snake = document.createElementNS("http://www.w3.org/2000/svg", "path");
    $canvas.append(snake);
    this.$snake = $(snake);
    
    const leftEye = document.createElementNS("http://www.w3.org/2000/svg", "circle");
    $canvas.append(leftEye);
    this.$leftEye = $(leftEye);

    const rightEye = document.createElementNS("http://www.w3.org/2000/svg", "circle");
    $canvas.append(rightEye);
    this.$rightEye = $(rightEye);

    this.$snake.attr({
        "stroke": "green",
        "stroke-linecap": "round",
        "stroke-linejoin": "round",
        "fill": "none",
        "stroke-opacity": 0.8
    });
    this.$leftEye.attr("fill", "black");
    this.$rightEye.attr("fill", "black");

    this.draw();
}

Snake.prototype.draw = function() {
    this.updateTailPosition();

    let d = "M" + this.headPosition.x + "," + this.headPosition.y;
    if (this.eatCount === 0) {
        d += "L" + this.headPosition.x + "," + this.headPosition.y;
    }
    else {
        const lastTraceIndex = this.trace.length - 1;
        if (this.trace.length === 1) {
            d += "L" + this.tailPosition.x + "," + this.tailPosition.y;
        }
        else {
            d += "L" + this.trace[0].x + "," + this.trace[0].y;
        }
        for (let i = 0; i < lastTraceIndex; i++) {
            if (i === lastTraceIndex - 1) {
                d += "L" + this.tailPosition.x + "," + this.tailPosition.y;
                break;
            }
            else {
                d += "L" + this.trace[i + 1].x + "," + this.trace[i + 1].y;
            }
        }
    }
    this.$snake.attr({
        "stroke-width": this.radius * this.scale * 2,
        "d": d
    });

    const eyeCenterDistance = (this.radius - this.eyeRadius) * this.scale * 0.9;
    const headRadian = this.headDegree.toRadian();
    const leftEyeX = this.headPosition.x + eyeCenterDistance * Math.cos(headRadian + Math.PI / 6);
    const leftEyeY = this.headPosition.y + eyeCenterDistance * Math.sin(headRadian + Math.PI / 6);
    this.$leftEye.attr({
        "cx": leftEyeX,
        "cy": leftEyeY,
        "r": this.eyeRadius * this.scale
    });

    const rightEyeX = this.headPosition.x + eyeCenterDistance * Math.cos(headRadian - Math.PI / 6);
    const rightEyeY = this.headPosition.y + eyeCenterDistance * Math.sin(headRadian - Math.PI / 6);
    this.$rightEye.attr({
        "cx": rightEyeX,
        "cy": rightEyeY,
        "r": this.eyeRadius * this.scale
    });
};

Snake.prototype.headDegreeChangeByKeyCode = function(keyCode) {
    let rotationDirection;

    if (keyCode === KEY_CODE.UP_ARROW) {
        rotationDirection = this.findRotationDirection(
            this.headDegree.DEGREE_90,
            this.headDegree.DEGREE_270
        );
    }
    else if (keyCode === KEY_CODE.DOWN_ARROW) {
        rotationDirection = this.findRotationDirection(
            this.headDegree.DEGREE_270,
            this.headDegree.DEGREE_90
        );
    }
    else if (keyCode === KEY_CODE.LEFT_ARROW) {
        rotationDirection = this.findRotationDirection(
            this.headDegree.DEGREE_0,
            this.headDegree.DEGREE_180
        );
    }
    else if (keyCode === KEY_CODE.RIGHT_ARROW) {
        rotationDirection = this.findRotationDirection(
            this.headDegree.DEGREE_180,
            this.headDegree.DEGREE_0
        );
    }
    else {
        return;
    }

    if (rotationDirection !== 0) {
        this.headDegree.shift(rotationDirection);
        this.trace.unshift(this.headPosition.clone());
    }
};

Snake.prototype.findRotationDirection = function(start90nDegree, end90nDegree) {
    if (this.headDegree.equals90nDegree(start90nDegree) || this.headDegree.equals90nDegree(end90nDegree)) {
        return 0;
    }
    if (this.headDegree.existIn90nDegreeRange(start90nDegree, end90nDegree)) {
        return 1;
    }
    return -1;
};

Snake.prototype.isHittingWall = function() {
    if (
        this.headPosition.x - this.radius <= 0 ||
        this.headPosition.y - this.radius <= 0 ||
        this.headPosition.x + this.radius >= GAME_FIELD_WIDTH ||
        this.headPosition.y + this.radius >= GAME_FIELD_HEIGHT
    ) {
        return true;
    }
    return false;
};

Snake.prototype.canEatFeed = function(feed) {
    return this.headPosition.distance(feed.position) <= this.radius + feed.radius;
};

Snake.prototype.move = function() {
    const headRadian = this.headDegree.toRadian();
    this.headPosition.x += this.speed * Math.cos(headRadian);
    this.headPosition.y += this.speed * Math.sin(headRadian);

    this.draw();
};

Snake.prototype.updateTailPosition = function() {
    if (this.eatCount === 0) return;

    const snakeLength = 2 * this.radius * this.eatCount;
    let tmpSnakeLength =  0;
    let joint = this.headPosition;
    let index = 0;
    while (true) {
        const nextJoint = this.trace[index];
        const preTmpSnakeLength = tmpSnakeLength;
        tmpSnakeLength += joint.distance(nextJoint);
        if (tmpSnakeLength === snakeLength) {
            this.tailPosition.x = nextJoint.x;
            this.tailPosition.y = nextJoint.y;
            break;
        }
        else if (tmpSnakeLength > snakeLength) {
            const remainingSnakeLenght = snakeLength - preTmpSnakeLength;

            if (joint.x === nextJoint.x) {
                this.tailPosition.x = joint.x;
                if (joint.y < nextJoint.y) {
                    this.tailPosition.y = joint.y + remainingSnakeLenght;
                }
                else {
                    this.tailPosition.y = joint.y - remainingSnakeLenght;
                }
            }
            else {
                const absC = nextJoint.distance(joint);
                const a = nextJoint.x - joint.x;
                const b = nextJoint.y - joint.y;

                this.tailPosition.x = joint.x + (a / absC) * remainingSnakeLenght;
                this.tailPosition.y = joint.y + (b / absC) * remainingSnakeLenght;
            }
            break;
        }

        if (index === this.trace.length - 1) {
            break;
        }

        joint = nextJoint;
        index++;
    }

    if (index < this.trace.length - 1) {
        this.trace.splice(index + 1);
    }
};

Snake.prototype.eatFeed = function(feed) {
    feed.eaten(this);
};
```

<details><summary>おまけ position.js</summary><div>

```javascript:position.js
function Position(x, y) {
    this.x = x;
    this.y = y;
}

Position.prototype.distance = function(position) {
    const dx = this.x - position.x;
    const dy = this.y - position.y;
    return Math.sqrt(dx * dx + dy * dy);
};

Position.prototype.clone = function() {
    return new Position(this.x, this.y);
};
```

</div></details>

+ updateTailPositionメソッドは頭の座標と移動経路からしっぽの座標を求めて更新しています。
+ eatFeedメソッドは餌を食べたときの処理を餌のほうのオブジェクトで処理したいのでこういう書き方をしています。例えば、餌Aは体を1伸ばす。餌Bは逆に体を縮める。餌Cはスピードを上げる。餌Dは蛇の色を変えるみたいな感じです。
+ 移動方向を制御しているheadDegreeプロパティのRegular4nPolygonDegreeオブジェクトは次で解説します。

# Regular4nPolygonDegree

このクラスは蛇の進行方向を管理します。
よーするにこーいうことです。

<img width="800" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/649673/419cdaa2-337b-9d13-76ad-8f4f1ad4e923.png">

<img width="800" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/649673/bab6540f-1a9f-30d7-ecf9-92bf9d269e49.png">

ソースは見なくていいです。

```javascript:regular4nPolygonDegree.js
function Regular4nPolygonDegree(n) {
    this.DEGREE_0 = 0;
    this.DEGREE_90 = n;
    this.DEGREE_180 = n * 2;
    this.DEGREE_270 = n * 3;

    this.N = n * 4;
    this.index = 0;
    this.centralAngle = Math.PI / (n * 2);
}

Regular4nPolygonDegree.prototype.shift = function(direction) {
    if (direction > 0) {
        this.index = (this.index + 1) % this.N;
    }
    else if (direction < 0) {
        this.index = (this.index - 1 + this.N) % this.N;
    }
};

Regular4nPolygonDegree.prototype.equals90nDegree = function(degree) {
    if (this.isDEGREE_XX(degree) === false) {
        throw new Error("オブジェクトで定義されているDEGREE_XXを引数を利用すること。");
    }

    return this.index === degree;
};

Regular4nPolygonDegree.prototype.existIn90nDegreeRange = function(startDegree, endDegree) {
    if (this.isDEGREE_XX(startDegree) === false || this.isDEGREE_XX(endDegree) === false) {
        throw new Error("オブジェクトで定義されているDEGREE_XXを引数を利用すること。");
    }

    if (startDegree < endDegree) {
        return startDegree < this.index && this.index < endDegree;
    }
    else if (startDegree > endDegree) {
        return startDegree < this.index || this.index < endDegree;
    }
    return false;
};

Regular4nPolygonDegree.prototype.isDEGREE_XX = function(degree) {
    if (
        degree === this.DEGREE_0   || degree === this.DEGREE_90  ||
        degree === this.DEGREE_180 || degree === this.DEGREE_270
    ) {
        return true;
    }
    return false;
};

Regular4nPolygonDegree.prototype.toRadian = function() {
    return this.index * 2 * Math.PI / this.N;
};

Regular4nPolygonDegree.prototype.convertRegular4nPolygon = function(n) {
    const radian = this.toRadian();
    Regular4nPolygonDegree.call(this, n);
    this.index = Math.round(radian / this.centralAngle) % this.N;
};
```

<details><summary>予防線</summary><div>

突っ込まれそうなので先手を打ちますが、

```javascript
Regular4nPolygonDegree.prototype.isDEGREE_XX = function(degree) {
    return degree === this.DEGREE_0 || degree === this.DEGREE_90 || degree === this.DEGREE_180 || degree === this.DEGREE_270;
};
```

と書いてもいいです。

</div></details>

# Feeder + Feed

Feedはまんま餌です。
eatenメソッドはゲームバランスを調整するときに適当につけたパラメータが残っています。
許せんって人は定数化でもしてください。

```javascript:feed.js
function Feed($canvas, feeder, x, y) {
    this.position = new Position(x, y);
    this.feeder = feeder;
    this.radius = this.feeder.feedRadius;

    const feed = document.createElementNS("http://www.w3.org/2000/svg", "circle");
    $canvas.append(feed);

    this.$feed = $(feed);
    this.$feed.attr({
        "cx": this.position.x,
        "cy": this.position.y,
        "r": this.radius,
        "fill": "brown"
    });
}

Feed.UNTIL_NOURISH_COUNT = 10;

Feed.prototype.eaten = function(snake) {
    this.$feed.remove();

    snake.eatCount++;

    if (snake.eatCount % Feed.UNTIL_NOURISH_COUNT === 0) {
        if (snake.speed < 10) {
            snake.speed += 0.5;
            const n = snake.headDegree.N / 4 - 2;
            if (n > 0) {
                snake.headDegree.convertRegular4nPolygon(n);
            }
        }
        if (snake.radius * snake.scale > 15) {
            snake.scale *= 0.99;
            this.feeder.feedRadius *= 0.99;
        }
    }

    if (snake.trace.length > 0) {
        snake.trace[snake.trace.length - 1].x = snake.tailPosition.x;
        snake.trace[snake.trace.length - 1].y = snake.tailPosition.y;
    }
};
```

Feederは餌をまきます。

```feeder.js
function Feeder($canvas) {
    this.$canvas = $canvas;
    this.feedRadius = 20;
}

Feeder.prototype.sowFeed = function(snake) {
    let x, y, snakeRotationRadius;

    do {
        x = Math.floor(Math.randomInt(GAME_FIELD_WIDTH + 1 - this.feedRadius * 2) + this.feedRadius);
        y = Math.floor(Math.randomInt(GAME_FIELD_HEIGHT + 1 - this.feedRadius * 2) + this.feedRadius);
        snakeRotationRadius = snake.speed / Math.sqrt(2 * (1 - Math.cos(snake.headDegree.centralAngle))) + snake.radius;
    }
    while (
        y < -x + snakeRotationRadius ||
        y <  x - GAME_FIELD_WIDTH  + snakeRotationRadius ||
        y >  x + GAME_FIELD_HEIGHT - snakeRotationRadius ||
        y > -x + GAME_FIELD_WIDTH  + GAME_FIELD_HEIGHT - snakeRotationRadius
    );

    return new Feed(this.$canvas, this, x, y);
};
```

<details><summary>おまけ common.js</summary><div>

```javascript:common.js
// 0以上max未満の整数の乱数
Math.randomInt = function(max) {
    return Math.floor(Math.random() * max);
};
```

</div></details>

sowFeedメソッドは蛇が餌を食べれる範囲に餌をまきます。
よーするにこーいうことです。

<img width="800" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/649673/9c010c0e-3c01-dde3-989e-414a25ae6550.png">

終わり。