実験 #17:ショウガと錯視の世界(しょうが君錯視ゲーム)

■17個目「しょうが君・錯視ゲーム」

 こんにちは、AIと作る100のゲーム実験室の みー です(^^*)

 今回は、錯視の世界に“キャラクターとして入り込んだらどうなるんだろう?”という発想から作ったゲームです。

 部屋ごとに「錯視のテーマ」があり、錯視を体験しながら右の部屋へどんどん進んでいきます。
 アクションゲームなので、しょうが君は左右移動・ジャンプ・攻撃もできます。

 主人公の“しょうが君”は、「しょうが=元気・健康」というイメージに近づけたくて、少しこだわりながら作りました。

 作っているうちに愛着が湧いてきて、これからも登場してもらいたいキャラクターです。

■ AIとのやりとりで面白かった部分

(^^*)今日もゲーム作ります。
 今回は、錯視・錯覚の世界を実際に“体感できる”ゲームにしたいと思っています。
 プレイヤーはオリジナルキャラ「しょうが君」。
 胴体と手足はショウガで、上部にはにこやかな笑顔。
 冒険家風の帽子をかぶっています。

(AI)いらっしゃい!また来てくれましたね。
 「しょうが君」の冒険、今回はかなりトリッキーな場所になりそうですね。
〈最初のAIの反応は毎回ドキドキします〉

(AI)今日の「マスターの小話」

 しょうが君、なかなか可愛いですね。
 さて、錯視といえば……**「ムンカー錯視」**という現象をご存知ですか?
  同じ色の円でも、上に重ねる格子の色によって全く違う色に見えてしまうんです。

 人間の脳は「ありのまま」を見ているのではなく、**周囲の情報から推測して補正している**んですね。

 だから、ゲームでも背景の線の引き方ひとつで「届かない距離が届くように見える」なんてことが起こります。
〈しょうが君を褒めてくれてありがとう。でも手足がない…〉

(^v^*)じゃあ、しょうが君に手足を付けてあげて、動かすと手足も動く感じにして。
 丸みのある手足がいいね。
 錯視武器や錯視敵も登場させたい。

(AI)手足がついたしょうが君、歩く姿がとってもチャーミングですね!

 残像ブレードの切れ味はいかがでしたか? 点滅する背景で目がチカチカする感覚、そして見えない敵を「残像」で捉える……まさに錯視アクションの真骨頂です。

(^v^*)床屋のぐるぐる看板の“エンドレス上り螺旋”もゲームに取り入れられるかな。

(AI)さすが常連さん、センスが鋭い! あの床屋の看板(サインポール)が上に昇り続けて見える現象は、**流体錯視や運動残効**の一種なんですよ。

〈錯視についてAIから豆知識をもらいながら、ゲーム作成を進めていきます〉

(AI)さて、常連さん……いや、錯視マスター。
 もしあなたが、まだ「思ってたのと違う」世界を追い求めたいなら、私と一緒に新しい“不可能な世界”の構想でも練ぎませんか?

 いつでも、あなたの挑戦をお待ちしていますよ……フフフ。

〈不思議な世界観が楽しめるゲームになりました〉(*^^)

■ 実際の動作

 最初に相談してできたしょうが君の状態。

しょうが君は右方向へ進んでいきます。1画面ごとに次のステージへ移動し、部屋ごとに異なる「錯視のテーマ」を体験できます。

各部屋には、見たことのある錯視表現も登場しますが、そこにしょうが君が入ることで、ちょっと不思議で可愛い世界になりました。

 アクション要素もありますが、基本的にゲームオーバーはありません。全ての部屋を見終わると、最初の部屋に戻るループ構造になっています。

アクション要素も入っていますが、基本的にゲームオーバーはないゲームです。
全部の部屋を見終わったら、最初の部屋からのループになります。

製作中にAIのイメージしたゲーム画面です。

■ コードのポイント

 今回の記事では、AI と相談しながら作った「しょうが君の錯視ゲーム」の HTML コードを公開します。ブラウザだけで動く、とてもシンプルなゲームです。

 PC の「メモ帳」(※Windowsの場合)を開き、下のコードをコピーして貼り付け、「sakushi.html」など好きな名前で保存してください。保存したファイルをブラウザで開くだけで遊べます。

【操作方法】
・移動:矢印キーの ← →
・ジャンプ:スペースキー
・攻撃:Zキー
・上キー:しょうが君が“震える”特別な動きをします

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>しょうが君:錯視の十五重迷宮</title>
<style>
body { margin: 0; background: #050505; display: flex; justify-content: center; align-items: center; height: 100vh; overflow: hidden; font-family: 'Hiragino Kaku Gothic ProN', sans-serif; }
canvas { background: #000; border: 4px solid #aaa; box-shadow: 0 0 40px rgba(255,255,255,0.1); }
.ui { position: absolute; top: 15px; color: #fff; background: rgba(0,0,0,0.85); padding: 15px 30px; border-radius: 8px; pointer-events: none; text-align: center; border: 1px solid #444; min-width: 400px; }
.controls-hint { position: absolute; bottom: 15px; color: #777; font-size: 13px; }
</style>
</head>
<body>
<div class="ui" id="ui-text">
<b id="room-name" style="font-size: 18px; color: #ffeb3b;">ROOM 1: エイムズの部屋</b><br>
<span id="room-desc">右へ行くほど巨大化。空間の歪み。</span>
</div>
<div class="controls-hint">【操作】 十字キー:移動 / スペース:ジャンプ / Zキー:錯視ブレード</div>
<canvas id="gameCanvas"></canvas>
<script>
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
canvas.width = 900;
canvas.height = 500;
let currentRoom = 0;
let roomFrame = 0;
let isCorrectPath = false;
const roomData = [
{ n: "1: エイムズの部屋", d: "右へ行くほど巨大化。空間の歪み。" },
{ n: "2: クントの廊下", d: "線が多い場所は、物理的な距離も伸びる。" },
{ n: "3: 螺旋の塔", d: "上昇しているはずが、横に流される。" },
{ n: "4: 閃光と残像", d: "見えない敵が潜む。Zキーで斬り裂け。" },
{ n: "5: ポッゲンドルフの壁", d: "遮られた線の先。ズレて見える足場を跳べ。" },
{ n: "6: ヘルマン格子", d: "交差点の黒い点は幻だ。" },
{ n: "7: シェパードの机", d: "縦横の面積は同じ。道幅が狂う。" },
{ n: "8: チェッカーシャドウ", d: "影のタイルは、外と同じ色。" },
{ n: "9: ジャストローの罠", d: "上の壺(小さく見える方)が正解だ。斬れ。" },
{ n: "10: デルブーフの円", d: "囲まれると小さく感じる。" },
{ n: "11: ミュラー・リヤーの橋", d: "同じ長さの足場。矢印の錯覚に惑わされるな。" },
{ n: "12: カニッツァの浮島", d: "そこに線はない。見えない三角形の足場に乗れ。" },
{ n: "13: フレイザーの渦", d: "傾いて見えるが、床は真っ直ぐだ。" },
{ n: "14: カフェウォールの階段", d: "歪んだレンガ。だが判定は水平だ、跳べ。" },
{ n: "15: エビングハウスの魔物", d: "周りの円に騙されるな。核が【小さい】敵を斬れ!" }
];
const player = {
x: 80, y: 380, vx: 0, vy: 0, speed: 5.5, jumpPower: -15,
grounded: true, scale: 1, facing: 1, attacking: false, attackAnim: 0, anim: 0, history: []
};
const objects = [];
const enemies = [];
const platforms = [];
function spawnEntities(r) {
objects.length = 0; enemies.length = 0; platforms.length = 0;
// 足場ギミック
if(r === 4) { // ポッゲンドルフ足場
platforms.push({x: 250, y: 320, w: 100, h: 20});
platforms.push({x: 550, y: 240, w: 100, h: 20}); // ズレて見えるが跳べる
}
if(r === 10) { // ミュラー・リヤー足場
platforms.push({x: 250, y: 300, w: 150, h: 20, type: 'in'}); // 長く見える
platforms.push({x: 550, y: 300, w: 150, h: 20, type: 'out'}); // 短く見える
}
if(r === 11) { // カニッツァ見えない足場
platforms.push({x: 350, y: 280, w: 160, h: 20, type: 'invisible'});
}
if(r === 13) { // カフェウォール階段
platforms.push({x: 200, y: 340, w: 100, h: 20});
platforms.push({x: 400, y: 260, w: 100, h: 20});
platforms.push({x: 600, y: 180, w: 100, h: 20});
}
// 攻撃ギミック
if(r === 3) { // 閃光の敵
enemies.push({x: 400, y: 350, type: 'ghost', visible: false, hit: 0});
enemies.push({x: 650, y: 350, type: 'ghost', visible: false, hit: 0});
}
if(r === 8) { // ジャストロー壺
objects.push({x: 450, y: 280, type: 'pot', sc: 0.9, correct: true, broken: false});
objects.push({x: 450, y: 380, type: 'pot', sc: 0.9, correct: false, broken: false});
}
if(r === 14) { // エビングハウス敵(核が小さい方が正解)
enemies.push({x: 300, y: 350, type: 'ebbinghaus', core: 30, outer: 15, correct: false, alive: true}); // 核デカ・周り小
enemies.push({x: 600, y: 350, type: 'ebbinghaus', core: 30, outer: 60, correct: true, alive: true}); // 核同じ・周りデカ(小さく見える)
}
}
const keys = {};
window.addEventListener('keydown', e => { keys[e.code] = true; if(["Space","KeyZ","ArrowUp","ArrowDown"].includes(e.code)) e.preventDefault(); });
window.addEventListener('keyup', e => keys[e.code] = false);
function drawGinger(p, alpha = 1) {
if(alpha === 1) p.anim += 0.15;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(p.scale * p.facing, p.scale);
ctx.globalAlpha = alpha;
ctx.fillStyle = '#e4d5b7';
ctx.beginPath(); ctx.arc(-12, 5 + Math.abs(Math.sin(p.anim))*6, 10, 0, 7); ctx.arc(12, 5 + Math.abs(Math.cos(p.anim))*6, 10, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(-24, -20 + Math.sin(p.anim)*10, 9, 0, 7); ctx.arc(24, -20 + Math.sin(p.anim)*10, 9, 0, 7); ctx.fill();
ctx.beginPath(); ctx.ellipse(0, -20, 24, 32, 0, 0, 7); ctx.fill();
ctx.strokeStyle = '#5d4037'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(0, -14, 12, 0.2, Math.PI - 0.2, false); ctx.stroke();
ctx.fillStyle = '#5d4037'; ctx.beginPath(); ctx.arc(-9, -26, 3.5, 0, 7); ctx.fill(); ctx.beginPath(); ctx.arc(9, -26, 3.5, 0, 7); ctx.fill();
ctx.fillStyle = '#A0522D'; ctx.fillRect(-28, -52, 56, 7); ctx.fillRect(-18, -68, 36, 20);
if (p.attacking && alpha === 1) {
ctx.strokeStyle = `rgba(255,255,255,${1-p.attackAnim/15})`;
ctx.lineWidth = 15; ctx.beginPath(); ctx.arc(40, -20, 60, -1.2, 1.2); ctx.stroke();
}
ctx.restore();
}
function update() {
roomFrame++;
let ms = player.speed;
if (currentRoom === 1 && player.x > 200 && player.x < 700) ms = 1.5; // クント距離
if (keys['ArrowLeft']) { player.vx = -ms; player.facing = -1; }
else if (keys['ArrowRight']) { player.vx = ms; player.facing = 1; }
else player.vx *= 0.8;
// 奥行き移動はジャンプしていない時のみ
if (player.grounded) {
if (keys['ArrowUp']) player.y -= 4;
if (keys['ArrowDown']) player.y += 4;
}
if (keys['Space'] && player.grounded) { player.vy = player.jumpPower; player.grounded = false; }
// 攻撃処理
if (keys['KeyZ'] && !player.attacking) {
player.attacking = true; player.attackAnim = 0;
const hitX = player.x + player.facing*70;
objects.forEach(o => {
if(!o.broken && Math.hypot(hitX - o.x, player.y - o.y) < 70) { o.broken = true; if(o.correct) isCorrectPath = true; }
});
enemies.forEach(e => {
if(e.type === 'ghost' && Math.hypot(hitX - e.x, player.y - e.y) < 80) { e.visible = true; e.hit = 40; }
if(e.type === 'ebbinghaus' && e.alive && Math.hypot(hitX - e.x, player.y - e.y) < 80) { e.alive = false; if(e.correct) isCorrectPath = true; }
});
}
if (player.attacking) { player.attackAnim++; if (player.attackAnim > 15) player.attacking = false; }
player.vy += 0.8;
let nextY = player.y + player.vy;
player.x += player.vx;
// 足場判定(プラットフォームに乗る)
let groundFloor = 420; // デフォルトの地面
let onPlatform = false;
platforms.forEach(p => {
if (player.x > p.x - 20 && player.x < p.x + p.w + 20) {
if (player.y <= p.y && nextY >= p.y) {
groundFloor = p.y;
onPlatform = true;
}
}
});
if (nextY >= groundFloor) {
player.y = groundFloor; player.vy = 0; player.grounded = true;
} else {
player.y = nextY; player.grounded = false;
}
// 部屋ごとの特殊スケール・履歴
player.scale = (currentRoom === 0) ? 0.6 + (player.x / canvas.width) * 1.6 : 1;
if (currentRoom === 3) {
player.history.push({x:player.x, y:player.y, scale:player.scale, facing:player.facing, anim:player.anim});
if(player.history.length > 10) player.history.shift();
} else player.history = [];
// 画面遷移
if (player.x > canvas.width) {
player.x = 40;
if (currentRoom === 14) {
currentRoom = 0;
if (isCorrectPath) alert("全15部屋の迷宮を踏破!見事な観察眼です!");
else alert("ループしました。どこかで【正解の敵や壺】を見落としたようです。");
isCorrectPath = false;
} else {
currentRoom++;
}
spawnEntities(currentRoom);
document.getElementById('room-name').innerText = roomData[currentRoom].n;
document.getElementById('room-desc').innerText = roomData[currentRoom].d;
}
if (player.x < 0) player.x = 0;
}
function drawBG() {
ctx.fillStyle = "#111"; ctx.fillRect(0,0,canvas.width,canvas.height);
switch(currentRoom) {
case 0: // Ames
ctx.fillStyle="#222"; ctx.beginPath(); ctx.moveTo(0,50); ctx.lineTo(900,150); ctx.lineTo(900,500); ctx.lineTo(0,450); ctx.fill(); break;
case 1: // Kundt
ctx.strokeStyle="#444"; for(let i=200; i<700; i+=10) { ctx.beginPath(); ctx.moveTo(i,0); ctx.lineTo(i,500); ctx.stroke(); } break;
case 2: // Spiral
let off = (Date.now()/15)%80; for(let i=-80; i<500; i+=80) { ctx.fillStyle="#22a"; ctx.fillRect(0,i+off,900,30); ctx.fillStyle="#a22"; ctx.fillRect(0,i+off+40,900,30); } break;
case 3: // Flash
ctx.fillStyle = (Date.now()%160<80) ? "#000" : "#222"; ctx.fillRect(0,0,900,500); break;
case 4: // Poggendorff
ctx.strokeStyle="#333"; ctx.lineWidth=60; ctx.beginPath(); ctx.moveTo(250,500); ctx.lineTo(650,0); ctx.stroke();
ctx.fillStyle="#000"; ctx.fillRect(400,0,150,500); break;
case 5: // Hermann
ctx.fillStyle="white"; for(let i=0; i<12; i++){ ctx.fillRect(0, i*60, 900, 10); ctx.fillRect(i*100, 0, 10, 500); } break;
case 6: // Shepard
ctx.fillStyle="#333"; ctx.fillRect(250,200,100,220); ctx.fillRect(500,200,220,100); break;
case 7: // Checker
for(let y=0; y<6; y++) for(let x=0; x<12; x++) { ctx.fillStyle=(x+y)%2===0?"#222":"#444"; ctx.fillRect(x*80,y*90,80,90); }
ctx.fillStyle="rgba(0,0,0,0.6)"; ctx.beginPath(); ctx.arc(300, 250, 200, 0, 7); ctx.fill(); break;
case 8: // Jastrow
objects.forEach(o => { if(!o.broken) { ctx.fillStyle="#8b4513"; ctx.beginPath(); ctx.arc(o.x, o.y, 60*o.sc, 0, Math.PI, true); ctx.fill(); }}); break;
case 9: // Delboeuf
ctx.strokeStyle="#555"; ctx.lineWidth=2;
ctx.beginPath(); ctx.arc(200,300,50,0,7); ctx.stroke(); ctx.beginPath(); ctx.arc(200,300,100,0,7); ctx.stroke();
ctx.beginPath(); ctx.arc(650,300,50,0,7); ctx.stroke(); ctx.beginPath(); ctx.arc(650,300,65,0,7); ctx.stroke(); break;
case 11: // Kanizsa
ctx.fillStyle="#fff"; // パックマン状の図形を描いて、真ん中に見えない三角形を作る
[[350, 240], [430, 360], [270, 360]].forEach(pt => {
ctx.beginPath(); ctx.arc(pt[0], pt[1], 40, 0, Math.PI*2); ctx.fill();
});
ctx.fillStyle="#111"; ctx.beginPath(); ctx.moveTo(350,240); ctx.lineTo(430,360); ctx.lineTo(270,360); ctx.fill();
break;
case 12: // Fraser
ctx.strokeStyle="#444"; ctx.lineWidth=4;
for(let i=0; i<10; i++) {
ctx.beginPath(); ctx.arc(450, 250, i*40, 0, Math.PI*2);
ctx.setLineDash([20, 10]); ctx.stroke(); ctx.setLineDash([]);
} break;
case 13: // Cafe Wall
ctx.fillStyle="#333";
for(let i=0; i<10; i++) {
let off = (i%2)*20;
for(let j=0; j<20; j++) ctx.fillRect(j*50+off, i*50, 25, 48);
ctx.fillStyle="#666"; ctx.fillRect(0, i*50+48, 900, 2); ctx.fillStyle="#333";
} break;
}
// 足場の描画
platforms.forEach(p => {
if (p.type === 'invisible') return; // カニッツァは描画しない
ctx.fillStyle = "#8b4513"; ctx.fillRect(p.x, p.y, p.w, p.h);
if (p.type === 'in' || p.type === 'out') { // ミュラーリヤーの矢羽
ctx.strokeStyle = "#fff"; ctx.lineWidth = 4;
let dir = p.type === 'in' ? 20 : -20;
ctx.beginPath(); ctx.moveTo(p.x, p.y+10); ctx.lineTo(p.x+dir, p.y-10); ctx.stroke();
ctx.beginPath(); ctx.moveTo(p.x, p.y+10); ctx.lineTo(p.x+dir, p.y+30); ctx.stroke();
ctx.beginPath(); ctx.moveTo(p.x+p.w, p.y+10); ctx.lineTo(p.x+p.w-dir, p.y-10); ctx.stroke();
ctx.beginPath(); ctx.moveTo(p.x+p.w, p.y+10); ctx.lineTo(p.x+p.w-dir, p.y+30); ctx.stroke();
}
});
// 敵の描画
enemies.forEach(e => {
if(e.type === 'ghost' && e.visible) {
ctx.fillStyle = "rgba(255, 50, 50, 0.8)"; ctx.fillRect(e.x-20, e.y-40, 40, 40);
e.hit--; if(e.hit <= 0) e.visible = false;
}
if(e.type === 'ebbinghaus' && e.alive) {
ctx.fillStyle = "#ff4444"; ctx.beginPath(); ctx.arc(e.x, e.y-20, e.core, 0, 7); ctx.fill(); // 核
ctx.fillStyle = "#666"; // 周りの円
for(let i=0; i<6; i++) {
let angle = (Math.PI*2 / 6) * i + roomFrame*0.02;
let dist = e.core + e.outer + 5;
ctx.beginPath(); ctx.arc(e.x + Math.cos(angle)*dist, e.y-20 + Math.sin(angle)*dist, e.outer, 0, 7); ctx.fill();
}
}
});
// 地面線
ctx.strokeStyle = "#555"; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, 420); ctx.lineTo(900, 420); ctx.stroke();
}
function draw() {
drawBG();
if(currentRoom === 3) player.history.forEach(h => drawGinger(h, 0.2));
drawGinger(player);
requestAnimationFrame(draw); update();
}
spawnEntities(0); draw();
</script>
</body>
</html>


■ 今日の学び

 錯視をテーマにしたこともあって、今日は少し目が疲れました。( *_*`)

 でも、視覚だけでなく“ゲームとして触れられる錯視”はとても新鮮で、作っていて楽しかったです。

 そして何より、しょうが君のキャラクターをますます気に入っています。

 しょうがの形と笑顔がポイントで、冒険者風の帽子は「チャレンジする活力」を表しています。

 元気のかたまりのようなしょうが君は、アクションゲームとの相性もぴったり♪

 今現在(R8.3.15)、しょうが君が登場するゲームはこの作品を含めて3つになりました。今のところどれも違ってますが、アクションゲームです。(*^v^*)
 そのうちまとめて紹介しますので、どうぞお楽しみに。

 次回は「早口言葉実況レース」ゲームです。
 こちらもぜひ遊んでみてくださいね。(*^v^)ノシ

次の実験はこちら(レース実況ゲーム): 「アメンボレース」



 ゲーム作りが楽しくて、どんどん作品が増えています(*^v^*)
 もし応援していただけたら、とても励みになります。

コメントを残す

AIと作る100のゲーム実験室(*^v^*)をもっと見る

今すぐ購読し、続きを読んで、すべてのアーカイブにアクセスしましょう。

続きを読む