【AIでWEBアプリ開発】センコちゃんのブロック崩しゲームの作成方法と実装内容

AI

センコちゃんのブロック崩し

※ゲームを開始すると音が出るので注意してください(音量を最低に下げれば音が消えます)

ゲームの補足

上のSelect Stage画面でどれかのステージを選択するボタンを押下することで、ブロック崩しゲームが始まります。

基本的には通常のブロック崩しと同じですが、ブロックには種類があり複数回ボールを当てないと壊せないものも有ります。

一定時間でアイテム(星形)が落ちてきますので、取得することで様々なアイテム効果を発揮します。

ただし、アイテム効果にはメリットのあるものとデメリットしかないもの等、複数の効果が存在しますので、取得しないのも一つの選択です。

本ゲームについて

本ゲームはWEBアプリの開発の練習がてらChatGPTを使って作成したもので、完成度は高くないです。

私自身コーディングはそれなりに出来る方で、仕事でWEBシステムを開発した経験も多くありますが、基本的には既存システムの保守がメインだったため、ゼロから作ってWEB公開するというのは新鮮な経験でした。

近年はAIの進歩に伴い、自分でコーディングしなくても簡単に複雑なシステムでも作れる時代となりました。

そのため、今後色々自分で新規アプリやゲーム等を作成してみて、WEB公開をするまでの流れを自分で身に付けたかったため、このゲームを作りました。

今回は簡単なブロック崩しゲームのため複雑な処理は何も入れていませんが、今後作るアプリやゲームはもう少しAIを活用して複雑な機能を盛り込みたいと考えています。

尚、コーディングの大半はChatGPTのo1に任せましたが、正直かなりの回数(週の50回制限を2回超えたので100回以上)やり取りしたことで、AIを使ったコーディングの面倒な部分は大体分かりました。

また、本ゲームはAWS環境で動かしています。今後WEBアプリの拡張性等を考えた場合、AWSやGCP等のクラウドサービスじゃないとやってられないと思ったためです。

ちなみにAWS環境で作成してWEB公開出来るというのは非常に重要なことだと考えていて、ローカル環境でアプリを動かすのとは別格の難しさが有ります。

以下に今回作成したゲームの作成方法とソースコードを載せておきますので、同じように簡単なゲームやアプリをWEB公開したいと考えている人が居れば参考にしてください。

ゲーム作成方法

今回のゲームのようにAIを使ってWEBアプリを開発する場合は、ほとんど場合で同じような手順となると思いますが、具体的な作成手順は以下となります。

①作成するアプリやゲームのコンセプトを考える(要求定義に相当)

②作成するアプリやゲームの具体的な仕様を考える(要件定義~基本設計に相当)

③作成するアプリやゲームをAIに依頼する(コーディングに相当)

④AIが作成したアプリやゲームがローカル環境で想定通りに動作するか確認、問題や改善事項あれば再度AIに依頼して手直しをするのを繰り返す(単体・結合テストに相当)

⑤AIが作成したアプリやゲームがAWS等のWEB環境で想定通りに動作するか確認、問題や改善事項あれば再度AIに依頼して手直しをするのを繰り返す(結合テスト以降のテスト工程に相当)


厳密に言い始めるともっと細かく分けることも出来ますが、大まかな内容としてはほとんどのアプリ・ゲームがこのような感じで作成することになると思います。

①作成するアプリやゲームのコンセプトを考える

ゲームやアプリに関わらず、何か物を作ろうと考えたときには、最初にどういったコンセプト(何のために、どういった目的で作るのか等)で作るのかを考える必要があります。

私が今回のゲームを作る際に考えたコンセプトは以下の通りです。

・今後複数のWEBアプリを簡単に稼働できる状態にしたい(今回作成するゲームだけでなく、複数運用する前提の構成とする)

・費用をあまり掛けずに動かしたい(複数運用しても費用が大きく上がらない構成とする)

・自分が持っているWordpressのブログ上でゲームを表示できるようにしたい

・今後の拡張性を考えてクラウドサービスを利用して運用したい(将来アクセスが増えても簡単に対応できる構成にしておきたい)

・最初に作るゲームは何でも良いが、簡単に動かせて遊べるゲームしたい


本来の要求定義はもっと詳細に目的や分析を行うものですが、個人で作成するWEBアプリやゲーム程度ならこの程度の何がしたいのかを最初に明確にしておけば十分だと思います。

このコンセプトから察することが出来るように、今回はゲーム内容は特に重視しておらず、今後複数のアプリやゲームを低コストで複数稼働出来る基盤づくりを目的としています。

②作成するアプリやゲームの具体的な仕様を考える

作ろうとするもの次第ですが、この工程が最も開発者の知識量等を問われるかもしれません。

コンセプトが決まったら具体的にどうやって作るかを考える必要があるのですが、専門知識がある人であれば、自分で考えて明確な仕様を決めれば良いです。

ただ、大半の人は最初は専門的な知識なんて持っていませんので、どういったものが作りたいのかを簡単に整理して、ChatGPT等のAIに教えてもらう方が良いです。

私の場合も最初は上で挙げたコンセプトを満たすようなゲームやWEBアプリが開発したい旨をChatGPTに相談して、具体的な作成手順を教えてもらいました。

しかし、この時に細かい条件を付けなければ、AIの方で足りない情報を自動で補完したうえで教えてくれる形となります。

本来は非常にありがたい機能なのですが、この仕様があるために最初は問題ないと思って作成していたアプリでも、作業が進んでから問題点に気づいてやり直すことが多々起きます。

今回のゲーム作成でも最初はChatGPTが勧める通り、今後作成することになる複数のアプリを全てAWSのElastic Beanstalkで稼働させようとしましたが、後になってこの方法では非常に大きなコストが掛かることが分かり、すべてやり直しました。

それ以降も同じように作成する度に何度も後になってから色々問題点に気づいてやり直してしまい、この仕様を詰めるだけでもChatGPTと数百回のやり取りをすることになりました。

専門知識のある人であれば、そういった問題点も最初から考慮したうえでAIに指示をだせるはずなので、知識のある人とない人ではここの手戻り作業の量に大きな差が付きます。

それでも何度も手戻り作業を繰り返す中で最適な構成を理解できるようにはなってくるはずですので、知識の無い人は諦めずに作業を続ければ必ず自分の望みにあったものを作れるようになるはずです。

③作成するアプリやゲームをAIに依頼する

具体的な仕様が決まれば後は実際に実装(コーディング)するだけとなりますが、本来であればこの実装の作業が開発においては最も時間の掛かる作業です。

ですが、今の時代のAIは非常に性能が高いため、AIに依頼する際のプロンプトの精度が高ければ高いほど、自分でのコーディングは一切しなくとも、想定通りの動きをするものを作ってくれます。

ただ、ChatGPTに依頼する場合は利用するモデルを可能な限り性能の高いもの(私の場合はo1を選択)にしてください。

私もChatGPTの4oで最初は試していましたが、出来上がるゲームがまともに動かないのがほとんどで、動いたとしても想定していたものは全く異なるものがほとんどでした。

月額20ドル(3000円くらい)でPlusユーザーにはなってo1が週に50回は使えるので、もしChatGPTを使って開発したいのであれば、最低限Plusユーザーになるのは必須かもしれません。

ちなみに私が今回のゲーム開発時に依頼した初期プロンプトは以下のようなものです。

私は個人開発者で今後WEBアプリを開発して世界に公開してみたいと考えています。そこで、以下にどのような形でアプリを開発して管理したいかの要望、そしてそのうえでお願いしたいことについて整理しましたので、協力してください。

■基本的な要望
・Node.jsで複数アプリを開発して管理したい
・Dockerを使った単一コンテナ方式でアプリを一つのサーバー上で複数動かしたい
・初期はコストを抑えたいので単一サーバーで問題ない。
・人気が出るようならより大きなサーバーやELBでのオートスケーリング等もしたいので、アプリは環境に依存せずサーバーに載せれば動くような構成にしたい。
・開発したアプリは別サーバーで運営しているWordpressのブログからiframe(https接続)で呼び出したい
・専用のドメイン(xxxx.com)とその証明書は取得済みなので、そちらを活用したい

■構成面での要望
・ローカル環境とAWS環境で簡単に動かせる構成にしたい
・AWS環境はElastic BeanstalkでDockerをデプロイすればすぐに稼働するような構成にしたい
・初期はコストを抑えたいので、ELBを使わずに単一サーバー(安いEC2インスタンス)としたい
・将来的にはElastic Beanstalkの高可用性の構成の環境を作成して、そちらに乗り換えることを検討
・コンテナ内でNginxを用いた、リバースプロキシで複数アプリへのアクセスを制御したい(app1,app2のように複数のアプリが起動できる状態)
・app1にはブロック崩しゲームを作成して欲しい
・CloudFront経由でhttps接続を行う(コストを抑えたいため、将来的にはELBでHTTPS接続を行う)

■お願いしたいこと
・上記の要望を満たした最適な環境構成についての提言
・上記の要望を満たした最適な構成でのサンプルプログラムの提供(ソースとフォルダ構成、ソースコードはコードの中身を全量で提供してください)
・上記の要望を満たした最適な構成でのローカル環境での実行・管理手順の詳細な解説
・上記の要望を満たした最適な構成でのAWS環境での実行・管理手順の詳細な解説

ChatGPTにアプリ作成を依頼する際のプロンプト
「え、ここまで細かく指定する必要あるの?」と思った人が多いかもしれませんが、単にローカル環境で動かしたり、コストやセキュリティ等を一切考慮せずにクラウドで稼働させたいだけであれば、ここまで細かく指定する必要はありません。

ただ、今回のコンセプトで挙げたような仕様を満たしたい場合は最低限これくらいは指定しないと希望している方向性の実装はしてくれません。

大体のネットで騒いでいる人達(インフルエンサー等)は「簡単に作れる」と連呼していますが、簡単な指示で作成したアプリは先述した通り、問題点が極めて多いため現実的な運用は期待できません。

正直、このプロンプトで作ってくれたものに対しても、その後数十回以上やり取りをして何とかゲームが完成したくらいです。

このことからも分かる通り、AIでWEBアプリ開発というのは可能ではあるのですが、運用面等を突き詰めて考え始めると、それなりの知識量は要求されます。

最低限の情報として、開発言語、フレームワーク、コンテナの利用有無、稼働方法、稼働させる環境、接続方法といった基盤的な要件と作成するアプリやゲームを実現するための要件を全て指示するのが無難です。

④ローカル環境で想定通りに動作するか確認

これはもうそのまんまの作業ですが、AWS等のWEB環境に上げる前にローカル環境でゲームやアプリが想定通りの内容がどうかを確認する作業です。

知識のある人なら難しくない作業ですが、初めての人にとってはかなりの鬼門となる作業工程です。

例えば、Pythonの開発言語で稼働させる場合はPythonのインストールとパスの設定が必要ですし、Dockerを使ったコンテナで起動させたいなら、Docker関連のインストール等が必要となってきます。

AIに聞けばそれらの設定方法等は教えてくれるのですが、そもそも何故インストールが必要なのか、何故この設定が必要なのか等を本質的に理解していないと、以降の工程で発生する問題に対応できないことが多くなります。

それに良く分からない環境を設定すること自体が、初心者にとってはかなり負担に感じられるはずですので、多くの初心者はこの作業で脱落すると思います。

また、環境を設定出来たとしてもAIの作ったアプリの完成度があまり高くない場合、上手く起動できずにアプリの問題か環境の問題なのかを切り分ける必要が出てきます。

エラー内容をAIに確認すれば対策を教えてくれるのですが、AIもこういった対応は結構な頻度で誤った対策等を提示してくるため、根本的な知識が不足していると上手く起動させることも困難となってきます。

これらの理由からローカル環境で想定通りに起動させることが出来るかどうかが、一つの初心者かどうかを測る目安になってくると思いますので、意欲のある人は何とかここまでは出来るようになりましょう。

⑤AWS等のWEB環境で想定通りに動作するか確認

最後の手順はWEBアプリを他の人が見れる場所(ネット)に公開することです。

費用に余裕のある人はテスト用と本番用の2つの環境を持っておくのが理想ですが、余裕のない人は最初は本番用の環境だけでも十分です。

WEBアプリをネット公開するにはいくつも方法が有りますが、基本的にはセキュリティや拡張性等を考慮していくと個人・法人問わず、クラウドサービスを利用するの一択になると思います。

ですが、クラウドサービスと一口に言っても、AWS・Azure・GCPといった大手からさくら等の中堅どころまで多岐に渡ります。

また、更にAWSのクラウドサービスの中でもEC2やECS、LightSail等の様々な環境でWEBアプリをデプロイすることが出来るので、これらの中から自分の目的に合わせた最適な環境を選ぶ必要があります。

特にこだわりが無ければそこまで難しい作業ではありませんが、セキュリティ・コスト・拡張性等の様々な運用観点を考慮するほど、構成の難易度は加速度的に増していきます。

もちろん、AIに質問しながら構成を決めていくのは有りなのですが、これもローカル環境の時と同様でそれぞれ構築する環境がなぜ必要なのかを本質的に理解していなければ、運用を維持し続けるのは非常に困難だと思います。

正直、こういった環境面を自分の目的に合わせて最適化するには専門知識が必要であり、単純にAIで全てやってもらうというのは非常に難しい分野です。

私はコンセプトのところで挙げた内容を実現する環境を作るため、AIと数えきれないほどの問答を繰り返したので、WEB環境を構築するには多大な労力が必要となることを理解しておいてください。

ゲームの実装内容

私が作成したゲームの具体的な実装内容について解説していきたいと思います。

今回作成したゲームは単純なブロック崩しゲームですが、今後の拡張性を考慮してDockerを使ったコンテナによって稼働する構成としています。

以下がプロジェクトの構成ですが、今回作成したブロック崩しは「app1」として作成し、今後作成するゲームやアプリは「app2」以降に同じような作り方で追加できるような構成にしています。

app1やapp2の各アプリはそれぞれ専用のポート番号を割り振って並列稼働させて、割り振ったポート番号のアプリへのアクセスにはNginxのリバースプロキシを使うようにしています。
root:.
│  Dockerfile
│  entrypoint.sh
│  README.md
│
├─apps
│  ├─app1
│  │  │  index.js
│  │  │  package.json
│  │  │
│  │  └─public
│  │      │  index.html
│  │      │  main.js
│  │      │  style.css
│  │      │
│  │      ├─images
│  │      └─sound
│  │
│  └─app2
│          index.js
│          package.json
│
└─nginx
        default.conf
この構成とすることでAWS環境上で動かす実体としてはDocker一つだけとなるため、Elastic BeanstalkでEC2にデプロイするだけで全てのアプリにアクセスできるようになります。

他には、各アプリにアクセスする際には私のWordpressブログからhttps接続のiframeで呼び出す運用としているため、https接続を可能とするためにAWS側のCloudfrontに証明書を入れて経由する仕組みとしています。

そのため、この環境を動かすために掛かっている主な費用はEC2のインスタンス料金くらいとなりますが、インスタンスもt3.microを選択しているため、運用は格安ですし無料期間を活用することも出来ます。

これらのことから当初のコンセプトとしていた、複数のアプリをコストがほとんど掛からない方法でかつ、セキュリティや拡張性も維持しつつ、Wordpressのブログから簡単に呼び出せて、管理も楽な構成のアプリ基盤を作るということを実現出来たと考えています。

欠点はEC2を単一稼働させているため、毎回アプリをどれか更新する度にすべてのアプリが一時的に停止してしまう構成になっているという、低すぎる可用性ですかね。

ただ、将来的に多数のアクセスが見込めるのであれば、ELBに証明書を入れて複数サーバーで運用する構成にすることで解決可能です。

その場合は複数サーバーを運用するための費用とELBを稼働させるための費用が掛かってしまうため、それくらいの費用がペイできるようにならないと微妙だと思いますが。

以降は参考までに実際の主要なソースコードを載せていきます。imagesとsoundの中の画像やBGMは好きなものに置き換えれば動くと思います。

Dockerfile

# 1. Node.js公式イメージをベース
FROM node:18-bullseye

# 2. Nginxインストール & デフォルト設定削除
RUN apt-get update && \
    apt-get install -y nginx && \
    rm -rf /var/lib/apt/lists/* && \
    rm -f /etc/nginx/sites-enabled/default

# 3. 作業ディレクトリ
WORKDIR /usr/src/app

# 4. アプリソースをコピー
COPY apps/ ./apps/

# 5. app1, app2 の 依存関係インストール
WORKDIR /usr/src/app/apps/app1
RUN npm install

WORKDIR /usr/src/app/apps/app2
RUN npm install

# 6. Nginx設定ファイルをコピー
WORKDIR /usr/src/app
COPY nginx/default.conf /etc/nginx/conf.d/default.conf

# 7. entrypoint.sh をコピー & 実行権限
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

# 8. ポート公開
EXPOSE 80

# 9. コンテナ起動時に実行
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

entrypoint.sh

#!/usr/bin/env bash
set -e

echo "Starting App1 (Block Breaker)..."
PORT=3001 node /usr/src/app/apps/app1/index.js &
APP1_PID=$!

echo "Starting App2..."
PORT=3002 node /usr/src/app/apps/app2/index.js &
APP2_PID=$!

sleep 3

# app1の生存確認
if ! ps -p $APP1_PID > /dev/null; then
    echo "Error: App1 (Block Breaker) failed to start."
    exit 1
fi
# app2の生存確認
if ! ps -p $APP2_PID > /dev/null; then
    echo "Error: App2 failed to start."
    exit 1
fi

echo "Starting Nginx..."
exec nginx -g 'daemon off;'

main.js

/**
 * main.js
 * 依頼内容(要件1〜10)をすべて満たす最終版ブロック崩しゲームの実装 (app1)
 * ・各ステージで背景画像、BGM、効果音を個別に差し替え可能
 * ・初期画面はステージ選択画面(BGM/SEの音量調整含む)
 * ・パドル操作は矢印キー、マウス、タッチ操作に対応
 * ・ボールが一度でも落下するとゲームオーバー
 * ・ゲームクリア時はステージ背景を隠さず、ブロックとパドルを消去し、ダイアログをパドル位置に表示
 * ・ボール/パドルの色はブロック色と被らない(ボール: limeまたは金色、パドル: #00ccff)
 * ・高難易度ステージではブロックが画面内に収まるように調整
 * ・アイテムとして星形のオブジェクトを表示(1.5倍サイズ)
 * ・アイテム効果は6種類(ランダム):
 *    0: パドル幅2倍
 *    1: ボールが金色に変化し、ブロックに衝突時は反射せず貫通(パドルでは反射)
 *    2: ボール速度2倍 (効果終了後元に戻す)
 *    3: ボールサイズ2倍
 *    4: パドル幅が半分になる
 *    5: ボール速度1.5倍 (効果終了後元に戻す)
 */

const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");

const stageSelectScreen = document.getElementById("stageSelectScreen");
const dialogScreen = document.getElementById("dialogScreen");
const dialogMessage = document.getElementById("dialogMessage");
const returnMenuButton = document.getElementById("returnMenuButton");
const bgmVolume = document.getElementById("bgmVolume");

const stageData = [
  {
    background: "images/stage1.png",
    bgm: "sound/stage1_bgm.mp3",
    blockHitSound: "sound/stage1_hit.mp3",
    effectSound: "sound/stage1_effect.mp3",
    rows: 6,
    cols: 8,
    blockTypes: [1, 2],
  },
  {
    background: "images/stage2.png",
    bgm: "sound/stage2_bgm.mp3",
    blockHitSound: "sound/stage2_hit.mp3",
    effectSound: "sound/stage2_effect.mp3",
    rows: 8,
    cols: 10,
    blockTypes: [1, 3],
  },
  {
    background: "images/stage3.png",
    bgm: "sound/stage3_bgm.mp3",
    blockHitSound: "sound/stage3_hit.mp3",
    effectSound: "sound/stage3_effect.mp3",
    rows: 10,
    cols: 12,
    blockTypes: [2, 3],
  },
  {
    background: "images/stage4.png",
    bgm: "sound/stage4_bgm.mp3",
    blockHitSound: "sound/stage4_hit.mp3",
    effectSound: "sound/stage4_effect.mp3",
    rows: 10,
    cols: 14,
    blockTypes: [1, 2, 3],
  },
  {
    background: "images/stage5.png",
    bgm: "sound/stage5_bgm.mp3",
    blockHitSound: "sound/stage5_hit.mp3",
    effectSound: "sound/stage5_effect.mp3",
    rows: 15,
    cols: 18,
    blockTypes: [1, 2, 3],
  },
];

let currentStage = 0;
let blockArray = [];
let blockWidth = 40;
let blockHeight = 20;
let blockPadding = 5;
let blockOffsetTop = 50;
let blockOffsetLeft = 50;

const canvasWidth = 640;
const canvasHeight = 480;

const paddleHeight = 10;
const paddleWidthBase = 80;
let paddleWidth = paddleWidthBase;
let paddleX = (canvasWidth - paddleWidth) / 2;

const ballRadiusBase = 8;
let ballRadius = ballRadiusBase;
let x, y, dx, dy;

let rightPressed = false;
let leftPressed = false;
let mouseDown = false;

let itemDropInterval = 20000;
let lastItemDropTime = 0;
let item = null;
let itemEffectActive = false;
let itemEffectEndTime = 0;
let savedSpeed = null;

let penetrationMode = false;

let isGameRunning = false;
let lives = 3;

let bgmAudio, blockHitAudio, effectAudio;

// ----------------------------------------------
// イベントリスナー
// ----------------------------------------------
document.querySelectorAll(".stageSelectBtn").forEach((btn) => {
  btn.addEventListener("click", (e) => {
    currentStage = parseInt(e.target.dataset.stage, 10);
    stageSelectScreen.classList.add("hidden");
    startGame();
  });
});

returnMenuButton.addEventListener("click", returnToStageSelect);

document.addEventListener("keydown", keyDownHandler);
document.addEventListener("keyup", keyUpHandler);
canvas.addEventListener("mousemove", onMouseMove);
canvas.addEventListener("touchmove", onTouchMove, { passive: false });

// ----------------------------------------------
// 初期画面設定
// ----------------------------------------------
dialogScreen.classList.add("hidden");
canvas.style.display = "none";

// ----------------------------------------------
// ゲーム開始
// ----------------------------------------------
function startGame() {
  canvas.style.display = "block";
  dialogScreen.classList.add("hidden");
  isGameRunning = true;
  lives = 3;
  initStage(currentStage);
  initBallAndPaddle();
  initAudioForStage(currentStage);
  onVolumeChange();
  bgmAudio.play();
  lastItemDropTime = performance.now();
  requestAnimationFrame(gameLoop);
}

function initStage(stageIdx) {
  const st = stageData[stageIdx];
  canvas.style.background = `url('/app1/${st.background}') no-repeat center / cover`;
  blockArray = [];
  for (let c = 0; c < st.cols; c++) {
    blockArray[c] = [];
    for (let r = 0; r < st.rows; r++) {
      const dur = st.blockTypes[Math.floor(Math.random() * st.blockTypes.length)];
      blockArray[c][r] = { x: 0, y: 0, durability: dur };
    }
  }
  const maxWidth = canvasWidth - blockOffsetLeft * 2 - blockPadding * (st.cols - 1);
  blockWidth = Math.floor(maxWidth / st.cols);
  const halfCanvas = Math.floor(canvasHeight * 0.5);
  blockHeight = Math.floor((halfCanvas - blockOffsetTop - blockPadding * (st.rows - 1)) / st.rows);
}

function initBallAndPaddle() {
  x = canvasWidth / 2;
  y = canvasHeight - 30;
  dx = 3;
  dy = -3;
  paddleWidth = paddleWidthBase;
  ballRadius = ballRadiusBase;
  paddleX = (canvasWidth - paddleWidth) / 2;
  item = null;
  itemEffectActive = false;
  savedSpeed = null;
  penetrationMode = false;
}

function initAudioForStage(stageIdx) {
  const st = stageData[stageIdx];
  bgmAudio = new Audio(`/app1/${st.bgm}`);
  bgmAudio.loop = true;
  blockHitAudio = new Audio(`/app1/${st.blockHitSound}`);
  effectAudio = new Audio(`/app1/${st.effectSound}`);
}

// ----------------------------------------------
// ゲームループ
// ----------------------------------------------
function gameLoop(timestamp) {
  if (!isGameRunning) return;
  ctx.clearRect(0, 0, canvasWidth, canvasHeight);
  drawBlocks();
  handleBall();
  drawPaddle();
  drawItem(timestamp);
  checkItemDrop(timestamp);
  requestAnimationFrame(gameLoop);
}

function drawBlocks() {
  const st = stageData[currentStage];
  for (let c = 0; c < st.cols; c++) {
    for (let r = 0; r < st.rows; r++) {
      const block = blockArray[c][r];
      if (block.durability > 0) {
        const brickX = c * (blockWidth + blockPadding) + blockOffsetLeft;
        const brickY = r * (blockHeight + blockPadding) + blockOffsetTop;
        block.x = brickX;
        block.y = brickY;
        ctx.beginPath();
        ctx.rect(brickX, brickY, blockWidth, blockHeight);
        let color = "#0095DD";
        if (block.durability === 2) color = "#DD9500";
        if (block.durability === 3) color = "#DD0009";
        ctx.fillStyle = color;
        ctx.fill();
        ctx.closePath();
      }
    }
  }
}

function handleBall() {
  ctx.beginPath();
  ctx.arc(x, y, ballRadius, 0, Math.PI * 2);
  ctx.fillStyle = penetrationMode ? "gold" : "lime";
  ctx.fill();
  ctx.closePath();

  x += dx;
  y += dy;

  if (x < ballRadius || x > canvasWidth - ballRadius) dx = -dx;
  if (y < ballRadius) {
    dy = -dy;
  } else if (y > canvasHeight - ballRadius) {
    if (x > paddleX && x < paddleX + paddleWidth) {
      let collidePoint = x - (paddleX + paddleWidth / 2);
      collidePoint = collidePoint / (paddleWidth / 2);
      const angle = collidePoint * (Math.PI / 3);
      const speed = Math.sqrt(dx * dx + dy * dy);
      dx = speed * Math.sin(angle);
      // パドルとの衝突は常に反射させる
      dy = -speed * Math.cos(angle);
    } else {
      gameOver("GAME OVER");
      return;
    }
  }
  collisionDetection();
}

function collisionDetection() {
  const st = stageData[currentStage];
  for (let c = 0; c < st.cols; c++) {
    for (let r = 0; r < st.rows; r++) {
      const b = blockArray[c][r];
      if (b.durability > 0) {
        if (
          x > b.x &&
          x < b.x + blockWidth &&
          y > b.y &&
          y < b.y + blockHeight
        ) {
          if (!penetrationMode) {
            dy = -dy;
          }
          b.durability--;
          blockHitAudio.currentTime = 0;
          blockHitAudio.play();
          if (checkStageClear()) {
            stageClear();
          }
        }
      }
    }
  }
}

function checkStageClear() {
  for (const col of blockArray) {
    for (const b of col) {
      if (b.durability > 0) return false;
    }
  }
  return true;
}

function stageClear() {
  // ゲームクリア時は、ブロックとパドルを消去して背景を表示
  // ここでキャンバス上をクリアしてから、ダイアログを表示
  ctx.clearRect(0, 0, canvasWidth, canvasHeight);
  gameOver("Stage Cleared!");
}

function gameOver(msg) {
  isGameRunning = false;
  if (msg === "GAME OVER") {
    canvas.style.display = "none";
  }
  // ステージクリア時は、ダイアログをパドル表示していた位置に表示
  if (msg === "Stage Cleared!") {
    dialogScreen.style.position = "absolute";
    // パドルはcanvasHeight - paddleHeight; ダイアログをその少し上に配置
    dialogScreen.style.top = (canvasHeight - paddleHeight - 30) + "px";
    dialogScreen.style.bottom = "";
  } else {
    dialogScreen.style.position = "";
    dialogScreen.style.bottom = "";
  }
  dialogMessage.textContent = msg;
  dialogScreen.classList.remove("hidden");
  if (bgmAudio) bgmAudio.pause();
}

function drawPaddle() {
  ctx.beginPath();
  ctx.rect(paddleX, canvasHeight - paddleHeight, paddleWidth, paddleHeight);
  ctx.fillStyle = "#00ccff";
  ctx.fill();
  ctx.closePath();
}

function keyDownHandler(e) {
  if (e.key === "ArrowRight" || e.key === "Right") rightPressed = true;
  else if (e.key === "ArrowLeft" || e.key === "Left") leftPressed = true;
}
function keyUpHandler(e) {
  if (e.key === "ArrowRight" || e.key === "Right") rightPressed = false;
  else if (e.key === "ArrowLeft" || e.key === "Left") leftPressed = false;
}

function onMouseMove(e) {
  const rect = canvas.getBoundingClientRect();
  const mouseX = e.clientX - rect.left;
  paddleX = mouseX - paddleWidth / 2;
  limitPaddle();
}
function onTouchMove(e) {
  e.preventDefault();
  const rect = canvas.getBoundingClientRect();
  const touch = e.touches[0];
  const touchX = touch.clientX - rect.left;
  paddleX = touchX - paddleWidth / 2;
  limitPaddle();
}
function limitPaddle() {
  if (paddleX < 0) paddleX = 0;
  else if (paddleX + paddleWidth > canvasWidth) paddleX = canvasWidth - paddleWidth;
}

function checkItemDrop(timestamp) {
  if (timestamp - lastItemDropTime > itemDropInterval) {
    dropItem();
    lastItemDropTime = timestamp;
  }
  if (itemEffectActive && performance.now() > itemEffectEndTime) {
    resetItemEffect();
  }
}

function dropItem() {
  item = {
    x: Math.random() * (canvasWidth - 20) + 10,
    y: 10,
    active: true
  };
}

function drawItem(timestamp) {
  if (!item || !item.active) return;
  item.y += 3;
  ctx.beginPath();
  ctx.fillStyle = "yellow";
  // 星形を1.5倍に拡大
  ctx.moveTo(item.x, item.y);
  ctx.lineTo(item.x + 8, item.y + 23);
  ctx.lineTo(item.x - 8, item.y + 9);
  ctx.lineTo(item.x + 8, item.y + 9);
  ctx.lineTo(item.x - 8, item.y + 23);
  ctx.closePath();
  ctx.fill();

  if (
    item.y > canvasHeight - paddleHeight - 10 &&
    item.x > paddleX &&
    item.x < paddleX + paddleWidth
  ) {
    item.active = false;
    triggerItemEffect();
  }
  if (item.y > canvasHeight + 20) {
    item.active = false;
  }
}

function triggerItemEffect() {
  effectAudio.currentTime = 0;
  effectAudio.play();

  itemEffectActive = true;
  itemEffectEndTime = performance.now() + 10000;
  const rand = Math.floor(Math.random() * 6);
  switch (rand) {
    case 0:
      paddleWidth *= 2;
      break;
    case 1:
      // ボールが金色に変化し、ブロックには貫通する(ただしパドルでは反射)
      penetrationMode = true;
      break;
    case 2:
      savedSpeed = { dx, dy };
      dx *= 2;
      dy *= 2;
      break;
    case 3:
      ballRadius *= 2;
      break;
    case 4:
      paddleWidth = paddleWidthBase / 2;
      break;
    case 5:
      savedSpeed = { dx, dy };
      dx *= 1.5;
      dy *= 1.5;
      break;
  }
}

function resetItemEffect() {
  itemEffectActive = false;
  penetrationMode = false;
  paddleWidth = paddleWidthBase;
  ballRadius = ballRadiusBase;
  if (savedSpeed) {
    dx = savedSpeed.dx;
    dy = savedSpeed.dy;
    savedSpeed = null;
  }
}

function returnToStageSelect() {
  dialogScreen.classList.add("hidden");
  stageSelectScreen.classList.remove("hidden");
  canvas.style.display = "none";
  isGameRunning = false;
  if (bgmAudio) bgmAudio.pause();
}

function onVolumeChange() {
  const vol = parseFloat(bgmVolume.value);
  if (bgmAudio) bgmAudio.volume = vol;
  if (blockHitAudio) blockHitAudio.volume = vol;
  if (effectAudio) effectAudio.volume = vol;
}

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>センコちゃんのブロック崩し</title>
  <link rel="stylesheet" href="/app1/style.css" />
</head>
<body>
  <h1>センコちゃんのブロック崩し</h1>

  <!-- ステージ選択画面 -->
  <div id="stageSelectScreen">
    <!-- BGMのON/OFFボタンを削除(ボリュームバーで調整) -->
    <label for="bgmVolume">BGM / SE Volume:</label>
    <input type="range" id="bgmVolume" min="0" max="1" step="0.1" value="1" />
    <h2>Select Stage</h2>
    <button class="stageSelectBtn" data-stage="0">Stage 1</button>
    <button class="stageSelectBtn" data-stage="1">Stage 2</button>
    <button class="stageSelectBtn" data-stage="2">Stage 3</button>
    <button class="stageSelectBtn" data-stage="3">Stage 4</button>
    <button class="stageSelectBtn" data-stage="4">Stage 5</button>
  </div>

  <!-- ダイアログ(ステージクリア/ゲームオーバー) -->
  <div id="dialogScreen" class="hidden">
    <div id="dialogMessage"></div>
    <!-- ステージ画像の邪魔にならないよう、ダイアログは画面下部に表示 -->
    <button id="returnMenuButton">Return to Stage Select</button>
  </div>

  <!-- キャンバス(ゲーム画面) -->
  <canvas id="gameCanvas" width="640" height="480"></canvas>

  <script src="/app1/main.js"></script>
</body>
</html>

default.conf

server {
    listen 80 default_server;

    # ルート('/')にアクセスしたら /app1/ にリダイレクト
    location = / {
        return 302 /app1/;
    }

    # /app1/ → app1(3001)
    location /app1/ {
        proxy_pass http://127.0.0.1:3001;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    # /app2/ → app2(3002)
    location /app2/ {
        proxy_pass http://127.0.0.1:3002;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

まとめ

本記事ではAIを用いた複数アプリを作成するためのアプリ基盤の作成方法と、その基盤の上で動かしているブロック崩しゲームの簡単な解説を行いました。

紹介した手順でなくとも同じような機能を持つアプリを作る事は出来るのですが、どうしてもセキュリティ・コスト・拡張性等を考え始めると、これぐらいの作業は必要になってくると思います。

良くネットではローカル環境や一時的なWEB環境でアプリを動かして騒ぐようなインフルエンサーも多いですが、そういったものは現実的な運用を考えるとほぼ役に立たないと思われるものが多く、実際にはほとんど参考になりません。

AIを使えば簡単にアプリやゲームを開発出来るというのは一概に間違ってはいないのですが、そのためにはAIを十分に活用するための事前知識が必要になるということを良く理解する必要があります。

もちろん、これまでより圧倒的に開発が楽になったのは疑いようのない事実ですので、恵まれた時代になったと思う反面、AIを活用するための知識を持つ人と持たない人で大きく差が付く時代になったようにも感じます。

今後アプリやゲームを開発してWEBで公開してみたいという人は本記事を参考に一度AIで作ってみて、何度もAIと対話を繰り返しながら作る難しさを学んでおくと今後必ず役に立つと思います。

他にもアプリを作るたびに記事にはするつもりなので、興味があればまた閲覧いただけると幸いです。

Canvaで簡単にマンガを作る方法 AIイラストで誰でもマンガ家になれる時代! – センコの活動記録 (senkohome.com)

コメント

タイトルとURLをコピーしました