2025年2月14日金曜日

OpenAI o3-mini-highにレトロゲームを作ってもらってOracle APEXのページに組み込む

OpenAIのo3-mini-highにインベーダーゲーム風のレトロゲームを作ってもらって、Oracle APEXのアプリケーションに組み込んでみます。

最初に与えたプロンプトです。

「JavaScriptでインベーダー・ゲームを書いて」

単一のHTMLページが生成されます。最後に以下のようにメッセージが返されています。

このコードをローカルに保存し、ブラウザで開くとシンプルなインベーダー・ゲームが動作します。

※このサンプルは基本的な実装例ですので、衝突判定の精度向上やエフェクトの追加など、さらなる拡張が可能です。

最初にこの生成されたコードをAPEXに組み込んでみます。場合によってReactのコードが生成されることがあります。Oracle APEXにReactを組み込むのはほぼできないので、その場合はプロンプトにVanilla JavaScriptと明示すると、フレームワークを使わないコードが生成されます。


空のAPEXアプリケーションを作成します。名前Retro Gameとします。


デフォルトで作成されるホーム・ページに組み込みます。

ページ・デザイナホーム・ページを開きます。


生成されたコードのstyleタグからbodyの指定を除いた記述を、ページ・プロパティCSSインラインに転記します。



scriptタグに記述されたJavaScriptのコードをコピーします。


ページ・プロパティ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>

ページを実行して遊んでみます。動きはしますが、一時停止は効きません。これはボタンをクリックしたときに、APEXのデフォルトの動作であるページの送信が実行されているためです。


ボタンのクリック時に後続の処理をキャンセルするように指示します。

「ボタンのクリック時にpreventDefaultを呼び出し、後続のイベント処理をキャンセルしてください。」

preventDefault()を呼び出すコードが生成されました。


APEXアプリケーションに空白ページを作成し、先ほどと同じ手順でstyleタグとscriptタグの記述をページに転記します。

ページを実行して遊んでみます。今度は一時停止再開が効きます。


データベースとレトロ・ゲームを連携するために、一時停止したときの状態を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コードとして以下を記述します。

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コードとして以下を記述します。

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ページ・ロード時に実行こちらのコードに置き換えます。

とりあえず一通りは完成しました。ページを実行して遊んでみます。

ボタン状態出力および状態入力でデータベースへの保存と取り出しはできていることは確認できました。しかし、キャンバス要素外でのキーボード・イベントでミサイルとかが発射されたりします。また逆にボタンにフォーカスが当たっていると、キャンバス上でのキーボード入力に反応します。


以下のプロンプトを与えて、問題点を修正します。

「Canvas上にポインタが載っているときだけ、キーボード操作を有効にしてください。
また、mouseenterの際にcanvasにフォーカスをセットしてください。」


不具合を修正したコードに置き換えます。

リージョンGameのHTMLコードに含まれるキャンバス要素にフォーカスを当てるため、tabindex="0"が追加されています。

<canvas id="gameCanvas" width="800" height="600" tabindex="0"></canvas>


不具合を修正したJavaScriptコードに含まれるボタンの処理をする部分を、再度APEXと連携するコードに置き換えます。置き換えたコードで、ページ・プロパティJavaScriptページ・ロード時に実行を置き換えます。

// 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);
}
}
view raw retro-game.js hosted with ❤ by GitHub

ページを実行し遊んでみます。先ほどの不具合は修正されているようです。


以上でAPEXアプリケーションは完成です。

今回作成したAPEXアプリケーションのエクスポートを以下に置きました。
https://github.com/ujnak/apexapps/blob/master/exports/retro-game.zip

Oracle APEXのアプリケーション作成の参考になれば幸いです。