
非エンジニアでもここまでできる!o3-miniで色々作ってみた
AIの進化に伴い、ビジネスパーソンにも手軽なプロトタイピングの需要が高まっています。とくに、OpenAIの最新モデル『o3-mini』を使えば、非エンジニアでも簡単にコードを生成したり、わからない箇所を補完したりすることが可能です。
本noteでは、Google Colaboratory(通称 Colab)というクラウド環境を利用し、非エンジニアの私がモーショングラフィックスや簡易ゲームを5種類ほど作ってみた体験をシェアします。
生成したコードも載せていますので、Google Colaboratoryで実行したら同じように動きます。ぜひ試してみてください。
OpenAIのo3-miniとは?
2024年2月1日にリリースされた、OpenAIの最新の小型推論モデルです。ChatGPTおよびAPI経由での利用が可能で、無料プランでも制限付きで使用できます。
主な特徴
速度と性能
・前モデルより24%速く応答
・特に数学、科学、プログラミングが得意
・3段階の推論モード(低・中・高)から選べる
※ 低(lowモード)はAPI利用時のみ選択可
できること
・プログラミングコードの生成
・数学や科学の問題解決
・最新情報を含めた回答作成
★ 特に、o3-miniは推論と検索を同時に行うことができることが大きな特徴です
各プランの使用制限
・Proプランは、無制限で使用可能
・Plusプランは、o3-miniが約150回/日、o3-mini-highが約50回/週
・無料プランは、もっと制限が強いが利用は可能
Google Colaboratoryについて
今回の開発環境には、Googleが提供する「Google Colaboratory」(以下、Colab)を利用しました。Colabは、ブラウザ上でPythonプログラミングが行える無料のクラウドサービスです。
主な特徴
基本機能
・Googleアカウントがあれば無料で利用可能
・ブラウザのみで利用でき、環境構築が不要
・データ分析や機械学習に必要なライブラリが事前にインストール済み
技術的メリット
・無料でGPU/TPUが利用可能
・Googleドライブと連携して、簡単にファイル保存・共有が可能
利用制限
・セッションは90分間の無操作で切断
・1回の連続使用は最大12時間まで
このように、Colabは特に初心者にとって、環境構築の手間なくPythonプログラミングを始められる優れたクラウドサービスです。使い方に関しては、ネットにたくさんありますので、ご自身で調べてみてください。
次からは、o3-miniでPythonコードを生成し、Colabで実行した例を5つ紹介します。
モーショングラフィックス
1. ペンデュラムウェーブ
ペンデュラムウェーブとは、糸の長さや振り子の位相を少しずつ変化させることで波のような現象を演出する物理実験です。
最初は横一列に並んでいて、揃ったところが分かりづらかったので、以下のようなことを伝えました。
・横に並ぶのではなく奥行きのある縦に並べたい
・振りが揃った時に1つの丸が見える状態にしたい
こちらのコードをColabに貼り付けて実行してみてください。
コードを実行して始まるまでに1分ほどかかります。
# 必要なライブラリのインポート
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from IPython.display import HTML
# -----------------------------
# パラメータの設定
# -----------------------------
g = 9.81 # 重力加速度 [m/s^2]
T_sync = 60 # 全体の同期時間 [秒]
N = 15 # 振り子の本数
# ※「横に並ぶ」配置から「縦に奥行きのある」配置にするため、
# 各振り子の周期に幅が出るよう、最小振動数を小さめに設定(例:5回〜)
n_min = 5
n_vals = np.arange(n_min, n_min + N) # 例:5,6,...,19 回
# 初期角度(全振り子同じ初期角度でスタート)
theta0_deg = 20 # 初期角度 [度]
theta0 = np.deg2rad(theta0_deg) # [ラジアン]
# 各振り子の周期 T_i(同期時間 T_sync 内に n_vals 回振動する)
T_i = T_sync / n_vals
# 単振り子の周期 T = 2π√(L/g) より L = g*(T/(2π))^2 として各振り子の長さを計算
L = g * (T_i / (2 * np.pi))**2
# -----------------------------
# 同期時の先端(ボール)の共通座標の設定
# -----------------------------
# ここで、振り子が同期したときに全ての先端が (x_common, y_common) に重なるようにする
x_common = 0 # 同期時の x 座標(任意)
y_common = -1 # 同期時の y 座標(任意、ここでは画面下寄りに設定)
# 各振り子の固定点(pivot)の設定:
# 同期時は、先端の座標は
# x_i = pivot_x_i + L_i*sin(theta0) = x_common
# y_i = pivot_y_i - L_i*cos(theta0) = y_common
# となるように、各 pivot を設定します。
pivot_x = x_common - L * np.sin(theta0)
pivot_y = y_common + L * np.cos(theta0)
# -----------------------------
# Figure の設定(16:9 の比率)
# -----------------------------
fig, ax = plt.subplots(figsize=(16, 9))
ax.set_aspect('equal')
# x軸は先端の周りに余裕をもたせる(例)
x_margin = max(L) + 0.5
ax.set_xlim(x_common - x_margin, x_common + x_margin)
# y軸は各振り子の固定点と先端が収まるように設定
y_bottom = min(pivot_y - L) - 0.5
y_top = max(pivot_y) + 0.5
ax.set_ylim(y_bottom, y_top)
ax.axis('off') # 軸は非表示に
# -----------------------------
# 描画オブジェクトの準備
# -----------------------------
lines = [] # 各振り子のロッド(線)
circles = [] # 各振り子の先端(ボール)
for i in range(N):
# ロッドの Line2D オブジェクト
line, = ax.plot([], [], lw=2, color='blue')
lines.append(line)
# 先端のボール('o' マーカー、必ずリストで座標を与える)
circle, = ax.plot([], [], 'o', color='red', markersize=10)
circles.append(circle)
# -----------------------------
# アニメーションの更新関数
# -----------------------------
def update(frame):
# 30fps として、時間 t [秒] を計算
t = frame / 15.0
for i in range(N):
# 小振幅近似:単振り子の角度は θ(t) = θ0 * cos(2π * n_i * t / T_sync)
theta = theta0 * np.cos(2 * np.pi * n_vals[i] * t / T_sync)
# 各振り子の固定点
x0 = pivot_x[i]
y0 = pivot_y[i]
# 先端(ボール)の位置
x1 = x0 + L[i] * np.sin(theta)
y1 = y0 - L[i] * np.cos(theta)
# ロッドの更新(線はリストで渡す)
lines[i].set_data([x0, x1], [y0, y1])
# 先端のボールの更新(必ずシーケンスで渡す)
circles[i].set_data([x1], [y1])
return lines + circles
# -----------------------------
# アニメーションの作成
# -----------------------------
frames = int(T_sync * 30) # 30 fps で T_sync 秒間
anim = FuncAnimation(fig, update, frames=frames, interval=33, blit=True)
# アニメーションを HTML5 動画として表示
HTML(anim.to_html5_video())
YouTubeのように速度を変更することもできます。
2. 矢印の流星群
o3-miniに
「キレイな流星群が左右から流れている。ぶつかったら弾ける。」
などを伝えてコードを生成してもらいました。
最初は白背景で、ただ弾けるエフェクトしかなかったので、以下のようなことを伝えました。
・夜空なので黒背景にしたい
・左右から流れ星が流れてくる
・流れ星の先端は丸ではなく尖って欲しい
こちらのコードをColabに貼り付けて実行してみてください。
始まるまでに多少時間がかかります(私の環境で約3分)。
# 必要なライブラリのインポート
import numpy as np
import matplotlib as mpl
mpl.rcParams['animation.embed_limit'] = 1000
from matplotlib.animation import FuncAnimation
import random
from IPython.display import HTML
# ----- 流星(Meteor)クラス -----
class Meteor:
def __init__(self, pos, vel, radius=0.015, color='white', max_history=15):
"""
pos: [x, y] 初期位置
vel: [vx, vy] 速度(1フレームあたりの移動量)
radius: 流星のサイズ(描画用の尺度)
color: 色(現状は白固定)
max_history: 軌跡として保持する過去の位置数
"""
self.pos = np.array(pos, dtype=float)
self.vel = np.array(vel, dtype=float)
self.radius = radius
self.color = color
self.history = [np.array(pos, dtype=float)]
self.max_history = max_history
def update(self, dt):
self.pos += self.vel * dt
self.history.append(np.array(self.pos))
if len(self.history) > self.max_history:
self.history.pop(0)
# ----- 爆発(Explosion)クラス -----
class Explosion:
def __init__(self, pos, num_particles=50, lifetime=1.2):
"""
pos: 爆発の中心位置
num_particles: 放出するパーティクル数(リッチな爆発用に増加)
lifetime: 各パーティクルの寿命(秒)
"""
self.pos = np.array(pos, dtype=float)
self.lifetime = lifetime
self.particles = [] # 各パーティクルは辞書型で位置・速度・経過時間を保持
for _ in range(num_particles):
angle = random.uniform(0, 2*np.pi)
# パーティクルの初速レンジを広げることで、弾けた印象を強調
speed = random.uniform(0.2, 0.7)
vx = speed * np.cos(angle)
vy = speed * np.sin(angle)
self.particles.append({
'pos': np.array(pos, dtype=float),
'vel': np.array([vx, vy]),
'age': 0
})
def update(self, dt):
new_particles = []
for p in self.particles:
p['pos'] += p['vel'] * dt
p['age'] += dt
if p['age'] < self.lifetime:
new_particles.append(p)
self.particles = new_particles
def is_dead(self):
return len(self.particles) == 0
# ----- 描画設定と初期化 -----
# dpiを指定して高精細に
fig, ax = plt.subplots(figsize=(12, 6), dpi=100)
fig.patch.set_facecolor('black') # 図全体の背景を黒に設定
ax.set_facecolor('black') # Axesの背景も黒に設定
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
ax.axis('off') # 軸は非表示
meteors = [] # 流星リスト
explosions = [] # 爆発リスト
# ----- アニメーション更新関数 -----
def update(frame):
global meteors, explosions
dt = 0.05 # タイムステップ(秒)
# ★ 新たな流星の追加(左右両側から生成)
if random.random() < 0.3:
y = random.uniform(0.2, 0.8)
speed = random.uniform(0.2, 0.4)
vy = random.uniform(-0.05, 0.05)
# 左側から右方向へ
meteors.append(Meteor(pos=[-0.05, y], vel=[speed, vy]))
if random.random() < 0.3:
y = random.uniform(0.2, 0.8)
speed = random.uniform(0.2, 0.4)
vy = random.uniform(-0.05, 0.05)
# 右側から左方向へ
meteors.append(Meteor(pos=[1.05, y], vel=[-speed, vy]))
# ★ 流星の位置・軌跡の更新
for m in meteors:
m.update(dt)
# ★ 流星同士の衝突判定(衝突時は爆発生成)
collided = set()
for i in range(len(meteors)):
for j in range(i+1, len(meteors)):
m1 = meteors[i]
m2 = meteors[j]
# 衝突判定:中心間距離が各流星のサイズの和より小さい場合
if np.linalg.norm(m1.pos - m2.pos) < (m1.radius + m2.radius):
collision_point = (m1.pos + m2.pos) / 2
explosions.append(Explosion(pos=collision_point, num_particles=50, lifetime=1.2))
collided.add(i)
collided.add(j)
# 衝突した流星はリストから除去
meteors = [m for idx, m in enumerate(meteors) if idx not in collided]
# ★ 画面外に出た流星の削除
def in_bounds(m):
x, y = m.pos
return -0.1 < x < 1.1 and -0.1 < y < 1.1
meteors = [m for m in meteors if in_bounds(m)]
# ★ 爆発パーティクルの更新
for e in explosions:
e.update(dt)
explosions[:] = [e for e in explosions if not e.is_dead()]
# ★ 画面の再描画
ax.clear()
ax.set_facecolor('black')
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
ax.axis('off')
# ★ 流星の軌跡と先端(comet-head)の描画
for m in meteors:
# 軌跡(tail)は過去の位置を線分で描画
if len(m.history) > 1:
pts = np.array(m.history)
for k in range(len(pts)-1):
alpha = (k+1) / len(pts) # 古いほど薄く
ax.plot(pts[k:k+2, 0], pts[k:k+2, 1], color=(1, 1, 1, alpha), linewidth=2)
# 流星の先端を燃えているように描画(円ではなく、移動方向に合わせた三角形)
vel_norm = np.linalg.norm(m.vel)
if vel_norm == 0:
# 万が一速度がゼロの場合は円で描画
circle = plt.Circle((m.pos[0], m.pos[1]), m.radius, color='white')
ax.add_artist(circle)
else:
d = m.vel / vel_norm # 移動方向の単位ベクトル
p = np.array([-d[1], d[0]]) # dに直交する単位ベクトル
L = m.radius * 3 # 三角形の先端までの長さ
W = m.radius * 2 # 三角形の幅
# 先にグロー効果用のパッチを描画(薄い白で拡大したもの)
glow_scale = 1.8
tip_glow = m.pos + d * (L * glow_scale)
left_glow = m.pos - d * (L/2 * glow_scale) + p * (W/2 * glow_scale)
right_glow = m.pos - d * (L/2 * glow_scale) - p * (W/2 * glow_scale)
glow_poly = plt.Polygon([tip_glow, left_glow, right_glow], color='white', alpha=0.2)
ax.add_patch(glow_poly)
# コメット(流れ星)の先端部分を描画
tip = m.pos + d * L
left = m.pos - d * (L/2) + p * (W/2)
right = m.pos - d * (L/2) - p * (W/2)
comet_head = plt.Polygon([tip, left, right], color='white')
ax.add_patch(comet_head)
# ★ 爆発パーティクルの描画
for e in explosions:
for p in e.particles:
alpha = max(0, 1 - p['age'] / e.lifetime)
# markersizeを少し大きめに設定してリッチな印象に
ax.plot(p['pos'][0], p['pos'][1],
marker='o', color=(1, 0.8, 0, alpha), markersize=4)
return []
# ----- アニメーションの生成 -----
anim = FuncAnimation(fig, update, frames=300, interval=50)
plt.close() # 自動表示を防ぐ
# Colab上でアニメーションを表示
HTML(anim.to_jshtml())
ちょっと発展的にしたくて、騎馬隊にしようとしたのですが、矢印を馬っぽくすることができませんでした…
簡易ゲーム
モーショングラフィックスだけでなく、動きのある簡易ゲームにも挑戦してみました。ビジネスシーンでは直接役に立たないように見えますが、「ゲームを作る」という工程はデータ処理やロジック設計の基礎を学ぶのに最適です。プログラミングの考え方を身につけるトレーニングとしてもおすすめできます。
1. タイピング花火
表示された文字をタイピングし、正しかった場合に花火が打ち上がるゲームです。
花火の種類をランダムにしたり、残り10秒になったら三文字にするなど、o3-miniに依頼しながらカスタマイズしています。
こちらのコードをColabに貼り付けて実行してみてください。
%%html
<iframe srcdoc="
<!DOCTYPE html>
<html>
<head>
<meta charset='UTF-8'>
<title>Typing Fireworks</title>
<!-- p5.js ライブラリ読み込み -->
<script src='https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.6.0/p5.min.js'></script>
<style>
body {
margin: 0;
padding: 0;
overflow: hidden;
background: black;
}
/* 入力欄やボタンのスタイル */
.myInput {
font-size: 24px;
padding: 8px;
}
</style>
</head>
<body>
<script>
/* ★ グローバル変数 ★ */
let fireworks = [];
let challengeWord = ''; // 表示中のチャレンジ用ひらがな(通常は1文字、残り10秒以内は3文字)
let inputField; // ユーザーがタイピングする入力欄
let retryButton; // 「RETRY」ボタン
let startTime; // ゲーム開始時刻(millis())
let timeLimit = 60; // 制限時間(秒)
// 使用するひらがな一覧(必要に応じて文字を追加してください)
let hiraganaList = 'あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん'.split('');
function setup() {
createCanvas(windowWidth, windowHeight);
background(0);
textFont('sans-serif');
startTime = millis();
newChallenge(); // 最初のチャレンジ文字を生成
// テキスト入力欄を作成
inputField = createInput('');
inputField.class('myInput');
inputField.position(20, height - 50);
inputField.size(200);
// Enter キー押下時に入力チェックを行う
inputField.elt.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
checkInput();
}
});
// 「RETRY」ボタンを作成(初期状態では非表示)
retryButton = createButton('RETRY');
retryButton.class('myInput');
retryButton.position(width/2 - 50, height/2 + 80);
retryButton.size(100, 50);
retryButton.mousePressed(resetGame);
retryButton.hide();
}
function draw() {
background(0, 25);
// 経過時間と残り時間の計算
let elapsed = (millis() - startTime) / 1000;
let remaining = timeLimit - elapsed;
if (remaining < 0) {
remaining = 0;
}
// タイマー表示(左上)
fill(255);
textSize(24);
textAlign(LEFT, TOP);
text('Time: ' + nf(remaining, 0, 1) + ' sec', 10, 10);
// チャレンジ文字の表示
textAlign(CENTER, CENTER);
if (remaining > 10) {
// 通常時は画面下部に1文字表示
textSize(48);
fill(255);
text(challengeWord, width / 2, height - 120);
} else {
// 残り10秒以内は中央に3文字表示
textSize(72);
fill(255);
text(challengeWord, width / 2, height / 2);
}
// 花火の更新・描画
for (let i = fireworks.length - 1; i >= 0; i--) {
fireworks[i].update();
fireworks[i].show();
if (fireworks[i].isFinished()) {
fireworks.splice(i, 1);
}
}
// 制限時間終了時の表示と処理
if (remaining <= 0) {
fill(255, 0, 0);
textSize(64);
text('Time Up!', width / 2, height / 2);
// 入力欄を無効化
inputField.attribute('disabled', '');
// 「RETRY」ボタンを表示
retryButton.show();
} else {
// 時間内ならボタンは非表示
retryButton.hide();
}
}
// ユーザーの入力をチェックする関数
function checkInput() {
let elapsed = (millis() - startTime) / 1000;
if (elapsed >= timeLimit) return; // 時間切れの場合は何もしない
let typed = inputField.value().trim();
// 入力がチャレンジ文字と完全一致した場合
if (typed === challengeWord) {
if (elapsed > timeLimit - 10) {
// 残り10秒以内なら大きな4尺玉(bigタイプ)の花火を中央から打ち上げ
fireworks.push(new Firework(width / 2, height / 2, 'big'));
} else {
// 通常時はランダムな花火を打ち上げる
let randomTypes = ['burst', 'spiral', 'star', 'heart', 'zigzag', 'doubleBurst', 'rainbow', 'glitter'];
let type = random(randomTypes);
let fx = random(width * 0.1, width * 0.9);
let fy = random(height * 0.1, height * 0.5);
fireworks.push(new Firework(fx, fy, type));
}
inputField.value(''); // 入力欄をクリア
newChallenge(); // 次のチャレンジ文字を生成
} else {
// 正解でなければ、入力欄をクリアして再挑戦
inputField.value('');
}
}
// 新しいチャレンジ文字を生成する関数
function newChallenge() {
let elapsed = (millis() - startTime) / 1000;
let remaining = timeLimit - elapsed;
if (remaining <= 10) {
// 残り10秒以内は3文字のチャレンジ
challengeWord = getRandomHiragana(3);
} else {
// 通常は1文字のチャレンジ
challengeWord = getRandomHiragana(1);
}
}
// n文字分のランダムなひらがな文字列を生成
function getRandomHiragana(n) {
let result = '';
for (let i = 0; i < n; i++) {
result += random(hiraganaList);
}
return result;
}
// 「RETRY」ボタンを押したときにゲームをリセットする関数
function resetGame() {
startTime = millis();
fireworks = [];
inputField.value('');
inputField.removeAttribute('disabled');
newChallenge();
retryButton.hide();
}
/* ★ 以下、これまでの花火・パーティクルのクラス ★ */
class Firework {
constructor(x, y, type) {
this.x = x;
this.y = y;
this.type = type;
this.particles = [];
if (type === 'burst') {
let count = 80;
for (let i = 0; i < count; i++) {
let angle = random(TWO_PI);
let speed = random(2, 6);
let vx = cos(angle) * speed;
let vy = sin(angle) * speed;
this.particles.push(new Particle(x, y, vx, vy));
}
} else if (type === 'spiral') {
let count = 100;
let baseAngle = random(TWO_PI);
for (let i = 0; i < count; i++) {
let angle = baseAngle + i * 0.3;
let speed = map(i, 0, count, 2, 5);
let vx = cos(angle) * speed;
let vy = sin(angle) * speed;
this.particles.push(new Particle(x, y, vx, vy));
}
} else if (type === 'star') {
let count = 100;
for (let i = 0; i < count; i++) {
let angle = i * TWO_PI / count;
let speed = (i % 2 === 0 ? random(3, 6) : random(1, 3));
let vx = cos(angle) * speed;
let vy = sin(angle) * speed;
this.particles.push(new Particle(x, y, vx, vy));
}
} else if (type === 'big') {
// 4尺玉風:大きな花火
let count = 200;
for (let i = 0; i < count; i++) {
let angle = random(TWO_PI);
let speed = random(3, 8);
let vx = cos(angle) * speed;
let vy = sin(angle) * speed;
this.particles.push(new Particle(x, y, vx, vy));
}
} else if (type === 'niagara') {
let count = 50;
for (let i = 0; i < count; i++) {
let offsetX = map(i, 0, count - 1, -width * 0.3, width * 0.3);
let vx = random(-1, 1);
let vy = random(3, 6);
this.particles.push(new Particle(x + offsetX, y, vx, vy));
}
} else if (type === 'heart') {
let count = 80;
for (let i = 0; i < count; i++) {
let t = map(i, 0, count, 0, TWO_PI);
let hx = 16 * pow(sin(t), 3);
let hy = 13 * cos(t) - 5 * cos(2 * t) - 2 * cos(3 * t) - cos(4 * t);
let v = createVector(hx, -hy);
v.normalize();
let speed = random(2, 6);
let vx = v.x * speed;
let vy = v.y * speed;
this.particles.push(new Particle(x, y, vx, vy));
}
} else if (type === 'zigzag') {
let count = 80;
for (let i = 0; i < count; i++) {
let angle;
if (i < count / 2) {
angle = random(radians(110), radians(130));
} else {
angle = random(radians(50), radians(70));
}
let speed = random(2, 6);
let vx = cos(angle) * speed;
let vy = sin(angle) * speed;
this.particles.push(new Particle(x, y, vx, vy));
}
} else if (type === 'doubleBurst') {
let count = 60;
for (let i = 0; i < count; i++) {
let angle = random(3 * PI / 2 - 0.4, 3 * PI / 2 + 0.4);
let speed = random(2, 6);
let vx = cos(angle) * speed;
let vy = sin(angle) * speed;
this.particles.push(new Particle(x, y, vx, vy));
}
for (let i = 0; i < count; i++) {
let angle = random(PI / 2 - 0.4, PI / 2 + 0.4);
let speed = random(2, 6);
let vx = cos(angle) * speed;
let vy = sin(angle) * speed;
this.particles.push(new Particle(x, y, vx, vy));
}
} else if (type === 'rainbow') {
let count = 80;
for (let i = 0; i < count; i++) {
let angle = i * TWO_PI / count;
let speed = random(2, 6);
let vx = cos(angle) * speed;
let vy = sin(angle) * speed;
let p = new Particle(x, y, vx, vy);
let hue = map(i, 0, count, 0, 360);
push();
colorMode(HSB);
let c = color(hue, 255, 255);
pop();
p.r = red(c);
p.g = green(c);
p.b = blue(c);
this.particles.push(p);
}
} else if (type === 'glitter') {
let count = 100;
for (let i = 0; i < count; i++) {
let angle = random(TWO_PI);
let speed = random(1, 4);
let p = new Particle(x, y, cos(angle) * speed, sin(angle) * speed);
p.glitter = true;
this.particles.push(p);
}
}
}
update() {
for (let p of this.particles) {
p.update();
}
}
show() {
for (let p of this.particles) {
p.show();
}
}
isFinished() {
return this.particles.every(p => p.alpha <= 0);
}
}
class Particle {
constructor(x, y, vx, vy) {
this.x = x;
this.y = y;
this.vx = vx;
this.vy = vy;
this.alpha = 255;
this.gravity = 0.1;
this.r = random(150, 255);
this.g = random(150, 255);
this.b = random(150, 255);
this.glitter = false;
}
update() {
this.x += this.vx;
this.y += this.vy;
this.vy += this.gravity;
if (this.glitter) {
this.vx += random(-0.2, 0.2);
this.vy += random(-0.2, 0.2);
}
this.alpha -= 3;
}
show() {
noStroke();
fill(this.r, this.g, this.b, this.alpha);
ellipse(this.x, this.y, 4);
}
}
function windowResized() {
resizeCanvas(windowWidth, windowHeight);
// 入力欄とボタンの位置を更新
inputField.position(20, height - 50);
retryButton.position(width/2 - 50, height/2 + 80);
}
</script>
</body>
</html>
" style="width: 100%; height: 600px; border: none;"></iframe>
2. 花火打ち上げゲーム
次は、対戦型にもなる花火打ち上げゲームです。最初は「当たりキーを押したら花火が打ち上がる」というシンプルなものでした。
そこから、o3-miniに依頼しながら
押したボタンを色付けする
当たりキーが押されたら「たまや~」が表れる
などをカスタマイズしていきました。
こちらのコードをColabに貼り付けて実行してみてください。
%%html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>花火筒&た~まや~~(左側のみ)演出付きゲーム</title>
<style>
body { margin: 0; overflow: hidden; background: #111; }
canvas { display: block; }
</style>
</head>
<body>
<canvas id="gameCanvas" width="1280" height="720"></canvas>
<script>
const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");
// -------------------------------
// 基本パラメータ
// -------------------------------
// 使用する9つのキー(小文字)
const allowedKeys = ['a','s','d','f','g','h','j','k','l'];
// 各キーの押下状態(false: 未押下、true: 一度押されたら常に true)
let keyStates = {};
allowedKeys.forEach(key => { keyStates[key] = false; });
// 花火筒(キー)のパラメータ
const tubeBaseWidth = 80; // 筒の底辺の幅
const tubeHeight = 120; // 筒の高さ
const tubeSpacing = 20; // 筒同士の間隔
const marginBottom = 30; // 画面下部の余白
const totalWidth = allowedKeys.length * tubeBaseWidth + (allowedKeys.length - 1) * tubeSpacing;
const tubesStartX = (canvas.width - totalWidth) / 2;
// ゲーム状態管理
let winningKey = allowedKeys[Math.floor(Math.random() * allowedKeys.length)];
let gameActive = true; // 当たりキー待ち状態
let showRestart = false; // ゲーム終了後にリスタートボタン表示フラグ
let particles = []; // 爆発エフェクト用パーティクル群
let rocket = null; // 打ち上げ中のロケットオブジェクト
const rocketDuration = 60; // ロケットが打ち上がる時間(フレーム数目安)
// -------------------------------
// 「た~まや~~」演出用(左側のみ)
// -------------------------------
const tamayaArtLeft = [
" /",
" /",
" /",
"/",
"た~まや~~",
"\",
" \",
" \",
" \"
];
let tamayaActive = false; // 演出中か否か
let tamayaLeft = null; // 左側のテキスト用オブジェクト
console.log("Winning key:", winningKey);
// 初期背景描画
ctx.fillStyle = "#111";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// -------------------------------
// 爆発エフェクト用パーティクル群
// -------------------------------
function spawnParticles(x, y) {
particles = [];
const count = 150;
for (let i = 0; i < count; i++) {
const angle = Math.random() * 2 * Math.PI;
const speed = Math.random() * 4 + 2;
particles.push({
x: x,
y: y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
radius: Math.random() * 3 + 2,
alpha: 1,
decay: Math.random() * 0.02 + 0.01,
color: 'hsl(' + Math.floor(Math.random() * 360) + ', 100%, 50%)'
});
}
}
function updateParticles() {
for (let p of particles) {
p.x += p.vx;
p.y += p.vy;
p.alpha -= p.decay;
}
particles = particles.filter(p => p.alpha > 0);
}
function drawParticles() {
for (let p of particles) {
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.globalAlpha = p.alpha;
ctx.fillStyle = p.color;
ctx.fill();
}
ctx.globalAlpha = 1;
}
// -------------------------------
// リスタートボタン描画(ゲーム終了後、中央に表示)
// -------------------------------
let restartButtonRect = null;
function drawRestartButton() {
const buttonWidth = 220;
const buttonHeight = 70;
const x = canvas.width / 2 - buttonWidth / 2;
const y = canvas.height / 2 - buttonHeight / 2;
restartButtonRect = { x: x, y: y, width: buttonWidth, height: buttonHeight };
// グラデーション背景
let grad = ctx.createLinearGradient(x, y, x, y + buttonHeight);
grad.addColorStop(0, "#28a745");
grad.addColorStop(1, "#218838");
ctx.fillStyle = grad;
// 丸みのある長方形
ctx.beginPath();
const r = 15;
ctx.moveTo(x + r, y);
ctx.lineTo(x + buttonWidth - r, y);
ctx.quadraticCurveTo(x + buttonWidth, y, x + buttonWidth, y + r);
ctx.lineTo(x + buttonWidth, y + buttonHeight - r);
ctx.quadraticCurveTo(x + buttonWidth, y + buttonHeight, x + buttonWidth - r, y + buttonHeight);
ctx.lineTo(x + r, y + buttonHeight);
ctx.quadraticCurveTo(x, y + buttonHeight, x, y + buttonHeight - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
ctx.fill();
// ボタンテキスト
ctx.font = "bold 32px sans-serif";
ctx.fillStyle = "#fff";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("Restart", x + buttonWidth / 2, y + buttonHeight / 2);
}
// -------------------------------
// 花火筒(キー)の描画(円錐型)
// -------------------------------
function drawKeyTubes() {
allowedKeys.forEach((key, i) => {
const x = tubesStartX + i * (tubeBaseWidth + tubeSpacing);
const baseY = canvas.height - marginBottom;
const apexX = x + tubeBaseWidth / 2;
const apexY = baseY - tubeHeight;
ctx.beginPath();
// 三角形:底辺が筒の口、先端が打ち上げ口
ctx.moveTo(x, baseY);
ctx.lineTo(x + tubeBaseWidth, baseY);
ctx.lineTo(apexX, apexY);
ctx.closePath();
// すでに押されたキーは明るいグラデーション、それ以外は落ち着いた色味
const grad = ctx.createLinearGradient(x, baseY, x, apexY);
if (keyStates[key]) {
grad.addColorStop(0, "#ffef82");
grad.addColorStop(1, "#ffc107");
} else {
grad.addColorStop(0, "#555");
grad.addColorStop(1, "#333");
}
ctx.fillStyle = grad;
ctx.fill();
// 枠線
ctx.strokeStyle = "#999";
ctx.lineWidth = 3;
ctx.stroke();
// キーのラベル(大文字)を筒の下部付近に表示
ctx.font = "32px sans-serif";
ctx.fillStyle = "#fff";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(key.toUpperCase(), x + tubeBaseWidth / 2, baseY - 20);
});
}
// -------------------------------
// ロケット(打ち上げ中)の更新と描画
// -------------------------------
function updateAndDrawRocket() {
if (rocket) {
rocket.t++;
rocket.y += rocket.vy;
// ロケットは小さな球体で表現(グロー効果付き)
ctx.beginPath();
ctx.arc(rocket.x, rocket.y, 8, 0, Math.PI * 2);
ctx.fillStyle = "#ff0000";
ctx.shadowColor = "#ff0000";
ctx.shadowBlur = 20;
ctx.fill();
ctx.shadowBlur = 0; // リセット
// 一定フレーム経過後に爆発
if (rocket.t >= rocketDuration) {
spawnParticles(rocket.x, rocket.y);
rocket = null;
// 爆発後、1.5秒後にリスタートボタン表示
setTimeout(() => { showRestart = true; }, 1500);
}
}
}
// -------------------------------
// 「た~まや~~」演出(左側のみのテキストアニメーション)
// -------------------------------
function updateTamaya() {
if (tamayaLeft) {
tamayaLeft.t++;
if (tamayaLeft.t < tamayaLeft.duration) {
let progress = tamayaLeft.t / tamayaLeft.duration;
tamayaLeft.currentX = tamayaLeft.startX + (tamayaLeft.targetX - tamayaLeft.startX) * progress;
} else {
tamayaLeft.currentX = tamayaLeft.targetX;
}
// 演出開始から (duration + 60) フレーム経過したら終了
if(tamayaLeft.t > tamayaLeft.duration + 60) {
tamayaActive = false;
}
}
}
function drawTamaya() {
const lineHeight = 28;
if (tamayaLeft) {
ctx.font = "24px monospace";
ctx.textAlign = "left";
ctx.textBaseline = "top";
ctx.fillStyle = "rgba(255,255,255,1)";
for (let i = 0; i < tamayaArtLeft.length; i++) {
ctx.fillText(tamayaArtLeft[i], tamayaLeft.currentX, tamayaLeft.y + i * lineHeight);
}
}
}
// -------------------------------
// 毎フレームの更新処理
// -------------------------------
function gameLoop() {
// 背景クリア
ctx.fillStyle = "#111";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 爆発エフェクトの更新&描画
if (particles.length > 0) {
updateParticles();
drawParticles();
}
// ロケットの更新&描画
updateAndDrawRocket();
// 「た~まや~~」演出が有効なら更新&描画
if (tamayaActive) {
updateTamaya();
drawTamaya();
}
// 画面下部に花火筒(キー)を描画
drawKeyTubes();
// ゲーム終了時、中央にリスタートボタンを描画
if (showRestart) {
drawRestartButton();
}
requestAnimationFrame(gameLoop);
}
gameLoop();
// -------------------------------
// キーボード入力処理
// -------------------------------
document.addEventListener("keydown", function(e) {
const key = e.key.toLowerCase();
if (allowedKeys.includes(key)) {
// 一度押されたキーはその後も色付け状態にする
if (!keyStates[key]) {
keyStates[key] = true;
}
// ゲーム進行中かつ、当たりキーが押されたら…
if (gameActive && key === winningKey && !rocket) {
// 対応する花火筒の先端座標を計算
const index = allowedKeys.indexOf(key);
const x = tubesStartX + index * (tubeBaseWidth + tubeSpacing);
const baseY = canvas.height - marginBottom;
const apexX = x + tubeBaseWidth / 2;
const apexY = baseY - tubeHeight;
// ロケット発射(筒の先端から上向きに打ち上げ)
rocket = { x: apexX, y: apexY, vy: -6, t: 0 };
gameActive = false;
// ★ 「た~まや~~」演出開始(左側のみ)
tamayaActive = true;
tamayaLeft = {
t: 0,
duration: 60,
startX: -50,
targetX: 50,
y: canvas.height / 2 - 150
};
}
}
});
// キーアップイベントは状態リセットを行わない(色付け維持のため)
// -------------------------------
// マウスクリック:リスタートボタン判定
// -------------------------------
canvas.addEventListener("click", function(e) {
if (showRestart && restartButtonRect) {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (x >= restartButtonRect.x && x <= restartButtonRect.x + restartButtonRect.width &&
y >= restartButtonRect.y && y <= restartButtonRect.y + restartButtonRect.height) {
restartGame();
}
}
});
// -------------------------------
// リスタート処理
// -------------------------------
function restartGame() {
gameActive = true;
showRestart = false;
particles = [];
rocket = null;
tamayaActive = false;
winningKey = allowedKeys[Math.floor(Math.random() * allowedKeys.length)];
console.log("New winning key:", winningKey);
// 各キーの状態をリセット(再び未押下状態に)
allowedKeys.forEach(key => { keyStates[key] = false; });
ctx.fillStyle = "#111";
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
</script>
</body>
</html>
3. 剣を刺していくゲーム
次は、クリックして遊べるゲームを作りました。イメージとしては、黒ひげさんが飛ぶあのゲームです。
このゲームは、先ほどの花火打ち上げの続きで
「クリックして剣を刺す箇所を選ぶ」
などを伝えて作りました。ベースができていたため、一度の指示で完成しています。
こちらのコードをColabに貼り付けて実行してみてください。
%%html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>黒ひげ危機一発風 ゲーム</title>
<style>
body { margin: 0; overflow: hidden; background: #222; }
canvas { display: block; }
</style>
</head>
<body>
<canvas id="gameCanvas" width="1280" height="720"></canvas>
<script>
const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");
// ゲーム管理用グローバル変数
let gameActive = true; // まだ正解穴(危険穴)が見つかっていない状態
let showRestart = false; // 海賊が出た後にリスタートボタン表示
let pirate = null; // 海賊(黒ひげ)の出現アニメーション用オブジェクト
// ★【剣を刺す穴】として利用する9箇所を定義(中心座標・半径・未刺状態)
// ※ここでは画面中央付近の樽の中に配置するイメージです
const regions = [
{ x: 540, y: 300, r: 30, stabbed: false },
{ x: 640, y: 300, r: 30, stabbed: false },
{ x: 740, y: 300, r: 30, stabbed: false },
{ x: 540, y: 400, r: 30, stabbed: false },
{ x: 640, y: 400, r: 30, stabbed: false },
{ x: 740, y: 400, r: 30, stabbed: false },
{ x: 540, y: 500, r: 30, stabbed: false },
{ x: 640, y: 500, r: 30, stabbed: false },
{ x: 740, y: 500, r: 30, stabbed: false }
];
// ゲーム開始時に、上記9箇所の中からランダムに1箇所を「危険穴」として選択
let winningIndex = Math.floor(Math.random() * regions.length);
console.log("Winning region index:", winningIndex);
// リスタートボタン描画用の矩形情報
let restartButtonRect = null;
// ★ 背景:樽の描画 ★
function drawBarrel() {
ctx.save();
ctx.beginPath();
// 中心 (640,400) を中心に、横・縦220pxの楕円(樽風)を描く
ctx.ellipse(640, 400, 220, 220, 0, 0, Math.PI * 2);
// 内側はブラウン系のラジアルグラデーション
let grad = ctx.createRadialGradient(640, 400, 50, 640, 400, 220);
grad.addColorStop(0, "#8B4513");
grad.addColorStop(1, "#5D2E0D");
ctx.fillStyle = grad;
ctx.fill();
// 樽の外枠
ctx.lineWidth = 8;
ctx.strokeStyle = "#4B2505";
ctx.stroke();
ctx.restore();
}
// ★ 穴(剣を刺す場所)の描画 ★
function drawRegions() {
regions.forEach(region => {
ctx.save();
ctx.beginPath();
ctx.arc(region.x, region.y, region.r, 0, Math.PI * 2);
// 穴は黒く塗りつぶす
ctx.fillStyle = "#000";
ctx.fill();
// 輪郭
ctx.lineWidth = 3;
ctx.strokeStyle = "#555";
ctx.stroke();
ctx.restore();
// すでに剣が刺されたなら、剣を描画する
if (region.stabbed) {
drawSword(region.x, region.y);
}
});
}
// ★ 剣の描画 ★
// ここでは、穴の中心から上方向に一定長(50px)の剣を描く(シンプルな直線+ハンドル)
function drawSword(x, y) {
ctx.save();
// 剣の刃部分
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x, y - 50);
ctx.lineWidth = 4;
ctx.strokeStyle = "#C0C0C0"; // 銀色
ctx.stroke();
// ハンドル部分(小さな矩形)
ctx.fillStyle = "#8B4513"; // 茶色
ctx.fillRect(x - 8, y - 50 - 10, 16, 10);
ctx.restore();
}
// ★ 海賊(黒ひげ)の出現アニメーション ★
// 正解の穴を刺したとき、該当の穴の中心から海賊の顔が上に向かってポンと出る
function updateAndDrawPirate() {
if (pirate) {
pirate.t++;
pirate.y += pirate.vy; // 上方向へ移動(vyは負の値)
// 海賊の顔をシンプルな円とひげ・アイパッチで表現
ctx.save();
ctx.beginPath();
ctx.arc(pirate.x, pirate.y, 40, 0, Math.PI * 2);
ctx.fillStyle = "#ffe0bd"; // 肌色
ctx.fill();
ctx.lineWidth = 3;
ctx.strokeStyle = "#000";
ctx.stroke();
// ひげ(下側半円)
ctx.beginPath();
ctx.arc(pirate.x, pirate.y + 10, 25, 0, Math.PI, false);
ctx.strokeStyle = "#000";
ctx.lineWidth = 4;
ctx.stroke();
// アイパッチ(片目を隠す)
ctx.beginPath();
ctx.arc(pirate.x - 15, pirate.y - 10, 6, 0, Math.PI * 2);
ctx.fillStyle = "#000";
ctx.fill();
ctx.restore();
// ある程度上まで出たらリスタートボタンを表示
if (pirate.t > 50 || pirate.y < 150) {
showRestart = true;
}
}
}
// ★ リスタートボタンの描画 ★
function drawRestartButton() {
const buttonWidth = 220;
const buttonHeight = 70;
const x = canvas.width / 2 - buttonWidth / 2;
const y = canvas.height / 2 - buttonHeight / 2;
restartButtonRect = { x: x, y: y, width: buttonWidth, height: buttonHeight };
ctx.save();
let grad = ctx.createLinearGradient(x, y, x, y + buttonHeight);
grad.addColorStop(0, "#28a745");
grad.addColorStop(1, "#218838");
ctx.fillStyle = grad;
// 丸みのある四角形
const r = 15;
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + buttonWidth - r, y);
ctx.quadraticCurveTo(x + buttonWidth, y, x + buttonWidth, y + r);
ctx.lineTo(x + buttonWidth, y + buttonHeight - r);
ctx.quadraticCurveTo(x + buttonWidth, y + buttonHeight, x + buttonWidth - r, y + buttonHeight);
ctx.lineTo(x + r, y + buttonHeight);
ctx.quadraticCurveTo(x, y + buttonHeight, x, y + buttonHeight - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
ctx.fill();
// ボタンのテキスト
ctx.font = "bold 32px sans-serif";
ctx.fillStyle = "#fff";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("Restart", x + buttonWidth / 2, y + buttonHeight / 2);
ctx.restore();
}
// ★ 毎フレームのメインループ ★
function gameLoop() {
// 背景クリア
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#222";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 樽(背景)の描画
drawBarrel();
// 穴(剣を刺す場所)の描画
drawRegions();
// 海賊が出始めている場合はアニメーション更新&描画
updateAndDrawPirate();
// ゲームオーバー後はリスタートボタンを表示
if (showRestart) {
drawRestartButton();
}
requestAnimationFrame(gameLoop);
}
gameLoop();
// ★ クリックイベント処理 ★
// ・リスタートボタンが表示中なら、その判定を行いリセット
// ・ゲーム進行中の場合は、クリック位置が各穴内かチェックし、未刺の穴なら「刺す」
// ※もしクリックした穴が winningIndex(正解穴)なら海賊出現アニメーション開始
canvas.addEventListener("click", function(e) {
const rect = canvas.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top;
// もしリスタートボタンが表示されているなら、そちらのクリック判定を行う
if (showRestart && restartButtonRect) {
if (clickX >= restartButtonRect.x && clickX <= restartButtonRect.x + restartButtonRect.width &&
clickY >= restartButtonRect.y && clickY <= restartButtonRect.y + restartButtonRect.height) {
restartGame();
return;
}
}
// ゲーム進行中なら、各穴の中にクリックが入っているかチェック
if (gameActive) {
for (let i = 0; i < regions.length; i++) {
let region = regions[i];
if (!region.stabbed) {
let dx = clickX - region.x;
let dy = clickY - region.y;
if (Math.sqrt(dx * dx + dy * dy) <= region.r) {
region.stabbed = true; // その穴に剣が刺さった状態にする
// もし正解(危険)穴なら、ゲーム終了とともに海賊を出現させる
if (i === winningIndex) {
gameActive = false;
pirate = { x: region.x, y: region.y, vy: -5, t: 0 };
}
break; // 1クリックにつき1箇所だけ処理
}
}
}
}
});
// ★ ゲームリスタート処理 ★
function restartGame() {
gameActive = true;
showRestart = false;
pirate = null;
regions.forEach(region => { region.stabbed = false; });
winningIndex = Math.floor(Math.random() * regions.length);
console.log("New winning region index:", winningIndex);
}
</script>
</body>
</html>
まとめ
非エンジニアであっても、OpenAIのo3-miniを活用し、少しの調整を繰り返すだけで、ある程度形にすることができました。大人だけでなく、子どもたちがプログラミングに触れる入口としても良いのではないのでしょうか?
もし本格的に学んでみたい、もっと高度なプロジェクトに挑戦したいと感じた場合は、同じ目標を持つ仲間と情報交換ができる学習コミュニティやスクールに参加してみるのも良いでしょう。
やはり、実際に手を動かしてみてこそ身につく知識がありますし、経験者からのフィードバックによってスキルは一段と伸びます。
我々もAIに関する有料Discordコミュニティ
『AI BOOTCAMP BUSINESS部』
を運営しています。
画像生成や動画生成やAI Tuberまで、幅広いジャンルの話題が飛び交っているだけでなく、あまりSNSでは話題になっていない海外の最新情報も発信されています。
興味がある方は、下記公式LINEのリッチメニューより申請してください!

他にも
次世代のイノベーターを育成する実践型スクール『AI BOOTCAMP』
オンラインプログラミング学習プラットフォーム『Route55』
も運営しています。
AI駆動開発やAIエージェント開発に興味がある方は、下記のサイトをぜひご覧ください。