■17個目「しょうが君・錯視ゲーム」
こんにちは、AIと作る100のゲーム実験室の みー です。(^^*)
今回は、錯視の世界に“キャラクターとして入り込んだらどうなるんだろう?”という発想から作ったゲームです。
部屋ごとに「錯視のテーマ」があり、錯視を体験しながら右の部屋へどんどん進んでいきます。
アクションゲームなので、しょうが君は左右移動・ジャンプ・攻撃もできます。
主人公の“しょうが君”は、「しょうが=元気・健康」というイメージに近づけたくて、少しこだわりながら作りました。
作っているうちに愛着が湧いてきて、これからも登場してもらいたいキャラクターです。
■ AIとのやりとりで面白かった部分
(^^*)今日もゲーム作ります。
今回は、錯視・錯覚の世界を実際に“体感できる”ゲームにしたいと思っています。
プレイヤーはオリジナルキャラ「しょうが君」。
胴体と手足はショウガで、上部にはにこやかな笑顔。
冒険家風の帽子をかぶっています。
(AI)いらっしゃい!また来てくれましたね。
「しょうが君」の冒険、今回はかなりトリッキーな場所になりそうですね。
〈最初のAIの反応は毎回ドキドキします〉
(AI)今日の「マスターの小話」
しょうが君、なかなか可愛いですね。
さて、錯視といえば……**「ムンカー錯視」**という現象をご存知ですか?
同じ色の円でも、上に重ねる格子の色によって全く違う色に見えてしまうんです。
人間の脳は「ありのまま」を見ているのではなく、**周囲の情報から推測して補正している**んですね。
だから、ゲームでも背景の線の引き方ひとつで「届かない距離が届くように見える」なんてことが起こります。
〈しょうが君を褒めてくれてありがとう。でも手足がない…〉
(^v^*)じゃあ、しょうが君に手足を付けてあげて、動かすと手足も動く感じにして。
丸みのある手足がいいね。
錯視武器や錯視敵も登場させたい。
(AI)手足がついたしょうが君、歩く姿がとってもチャーミングですね!
残像ブレードの切れ味はいかがでしたか? 点滅する背景で目がチカチカする感覚、そして見えない敵を「残像」で捉える……まさに錯視アクションの真骨頂です。
(^v^*)床屋のぐるぐる看板の“エンドレス上り螺旋”もゲームに取り入れられるかな。
(AI)さすが常連さん、センスが鋭い! あの床屋の看板(サインポール)が上に昇り続けて見える現象は、**流体錯視や運動残効**の一種なんですよ。
〈錯視についてAIから豆知識をもらいながら、ゲーム作成を進めていきます〉
(AI)さて、常連さん……いや、錯視マスター。
もしあなたが、まだ「思ってたのと違う」世界を追い求めたいなら、私と一緒に新しい“不可能な世界”の構想でも練ぎませんか?
いつでも、あなたの挑戦をお待ちしていますよ……フフフ。
〈不思議な世界観が楽しめるゲームになりました〉(*^^)
■ 実際の動作
最初に相談してできたしょうが君の状態。

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

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

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

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

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

■ コードのポイント
今回の記事では、AI と相談しながら作った「しょうが君の錯視ゲーム」の HTML コードを公開します。ブラウザだけで動く、とてもシンプルなゲームです。
PC の「メモ帳」(※Windowsの場合)を開き、下のコードをコピーして貼り付け、「sakushi.html」など好きな名前で保存してください。保存したファイルをブラウザで開くだけで遊べます。
【操作方法】
・移動:矢印キーの ← →
・ジャンプ:スペースキー
・攻撃:Zキー
・上キー:しょうが君が“震える”特別な動きをします
▼ここからコード▼(クリックで開く)
<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^*)
もし応援していただけたら、とても励みになります。

コメントを残す