2022 VIVA JS World Cup 開幕!! 〜 Vue3で作るサッカーゲーム 〜
2022.12.24
この記事は最終更新日から1年以上が経過しています。
本記事はCocone Advent Calendar 2022の24日目の記事となります。
こんにちは
メリークリスマス!!
いやぁ、しかし先週の日曜日は、 M1グランプリやワールドカップやらで何かと忙しかったですね。
しかし、色々感動しましたね。特にワールドカップの決勝はすんごい試合でしたね。
歴史的な試合ですね。あれは。
日本戦も凄かったですねぁ。前評判は良くなかった日本ですが、いざ本番になるといくつもの奇跡と、いくつも感動と勇気を与えて頂いたと思います。とにかく、あの頑張り姿にとにかく感動しましたね。いやぁ、良かった。
何本もシュートは打っていたのですが、やっぱそう簡単には点が入らないのがワールドカップ。
でも、皆さん思いませんでした?
「今のシュート、キーパーがいなかったら点入っていたよなぁ。」
と。
そう、キーパーがいなかったら何点になっていたか分かりません。
そこで開幕します!!!
2022 VIVA JS World Cup 開幕!!
Let’s PLAY ▶▶▶ 2022 VIVA JS World Cup
↑↑↑ こちらのページよりプレイできます。 ↑↑↑
GitHub
ソースはこちら
https://github.com/webcyou-org/2022-VIVA-JS-WorldCup
遊び方
さぁ、キーパーのいないフィールドでシュートの練習し放題だ!
次回、2026年のワールドカップ目指してひたすら練習だ!
キーボードの十字カーソルで選手を移動。 xキーでシュートパワーを貯め思いっきりシュート!
↑ : ArrowUp or KeyW → : ArrowRight or KeyD ↓ : ArrowDown or KeyS ← : ArrowLeft or KeyA shoot : KeyX
作り方
今回はゲームエンジンなど使用せず、言語はTypeScript、フレームワークとしてVue3を使用しております。
- vue 3.2.45
- typescript 4.7.4
ゲーム作成に慣れている方は、真新しい情報は乏しいかもですが、フロントエンド技術を用いて新規でゲーム作成したいと思っている方は参考になる内容かもしれなので、よろしければご参考にしてください。
ゲームループ
何はともあれ、ゲームを作成するにはゲームループが必要である。
お金の使い方として、消費、投資、浪費があるように、ゲームループも3つの基本の要素があります。
- 「Input」
- 「Update」
- 「draw」
この三原則である。
いつの日だってこれが基本である。
const gameInterval = setInterval(gameLoop, 1000 / fps); const gameLoop = () => { // input(); update(); draw(); };
プレイヤー
選手達は、Player classとして作成します。主なProps(interface)は以下の通りとなります。
interface PlayerProps { x: number; y: number; width: number; height: number; speed: number; }
x, y座標と共に、width、heightと、キャラクター自身のサイズも保持します。その他に、走るスピードも初期の段階で設定可能としておきます。
ドット絵作成
キャラクターのビジュアルはドット絵(ピクセルアート)で作成しました。
何より、こちらの作成が楽しかったのですが、時間掛かりまくった割には、最終的な仕上りの出来はひどいものとなってしまいました(笑)
つくづく、作成の練習の時間が欲しいと感じる今日この頃です。
ドット絵(ピクセルアート)を作成したアプリは、以下の通りとなります。
こちら、無料且つ、iPad対応アプリなので Apple Pencilで書き書きが可能で、ドット絵(ピクセルアート)をサクッと作成するにはもってこいのアプリだと思います。
こちらのアプリでドット絵のベースを作成し、アニメーションなど仕上げの為、aseprite を利用します。
こちらもドット絵作成アプリとして有名なアプリとなっており、痒いところまで手が届く色んな事が行えるアプリとなっております。
Windows、Mac、Ubuntuと様々なOSにも対応しております。
残念ながら無料ではなく、 $19.99 USD とお金がかかってしまいますが、無料で使えるtrialバージョンもありますので、まずは無料でtrialを使用し、気に入ったら購入しても良いかもですね。
ドット絵作成のバイブル的な書籍
その他に、ドット絵作成のバイブル的な書籍として、「Pixel Logic」という書籍があります。
こちら、日本語版はなく英語版のみとなってしまいますが、そこまで英語の文章も多くなく、絵が多く内容も文字を読まなくても理解しやすい内容となっております。ドット絵に関してはこの一冊でほぼ網羅されている感じはあります。 値段も9$ USDと購入しやすい価格帯 なので気になった方は購入してはいかがでしょうか。
パラパラと見ているだけでも楽しい内容となっております。
プレイヤーのステート
それでは、プレイヤーを実装していきましょう。
まず、ゲーム上のプレイヤーのステートを考えます。
ファミコンなどのサッカーゲームでは、(今もかな)基本的には、 プレイヤー移動 → ボールの当たり判定 → ドリブルモード → シュート。 となっているので、こちらの形を参考に実装していきます。
主な処理として以下の関数を定義していきます。
ゲームループで、mainProcessを実行。
mainProcess() {
移動の際は、moveProcess()が実行される。
moveProcess() {
ボールをキープし、ドリブルモードの際は、keepProcess()が実行される。
keepProcess() {
シュートで、再びプレイヤー移動モード
shoot() {
と言ったところになります。
キーボード操作
キーボードで操作できるように、キーボードのイベントハンドラー 「keydown」と「keyup」 をbindし、取得していきます。
残念ながら、キーボードイベントのコールバックで取得できるキー情報は単体となってしまうので、複数入力されてもそのすべての情報は取得できません。
つまり、 右上移動の際に「右」と「上」のキーを押されても、最後に押された「上」のキーしか取得できないので、押されているキーの状態を保存する必要があります。
ビットマスクによる状態管理
そんなキー操作の状態管理に適しているのがビットマスクとなります。
enum Directionを作成します。それぞれの方向にヘキサリテラル(16進数表記)ビット割当を行います。(ここは好みかな)
export enum Direction { NONE = 0, UP = 0x0001, UP_RIGHT = 0x0003, RIGHT = 0x0002, BOTTOM_RIGHT = 0x0006, BOTTOM = 0x0004, BOTTOM_LEFT = 0x000c, LEFT = 0x0008, UP_LEFT = 0x0009, }
押されている状態をstateのdirectionに保存します。
direction: 0x0000,
【押されているキーを追加】
押されているキーを追加するために、フラグを立てる時は、「|」論理和ORを用いてフラグをtrueにします。
上キー押下時
state.direction |= Direction.UP;
「keyup」イベントでフラグを解除
「keyup」イベントで押されているキーが離されたと判定し、キーフラグを解除します。
その際、 「~」論理否定NOTで反転させ、「&」論理積ANDを用いて論理積を求めます。
論理積用いたフラグ解除式
x & ~y
上キー押上時
state.direction &= ~Direction.UP;
こちらで、複数押されているキーの状態を保存しつつ、キーが離された時にそのキーのフラグの解除も行えました。
これらの状態をPlayerに渡し、ゲームループで移動させます。
コントローラー
ここまでやると、コントローラで移動させたくなってきましたね。
一家に一台あるPS5のコントローラーでも操作できるようにしていきましょう。
(PS5 本体持っていないが、コントローラーだけはある。。)
ゲームパッドAPI
ゲームパッドAPIを用いて実装していきます。
実装の詳細などは割愛させて頂くとし、MDNのドキュメントに実装方法などの詳細が記載されていますので、そちらを参考にして頂ければと思います。
実装していて思ったのが、webkitGetGamepads、webkitGetGamepads()がどうも、TypeScriptで定義されなくなっているようで、ブラウザ側でも実装もされていないかもですね。(各ブラウザ、きちんと確認はしていないのでなんとも。)
: // : navigator.webkitGetGamepads // ? navigator.webkitGetGamepads()
この辺りは削除しました。
ボール
ボールの表現をBall Classで実装していきます。
Playerと同様に、x座標、y座標、幅 width、高さ height、早さ speed、に加え、半径rを定義します。
interface BallProps { x: number; y: number; r: number; width: number; height: number; speed: number; }
ボールの当たり判定
ボールの当たり判定をPlayer側に実装していきます。
プレイヤーとボールの2点間の距離
まず、プレイヤーとボールの2点間の距離を求めるための式として、 三平方の定理(ピタゴラスの定理) を用いて計算します。
「直角三角形において斜辺の長さをc、直角と隣り合う2辺の長さをa、 bとするとき が成り立つ。」
といった公式が使えるので使用していきます。(思い出しました?)
この時、斜線cを求める場合、
こちらの公式が使えますので、こちらを用いて、プレイヤーとボールの位置関係を考えると、以下の図のように
$$(点Aから点Bの長さ)^{2} = (yの差)^{2} + (xの差)^{2}$$
となり、斜線c(距離)を求めるとなると、
$$点Aから点Bの長さ = \sqrt{(yの差)^{2} + (xの差)^{2}}$$
で、求められます。
これを、TypeScript(JavaScript)で実装すると以下の様になります。
PlayerのgetDistance関数
Math.sqrt(Math.pow(x - this.footX, 2) + Math.pow(y - this.footY, 2));(this.footXなどは、足元に中心点を置いてます。)
BallCollider
上記の距離を用いて、ボールの当たり判定を求めます。
サッカーボールは円形なので、$(xの差)^{2} + (yの差)^{2}$が、「半径の2乗」の値より小さいときに当たりといった形で当たり判定をつけることができます。
$$(xの差)^2+(yの差)^2 < r^2$$
PlayerのcheckBallCollision関数
const dx = ball.x - this.x; const dy = ball.y - this.y - this.height / 2; if (dx * dx + dy * dy < ball.r * ball.r) { ...
ゴール判定をPub/Sub的にcallback
ボールがゴールに入ったという判定を、どう保持させようかと考えた時、Ball Classに保持するのが適切かなと思い、Ball Classに保持させました。
ゴールもPlayerやBall同様、Goal classとして抽象化されていて、x、y座標を持っています。
Filedも同様で、GoalとFiledはシングルトンとして扱っています。
isGoal(): boolean { const field: Filed = Filed.getInstance(); const goal: Goal = Goal.getInstance(); let isGoal = false; if ( field.x > this.x + this.width && field.y + goal.y < this.y + this.height && field.y + goal.y + goal.height > this.y + this.height ) { isGoal = true; this.goalCallBack(); } return isGoal; }
この際、ボール自身がゴールに入ったと判定できていますが、それを知らせる為にコールバックが必要となります。goalCallBack関数を変数で定義し、
public goalCallBack: Function = () => {};Pub/Sub的に扱えるように、addGoalCallBack関数で登録(購読)できるようにします。
addGoalCallBack(fn: Function) { this.goalCallBack = fn; }
これで、ボール側からゴールの送信されると、スコアが加算されるようにコールバックを作成できます。
state.balls[i].addGoalCallBack(() => { state.score++; state.isGoalAnime = true; });
Pub/Subは個人的に好き。
Vue3に適応
さあ。画面を作成していくためにVue3を実装していきましょう。
Vue.jsは1の時代から使用しているのですが、Vue3をちゃんと扱うのは何気に今回初かも。
Vue3になり、大きな変化として、 Options API → Composition API となり、更に、Vue.js 3.2 から <script setup>
構文が使える様になっております。
個人的には、ClassComponentのままでも慣れてて良かったのですけどね。
構築のためにviteを使用していきます。(詳細は割愛)
以下のコマンドでプロジェクトを生成。
$ npm init vue@latest
これまでのVueとの違いとして、reactiveやonMountedメソッドなどが用意されていることですかね。
import { ref, reactive, onMounted, onUnmounted } from "vue";
ゲームの状態である stateをまるっとリアクティブ化させるため、reactiveメソッドを使用します。
let state = reactive({ score: 0, direction: 0x0000,
あと、キーボードイベントの為のkeydownイベントリスナーなどは、onMountedメソッドに記述するのが正らしい。
onMounted(() => {
プレイヤーやらボールやらは、単一ファイルコンポーネント(SFC)として作成。
stateの値をそのままpropsでプロパティの受け渡しを行います。
<PlayerView :x="state.player.x" :y="state.player.y" ... />
SFC側で<script setup>
構文用いて受け取り、templateにbindさせれば完成。
<script setup lang="ts"> const props = defineProps<{ x: Number; y: Number; ... }>();
アニメーション
ボールがゴールした際にアニメーションが欲しいなと思い、CSSアニメーションで実装。
ボールのゴールのコールバックで、stateのisGoalAnimeをtrueにしアニメ開始。
animationendイベントでアニメ終了イベントを取得し、stateのisGoalAnimeをfalse。
従来であれば、DOM(アニメのcomponent)にイベントを貼るべきですが、まあこのくらいの規模ならと、ここは横着して雑にwindowにイベントを貼っちゃってます。
window.addEventListener("animationend", () => { if (state.isGoalAnime) state.isGoalAnime = false; });
何個か問題も
SFCの<script setup>
構文内のpropsで「Cannot redeclare block-scoped variable」が発生する。
ThePlayer.vue
const props = defineProps<{
こちらは、「propsの変数は既に宣言されているので再宣言できないよ」というEslintのエラーぽく、SFCが別々のファイルと存在していても、ファイルが グローバルスコープを共有するスクリプトとして宣言している。 とTypeScriptで認識してしまい発生しちゃっているぽいです。
propsを定義している変数のスコープが、グローバルと認識してしまって、ファイルを分けても同様の変数名で宣言した場合にエラーが発生してしまう。
回避の為に、export
でモジュール化するなど (import先のファイルでdefault Vue.extend({})などの宣言)の回避策はあるようですが、そもそもSFC(<script setup>
構文)で作成しているのに export
しないといけないって何?(笑)と思い、そのままにしてしまいました。
これには、皆さんも悩まさせれているようで、回避策も他にも挙がっている感じでしたが、根本的な解決は待つしかなさそうな雰囲気でした。
もうひとつ。こちらも同様で、VScodeにVeturのプラグイン入れていますが、SFCをimportする際に「is not a module.Vetur(2306)」発生。回避のため、@ts-ignore付けてしまった。。。
// @ts-ignore import GoalAnime from "./components/GoalAnime.vue"; is not a module.Vetur(2306)
完成
と言った感じで、今回の作成の大まかなところのみ触れてきましたが、取り敢えず完成しましたね。
あ。。ここまで読んで頂いた人は気がついたかもしれませんが、 render部分ですがVue.jsじゃなく、Canvasでもその他でも良かった訳で、Vue3が使いたかっただけで無理やり使用してみました。
しかしながら、renderをVue3(DOM)にする事によって、CSSでレイアウトできたり、アニメーションを作成したりと、メリットもそれなりにあるかと思います。
また、この様に、ViewとModelの分離が行えれば、容易にrenderも変更できたり、Testも書きやすかったりしますので、ViewとModelの分離は大事ですね。
最後に
バタバタ思いついて、仕事終わって帰って夜中にカチカチと2週間ほどで作成したこともあって、多々至らないところもあるのですが、スルーしちゃってしまっている感があります。。PlayerとBallのframeCount、currentFrameの扱いのブレや、ドリブル時にボールを追いかけるプレイヤーの向きによって近づく距離を手調整行いマジックナンバー発生しているのとか、プレイヤーの状態管理が甘く、ちょっとしたバグも発生しているので、このレベルでもステートマシーンを導入した方が良さげとかとかとか。。
でも、全部やりだしたらキリがないのでここで一旦終了としました。
しかしながら、久々にゲームぽいのを実装して楽しかったのと、(こんな事やってて良いのかという背徳感はありつつ(笑))以下の様にもっとこれやりたい欲も出てきたのは事実です。
- ボール複数対応しているが、複数出すUIとかない。
- やっぱキーパーいるんじゃない?
- キーパーもいたら敵もいるんじゃない?
- ボールの軌道が一直線なので、宙に浮いたり、カーブしたりさせたい
- 敵も作るのであれば、サーバー建てて、2人でプレイできるようにしたい
- そもそもドット絵がイケてないので、ゆっくり作成し直したい。。
- サウンド、SE入れたくもなってきた
…
と、考えたらキリがないのですよね。。
取り敢えず、それなりにサッカーゲームのベースとしても使えるかと思いますので、改造されたい方などはGitHubよりフォークして頂き、ご自由に改造して頂ければ幸いです。また、プルリクもお待ちしております。
https://github.com/webcyou-org/2022-VIVA-JS-WorldCup
Unityで作成すればもっとサクッと作成できるんだろうなぁと思いつつ、メジャーなゲームエンジン以外で、ちょっとまた作成したい。という気持ちが芽生えてしまったので、別のゲームを今後使って行く可能性の高いFlutter(Drat)で作成しようかと考えている今日この頃です。
ではでは。良いクリスマスを。。。