「JavaScriptでインベーダー・ゲームを書いて」
単一のHTMLページが生成されます。最後に以下のようにメッセージが返されています。
このコードをローカルに保存し、ブラウザで開くとシンプルなインベーダー・ゲームが動作します。
※このサンプルは基本的な実装例ですので、衝突判定の精度向上やエフェクトの追加など、さらなる拡張が可能です。
最初にこの生成されたコードをAPEXに組み込んでみます。場合によってReactのコードが生成されることがあります。Oracle APEXにReactを組み込むのはほぼできないので、その場合はプロンプトにVanilla JavaScriptと明示すると、フレームワークを使わないコードが生成されます。
空のAPEXアプリケーションを作成します。名前はRetro Gameとします。
デフォルトで作成されるホーム・ページに組み込みます。
ページ・デザイナでホーム・ページを開きます。
生成されたコードのstyleタグからbodyの指定を除いた記述を、ページ・プロパティのCSSのインラインに転記します。
scriptタグに記述されたJavaScriptのコードをコピーします。
静的コンテンツのリージョンを作成し、生成されたコードに含まれているキャンバス要素をソースのHTMLコードとして記述します。
<canvas id="gameCanvas" width="800" height="600"></canvas>
以上で組み込みは完了です。ページを実行して遊びます。
ゲームに一時停止と再開のボタンを追加します。
「一時停止と再開のボタンを追加してください。」
一時停止と再開のボタンが追加されたコードが生成されました。
APEXアプリケーションに空白ページを作成し、先ほどと同じ手順でstyleタグとscriptタグの記述をページに転記します。
静的コンテンツのリージョンに、ボタンが追加された以下のHTMLソースを記述します。
<div id="controls">
<button id="pauseButton">一時停止</button>
<button id="resumeButton">再開</button>
</div>
<canvas id="gameCanvas" width="800" height="600"></canvas>
ボタンのクリック時に後続の処理をキャンセルするように指示します。
preventDefault()を呼び出すコードが生成されました。
ページを実行して遊んでみます。今度は一時停止と再開が効きます。
データベースとレトロ・ゲームを連携するために、一時停止したときの状態をJSONドキュメントとして出力するファンクションと、そのJSONドキュメントを受け取ってゲームを再開するファンクションを作成してもらいます。
「一時停止したときの状態をJSONドキュメントとして出力するファンクションと、そのJSONドキュメントを受け取ってゲームを再開するファンクションを作成してください。」
ファンクションexportGameState()とimportGameState(jsonDocument)のふたつのファンクションが生成され、状態出力と状態読込というボタンが追加されたコードが生成されました。生成されたコードはこちらです。
APEXアプリケーションに空白ページを作成し、先ほどと同じ手順でstyleタグとscriptタグの記述をページに転記します。
静的コンテンツのリージョンには、ボタンとテキスト領域が追加された以下のHTMLソースを記述します。
<div id="controls">
<button id="pauseButton">一時停止</button>
<button id="resumeButton">再開</button>
<button id="exportButton">状態出力</button>
<button id="importButton">状態読込</button>
</div>
<textarea id="stateOutput" placeholder="ここにJSON状態を貼り付けて読込"></textarea>
<canvas id="gameCanvas" width="800" height="600"></canvas>
最後にデータベースと連携させます。
クイックSQLの以下のモデルより、ゲームの状態を保存する表EBAJ_RETRO_GAME_STATEを作成します。
# prefix: ebaj
retro_game_state
state_name vc80 /nn /unique
state json
レビューおよび実行をクリックした後は、実行や即時実行のボタンをクリックして表を作成するところまで進みます。
表EBAJ_RETRO_GAME_STATEが作成されます。
これからは、通常のAPEXのアプケーション作成作業を行います。
ページ4をコピーしてページ5を作成し、データベースへの連携を追加します。
ページ4からページ5を作成します。
新規ナビゲーション・メニュー・エントリの作成を選択します。
ページのコピーを実行します。
ページのコピーが作成されました。
APEXのアプリケーションらしくするため、および、ボタンの動作をAPEXアクションとして定義するために、Gameのリージョンに作成されているボタンやテキスト領域に代えて、APEXのボタンなどを作成します。
ボタンなどを配置するリージョンを静的コンテンツとして作成します。APEXアクションのコンテキストを作成するため、静的IDとしてCONTROLSを設定します。
ボタンPAUSE(一時停止)、RESUME(再開)、EXPORT(状態出力)、IMPORT(状態読込)を作成します。これらの動作のアクションに動的アクションで定義を選択し、詳細のカスタム属性としてdata-action="<ボタン名>"を設定します。
ボタンRESETは表EBAJ_RETRO_GAME_STATEの内容を全削除して、初期状態に戻すために使用します。このボタンだけは動作のアクションはページの送信です。
JSONドキュメントとして出力した状態はデータベースに保存します。データベースへの状態の保存および取り出しを実行するにあたって、状態名の指定および選択にコンボボックスを使うことにします。
先にコンボボックスの手動入力アイテムを作成します。識別の名前はP5_STATE_NAME_NEW、タイプは非表示です。コンボボックスの手動入力アイテムなので、設定の保護された値はオフにします。
セッション・ステートのデータ型はVARCHAR2、ストレージはリクエストごと(メモリーのみ)とします。
コンボボックスの名前はP5_STATE_NAMEとします。手動入力アイテムとして作成済みのP5_STATE_NAME_NEWを指定します。
LOVのタイプにSQL問合せを選択し、SQL問合せとして以下を記述します。
select state_name d from ebaj_retro_game_state order by id asc
セッション・ステートのデータ型はVARCHAR2、ストレージはリクエストごと(メモリーのみ)です。
リージョンGameのソースのHTMLコードより、ボタンやテキスト領域の要素を除きます。
<canvas id="gameCanvas" width="800" height="600"></canvas>
ブラウザのJavaScriptから呼び出す、データベースにゲームの状態を保存するプロセスをAjaxコールバックとして作成します。名前はEXPORT、ソースのPL/SQLコードとして以下を記述します。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
declare | |
l_state_name_new ebaj_retro_game_state.state_name%type; | |
l_state_name ebaj_retro_game_state.state_name%type; | |
l_state clob; | |
e_export_failed exception; | |
begin | |
l_state_name := :P5_STATE_NAME; | |
l_state_name_new := :P5_STATE_NAME_NEW; | |
l_state := apex_application.g_x01; | |
if length(l_state_name) > 0 then | |
apex_debug.info('existing state = %s, %s', l_state_name, l_state); | |
update ebaj_retro_game_state set state = l_state where state_name = l_state_name; | |
elsif length(l_state_name_new) > 0 then | |
apex_debug.info('new state = %s, %s', l_state_name_new, l_state); | |
insert into ebaj_retro_game_state(state_name, state) values(l_state_name_new, l_state); | |
else | |
apex_debug.info('Both P5_STATE_NAME and P5_STATE_NAME_NEW is empty'); | |
raise e_export_failed; | |
end if; | |
commit; | |
htp.p('{ "success": true }'); | |
end; |
データベースからゲームの状態を取り出すプロセスをAjaxコールバックとして作成します。名前はIMPORT、ソースのPL/SQLコードとして以下を記述します。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
declare | |
l_state_name ebaj_retro_game_state.state_name%type; | |
l_state clob; | |
l_state_json json_object_t; | |
l_response clob; | |
l_response_json json_object_t; | |
begin | |
l_state_name := :P5_STATE_NAME; | |
select state into l_state from ebaj_retro_game_state where state_name = l_state_name; | |
l_state_json := json_object_t(l_state); | |
l_response_json := json_object_t(); | |
l_response_json.put('success', true); | |
l_response_json.put('state', l_state_json ); | |
l_response := l_response_json.to_clob(); | |
htp.p(l_response); | |
end; |
ボタンRESETを押したときに呼び出されるプロセスとしてRESETを作成します。
ソースのPL/SQLコードに以下を記述します。
delete from ebaj_retro_game_state;
サーバー側の条件のボタン押下時にRESETを指定します。
ボタンなどのコンポーネントをAPEX由来のものに置き換えたので、ページ・プロパティのJavaScriptのページ・ロード時に実行をこちらのコードに置き換えます。
とりあえず一通りは完成しました。ページを実行して遊んでみます。
ボタン状態出力および状態入力でデータベースへの保存と取り出しはできていることは確認できました。しかし、キャンバス要素外でのキーボード・イベントでミサイルとかが発射されたりします。また逆にボタンにフォーカスが当たっていると、キャンバス上でのキーボード入力に反応します。
以下のプロンプトを与えて、問題点を修正します。
また、mouseenterの際にcanvasにフォーカスをセットしてください。」
不具合を修正したコードに置き換えます。
リージョンGameのHTMLコードに含まれるキャンバス要素にフォーカスを当てるため、tabindex="0"が追加されています。
<canvas id="gameCanvas" width="800" height="600" tabindex="0"></canvas>
不具合を修正したJavaScriptコードに含まれるボタンの処理をする部分を、再度APEXと連携するコードに置き換えます。置き換えたコードで、ページ・プロパティのJavaScriptのページ・ロード時に実行を置き換えます。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Canvasとコンテキストの取得 | |
const canvas = document.getElementById('gameCanvas'); | |
const ctx = canvas.getContext('2d'); | |
// マウスがCanvas上にあるときのみキーボード操作を有効にするため、 | |
// mouseenter時にCanvasへフォーカス、mouseleave時にフォーカスを外す | |
canvas.addEventListener('mouseenter', (e) => { | |
canvas.focus(); | |
}); | |
canvas.addEventListener('mouseleave', (e) => { | |
canvas.blur(); | |
}); | |
// ゲームの一時停止フラグ | |
let isPaused = false; | |
// ------------------------------------------------------------ | |
// APEXアクションによる実装に置き換える。 | |
// ------------------------------------------------------------ | |
/* | |
// ボタンとテキストエリアの取得 | |
const pauseButton = document.getElementById('pauseButton'); | |
const resumeButton = document.getElementById('resumeButton'); | |
const exportButton = document.getElementById('exportButton'); | |
const importButton = document.getElementById('importButton'); | |
const stateOutput = document.getElementById('stateOutput'); | |
// 各ボタンのクリックイベント(preventDefault()で後続のイベント処理をキャンセル) | |
pauseButton.addEventListener('click', (e) => { | |
e.preventDefault(); | |
isPaused = true; | |
}); | |
resumeButton.addEventListener('click', (e) => { | |
e.preventDefault(); | |
isPaused = false; | |
}); | |
exportButton.addEventListener('click', (e) => { | |
e.preventDefault(); | |
const json = exportGameState(); | |
stateOutput.value = json; | |
console.log("Exported state:", json); | |
}); | |
importButton.addEventListener('click', (e) => { | |
e.preventDefault(); | |
const json = stateOutput.value; | |
importGameState(json); | |
console.log("Imported state:", json); | |
}); | |
*/ | |
// ------------------------------------------------------------ | |
// これ以降は、APEX の JavaScript API を利用して、APEX アプリケーションと連携するためのコード | |
// ------------------------------------------------------------ | |
// ボタンの処理はAPEXアクションとして定義する。 | |
const appCtx = apex.actions.createContext("controls", document.getElementById("CONTROLS")); | |
// ボタンのアクションを定義 | |
appCtx.add([ | |
{ | |
name: "PAUSE", | |
action: (event, element, args) => { | |
isPaused = true; | |
} | |
}, | |
{ | |
name: "RESUME", | |
action: (event, element, args) => { | |
isPaused = false; | |
} | |
}, | |
{ | |
name: "EXPORT", | |
action: (event, element, args) => { | |
apex.debug.info("export"); | |
const stateJSON = exportGameState(); | |
let spinner$ = apex.util.showSpinner($("#CONTROLS")); | |
apex.server.process("EXPORT", { | |
x01: stateJSON, | |
pageItems: "#P5_STATE_NAME,#P5_STATE_NAME_NEW" | |
}, { | |
success: (data) => { | |
apex.item("P5_STATE_NAME").refresh(); | |
apex.message.showPageSuccess("成功"); | |
spinner$.remove(); | |
} | |
}); | |
} | |
}, | |
{ | |
name: "IMPORT", | |
action: (event, element, args) => { | |
apex.debug.info("import"); | |
let spinner$ = apex.util.showSpinner($("#CONTROLS")); | |
apex.server.process("IMPORT", { | |
pageItems: "#P5_STATE_NAME" | |
}, { | |
success: (data) => { | |
const stateJSON = data.state; | |
apex.debug.info(stateJSON); | |
try { | |
importGameState(JSON.stringify(stateJSON)); | |
apex.message.showPageSuccess("成功"); | |
} catch (error) { | |
alert("状態の読み込みに失敗しました: " + error); | |
} | |
spinner$.remove(); | |
} | |
}); | |
} | |
} | |
]); | |
// ------------------------------------------------------------ | |
// APEXとのインテグレーションのコードはここまで。 | |
// ------------------------------------------------------------ | |
// プレイヤーのプロパティ | |
const player = { | |
x: canvas.width / 2 - 20, | |
y: canvas.height - 50, | |
width: 40, | |
height: 20, | |
speed: 5, | |
movingLeft: false, | |
movingRight: false | |
}; | |
// プレイヤーの弾(ショット)の配列 | |
const bullets = []; | |
// インベーダーの設定 | |
const invaders = []; | |
const invaderRowCount = 3; | |
const invaderColumnCount = 8; | |
const invaderWidth = 30; | |
const invaderHeight = 20; | |
const invaderPadding = 10; | |
const invaderOffsetTop = 30; | |
const invaderOffsetLeft = 30; | |
// インベーダーオブジェクトを作成 | |
for (let row = 0; row < invaderRowCount; row++) { | |
for (let col = 0; col < invaderColumnCount; col++) { | |
const x = invaderOffsetLeft + col * (invaderWidth + invaderPadding); | |
const y = invaderOffsetTop + row * (invaderHeight + invaderPadding); | |
invaders.push({ x, y, width: invaderWidth, height: invaderHeight, alive: true }); | |
} | |
} | |
// インベーダーの移動方向とスピード | |
let invaderDirection = 1; // 1:右方向、-1:左方向 | |
const invaderSpeed = 0.5; | |
const invaderDrop = 10; | |
// キーボードイベントはCanvasにフォーカスがある場合のみ有効 | |
canvas.addEventListener('keydown', (e) => { | |
// キーボード操作はCanvasがフォーカスされているときのみ動作する | |
if (document.activeElement !== canvas) return; | |
if (e.code === 'ArrowLeft') { | |
player.movingLeft = true; | |
} else if (e.code === 'ArrowRight') { | |
player.movingRight = true; | |
} else if (e.code === 'Space') { | |
// スペースキーで弾を発射(プレイヤーの中央から) | |
bullets.push({ | |
x: player.x + player.width / 2 - 2.5, | |
y: player.y, | |
width: 5, | |
height: 10, | |
speed: 7 | |
}); | |
} | |
}); | |
canvas.addEventListener('keyup', (e) => { | |
// キーボード操作はCanvasがフォーカスされているときのみ動作する | |
if (document.activeElement !== canvas) return; | |
if (e.code === 'ArrowLeft') { | |
player.movingLeft = false; | |
} else if (e.code === 'ArrowRight') { | |
player.movingRight = false; | |
} | |
}); | |
// ゲームの状態を更新する関数 | |
function update() { | |
if (!isPaused) { | |
// プレイヤーの移動 | |
if (player.movingLeft && player.x > 0) { | |
player.x -= player.speed; | |
} | |
if (player.movingRight && player.x + player.width < canvas.width) { | |
player.x += player.speed; | |
} | |
// 弾の移動と画面外の弾の削除 | |
for (let i = 0; i < bullets.length; i++) { | |
bullets[i].y -= bullets[i].speed; | |
if (bullets[i].y + bullets[i].height < 0) { | |
bullets.splice(i, 1); | |
i--; | |
} | |
} | |
// インベーダーの移動 | |
let hitEdge = false; | |
for (const invader of invaders) { | |
if (!invader.alive) continue; | |
invader.x += invaderSpeed * invaderDirection; | |
if (invader.x + invader.width > canvas.width || invader.x < 0) { | |
hitEdge = true; | |
} | |
} | |
if (hitEdge) { | |
invaderDirection *= -1; | |
for (const invader of invaders) { | |
invader.y += invaderDrop; | |
} | |
} | |
// 弾とインベーダーの衝突判定 | |
for (let i = 0; i < bullets.length; i++) { | |
for (const invader of invaders) { | |
if (!invader.alive) continue; | |
if ( | |
bullets[i].x < invader.x + invader.width && | |
bullets[i].x + bullets[i].width > invader.x && | |
bullets[i].y < invader.y + invader.height && | |
bullets[i].y + bullets[i].height > invader.y | |
) { | |
invader.alive = false; | |
bullets.splice(i, 1); | |
i--; | |
break; | |
} | |
} | |
} | |
// ゲームオーバーチェック:いずれかのインベーダーがプレイヤーの位置まで来たら終了 | |
for (const invader of invaders) { | |
if (invader.alive && invader.y + invader.height >= player.y) { | |
alert("Game Over!"); | |
document.location.reload(); | |
return; | |
} | |
} | |
} | |
} | |
// 画面を描画する関数 | |
function draw() { | |
ctx.clearRect(0, 0, canvas.width, canvas.height); | |
// プレイヤー描画(緑色) | |
ctx.fillStyle = '#00ff00'; | |
ctx.fillRect(player.x, player.y, player.width, player.height); | |
// 弾描画(黄色) | |
ctx.fillStyle = '#ffff00'; | |
for (const bullet of bullets) { | |
ctx.fillRect(bullet.x, bullet.y, bullet.width, bullet.height); | |
} | |
// インベーダー描画(赤色) | |
ctx.fillStyle = '#ff0000'; | |
for (const invader of invaders) { | |
if (invader.alive) { | |
ctx.fillRect(invader.x, invader.y, invader.width, invader.height); | |
} | |
} | |
// 一時停止中は「Paused」と表示 | |
if (isPaused) { | |
ctx.font = "30px Arial"; | |
ctx.fillStyle = "white"; | |
ctx.fillText("Paused", canvas.width / 2 - 50, canvas.height / 2); | |
} | |
} | |
// メインループ | |
function gameLoop() { | |
update(); | |
draw(); | |
requestAnimationFrame(gameLoop); | |
} | |
gameLoop(); | |
// ********************************************* | |
// ゲーム状態をJSONドキュメントとして出力する関数 | |
function exportGameState() { | |
const state = { | |
player: { | |
x: player.x, | |
y: player.y, | |
width: player.width, | |
height: player.height, | |
speed: player.speed | |
}, | |
bullets: bullets.map(bullet => ({ | |
x: bullet.x, | |
y: bullet.y, | |
width: bullet.width, | |
height: bullet.height, | |
speed: bullet.speed | |
})), | |
invaders: invaders.map(inv => ({ | |
x: inv.x, | |
y: inv.y, | |
width: inv.width, | |
height: inv.height, | |
alive: inv.alive | |
})), | |
invaderDirection: invaderDirection | |
}; | |
return JSON.stringify(state); | |
} | |
// JSONドキュメントを受け取って状態を復元しゲームを再開する関数 | |
function importGameState(jsonDocument) { | |
try { | |
const state = JSON.parse(jsonDocument); | |
// プレイヤーの状態を復元 | |
player.x = state.player.x; | |
player.y = state.player.y; | |
player.width = state.player.width; | |
player.height = state.player.height; | |
player.speed = state.player.speed; | |
// 弾の状態を復元(既存の配列をクリアしてから追加) | |
bullets.length = 0; | |
state.bullets.forEach(b => bullets.push(b)); | |
// インベーダーの状態を復元 | |
invaders.length = 0; | |
state.invaders.forEach(inv => invaders.push(inv)); | |
// インベーダー移動方向の復元 | |
invaderDirection = state.invaderDirection; | |
// 状態読込後は再開状態にする | |
isPaused = false; | |
} catch (e) { | |
console.error("状態の読込に失敗しました:", e); | |
} | |
} |
ページを実行し遊んでみます。先ほどの不具合は修正されているようです。
以上でAPEXアプリケーションは完成です。
今回作成したAPEXアプリケーションのエクスポートを以下に置きました。
https://github.com/ujnak/apexapps/blob/master/exports/retro-game.zip
Oracle APEXのアプリケーション作成の参考になれば幸いです。
完