■73個目 「盆栽職人~松の造形~」
こんにちは、AIと作る100のゲーム実験室の みー です。(*^_^*)/
今回は、和の心を感じるゲームを作ってみました。
テーマは……なんと “松の盆栽” です!
一鉢の松の苗木が、ゆっくり、じわじわと成長していきます。
プレイヤーは盆栽職人となり、
その成長を見守りながら形を整えていきます。
今回こだわったのは、盆栽の“リアルな造形美”と“手入れの楽しさ”。
ただ成長するだけでなく、
枝の先細り、古木の質感、コケの表情まで表現してみました。
どっしりとした存在感のある巨木を目指して、じっくりと仕立てられます。
ゲームの中では、職人の道具を使って手入れを行います。
- 剪定ばさみ:枝先を切って成長を止める
- ピンセット:松葉を一本ずつスッと抜く
- 枝葉一括抜き:スペシャルピンセット!バサッと一気に葉を透く
- 針金掛け:枝に針金をかけ、理想の形へ曲げる
盆栽は「ツツツー」ではなく「じわじわ」と成長します。(^_^)ノ
ホバー時のハイライトやズーム機能を使って、
どこを剪定するか、どこに針金をかけるか、ミリ単位の手入れを楽しめます。
太くなった幹からは葉が出ない、曲げすぎると折れるなど、
盆栽の美学も取り入れています。(^^*)
理想の形が完成したら、「仕上がり確認」で針金を外し、
本来の姿を鑑賞できます。
さらに「画像保存」で、和紙の背景とともに綺麗な写真として残せます。
どんな一鉢が生まれるのか、お楽しみに!
■ AIとのやりとりで面白かった部分
● ゲーム仕様の相談からスタート
みー:(^v^) 今日もゲームを作ります。HTML形式で動かせるゲーム。
今回は「盆栽のゲーム」を作れるかなと思って相談しました。
苗木から始まり、超早送りで成長していく松の木。
幹や枝は伸び、徐々に太くなり、
生長点を切るとそこで成長が止まります。
側枝を伸ばして形を作り、
細い枝には針金をかけて曲げることもできます。
(※曲げすぎると折れるので注意。)
剪定ばさみ、ピンセット、針金などの道具を
切り替えながら操作できるようにしたい。
主役となる松は、リアリティ高く表現してほしいです。
AI: まいど。いつもの特等席が空いてるよ。
今日は盆栽、しかも松とは渋いテーマだね。
超早送りで成長する命を、鋏と針金で形作る……
まさに職人の腕の見せ所。
要望通り、HTMLファイルの中に
- 松のリアルな成長
- 剪定(鋏)
- 葉抜き(ピンセット)
- 針金掛け
全部詰め込んでおいたよ。
みー:(つv=`) 背景が暗くて枝の成長が見えにくいよ。
剪定した先はフェードアウトさせたい。
成長が速すぎて丁寧に作業できないので、
「成長 → 一時停止 → 作業」のサイクルで楽しめるようにしたいです。
最終的には、5分ほどで樹齢何十年もの古木になるイメージ。
AI: 確かに、成長が早すぎたし、背景が暗いと枝振りが見えにくいよな。
盆栽は余白とシルエットが命だから、
和紙のような明るい背景に変更して、枝がくっきり見えるようにしたよ。
さらに「時間停止」ボタンを追加。
放置すれば5分後には風格のある古木になるよう、成長率も調整した。
剪定した先がスーッと消えるフェードアウト処理も入れてある。
みー:(^v^;) 曲げる操作が難しいね。
折れた枝は消えるようにしてほしい。
剪定や折れで成長が止まった枝は、太るだけで葉は出ないように。
枝先が太くなりすぎると盆栽のシルエットが崩れるので、
先端ほど細くなるように調整できるかな。
AI: まいど。 盆栽は「伸ばす」より「止めて太らせる」方が本質やからな。
要望に合わせて、
- 成長が止まった枝は太るだけ
- 生きている枝からは新しい松葉がゆっくり吹き出す
- 先端ほど細くなるように調整
こんな感じで処理を書き直したよ。
丁寧に剪定すれば、立派な「枝棚」が作れるはずや。
みー:(* ̄~ ̄) 幹が鉢の手前に見えてしまうので修正お願い。
太くなった幹から葉が出ないように。
幹と枝のつながり部分の違和感もなくしたい。
AI: お、観察眼が完全に盆栽職人やね。
鉢と幹の関係、太い幹からの葉吹き、枝振りのシルエット……
どれも盆栽の美学に関わる大事なポイントや。
みー:(*=_=) 枝が鉢のふちにかかると後ろに隠れちゃうね。 巨木としての存在感ももっと出したい。
AI: いやあ、あんたのこだわりは本物や。
「枝が鉢に隠れる問題」と「巨木の存在感」、どっちも妥協できん。
そこで、徹底的に改修した「巨木・懸崖対応版」を用意したよ。
■ 実際の動作
最初に AI が作ってくれたゲーム画面がこちらです。
成長スピードがとても速く、手入れをする間もなく枝が伸びてしまい、
背景も暗めで少し見えにくい印象でした。

(画像:AIが最初に生成したゲーム画面)
こちらが 完成版のスタート画面 です。
芽が出てくるところから始まり、ゆっくりと成長を楽しめます。

(画像:完成版ゲーム画面)
成長が進むと、枝が分かれ、少しずつ葉が出てきます。

(画像:最初の葉までの成長)
枝や幹が細いうちは、針金掛けで角度を調整できます。
ただし、一番太い元の幹はマウス位置の調整が難しいので、
折らないように気をつけてください。

(画像:針金掛け画面)
もし最初の幹を折ってしまうと、そこで成長は止まり、やり直しになります。

(画像:曲げすぎて折ってしまった画面)
成長が進むと幹が太り、針金も外れます。
幹が太くなるまでは葉がどんどん出てくるので、
葉を取り除きながら枝の形を確認し、
剪定や針金掛けで理想の形に整えていきます。

(画像:盆栽の成長途中①)
さらに太くなった幹や枝からは、葉が出なくなってきます。
針金での調整もできなくなり、盆栽らしい“落ち着いた姿”になっていきます。

(画像:盆栽の成長途中②)
時間をかけて育てると、より存在感のある巨木へと成長します。

(画像:盆栽の成長途中③)
どんな盆栽にするかは自由です。
うまくいかなくても、自分で育てると自然と愛着がわいてきます。

(画像:今回の作品)
■ コードのポイント
今回の記事では、AI と相談しながら作った
「盆栽職人~松の造形~」 の HTML コードを公開します。
このコードをそのまま保存するだけで、
あなたのPCでもすぐに遊べます。
ブラウザだけで動く、とてもシンプルなゲームなので、
気軽に試してみてくださいね。
▼ 遊ぶまでの手順
①PC の「メモ帳」(※Windowsの場合)や
Mac の「テキストエディット」など、テキストエディタを開きます。
②下のコードをコピーして貼り付けます。
③「bonsai.html」など、好きな名前で保存します。
(※拡張子は .html にしてください)
④保存したファイルをブラウザで開けば、すぐに遊べます。
■ 操作方法
今回は、このゲームのキモである “操作方法” をご紹介します! 複雑そうに見えて、実はとても直感的に遊べるようになっています。(^_^)
1. 苗のお手入れ(基本)
まずは、基本の観察と画面操作です。(^^)
- ズームと移動
マウスホイールで、じわじわとズームイン・アウトできます。
拡大したい場所にマウスを置くのがコツ。
右クリックのドラッグで視点を移動できるので、
ミリ単位の手入れもバッチリです。
2. 剪定ばさみ(Pruning)
「剪定」ボタン(赤色✂️アイコン)を選ぶと、はさみが使えます。
- 操作方法
枝にマウスをホバーすると、そこから先が赤く光ります。
ここがパチンッと切り落とされる部分。
狙いを定めて左クリック!(^v^)” - コツ
枝先を切って成長点を止めると、
葉が密生しやすくなるという “盆栽の美学” も体験できます。
3. 針金掛け(Wiring)
「針金」ボタン(オレンジ色コイルアイコン)で、枝を理想の形へ。
- 操作方法
ホバーすると、曲げられる枝がオレンジ色に光ります。(^^v)
針金をかけたら、左クリックでドラッグして、
スーッと好きな方向へ曲げてください。 - 注意点
ガイドエリア(緑)を越えて赤いゾーンまで曲げすぎると……
ボキッ!! Σ(=Д=;)
と折れてしまうので気をつけて。
4. ピンセット(Tweezing)
「ピンセット」ボタン(黄色アイコン)は、葉の透かしに使います。
- 操作方法(個別抜き)
松葉を一本だけスッと抜きたい時に。
黄色く光る葉を狙ってクリックします。 - スペシャルピンセット(一括抜き)
枝を選ぶと、その枝に生えている松葉を
バサッ!! と全部抜く、究極の時短ツールです。
5. 画像保存(Saving)
「画像保存」ボタンで、完成した一鉢を綺麗な画像として保存できます。📸
- 操作方法
クリックすると、ハイライトやガイド、針金表示が一瞬で消え、
仕上がり状態の PNG 画像がダウンロードされます。(^^v) - ポイント 和紙のようなあたたかい背景ごと保存されます。
以上が『盆栽職人』の操作方法でした!
“コードは長いので折りたたんでいます。必要な方は開いてご覧ください。”
▼ここからコード▼(クリックで開く)
<html lang="ja"><head> <meta charset="UTF-8"> <title>盆栽職人 - 松の造形</title> <style> body { margin: 0; padding: 0; background-color: #e8dfd3; color: #333; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; display: flex; flex-direction: column; align-items: center; height: 100vh; overflow: hidden; } #ui-bar { background-color: #2c2c2c; width: 100%; padding: 10px 15px; display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); z-index: 10; } .tool-btn { background-color: #444; color: white; border: 2px solid #555; padding: 8px 12px; border-radius: 5px; cursor: pointer; font-size: 14px; font-weight: bold; transition: all 0.2s; display: flex; align-items: center; gap: 5px; } .tool-btn:hover { background-color: #555; } .tool-btn.active { background-color: #8b5a2b; border-color: #d2b48c; box-shadow: 0 0 10px #d2b48c; } #btn-preview { background-color: #3b5998; border-color: #5b79b8; } #btn-preview.active { background-color: #4c70ba; box-shadow: 0 0 10px #8baaf0; } #btn-time { background-color: #2e6b40; border-color: #4CAF50; } #btn-time.paused-btn { background-color: #a8423f; border-color: #f44336; } #btn-retry { background-color: #555; border-color: #666; } #btn-retry:hover { background-color: #d32f2f; border-color: #f44336; } #btn-save { background-color: #9c27b0; border-color: #ba68c8; } #btn-save:hover { background-color: #7b1fa2; } .slider-container { display: flex; align-items: center; color: #eee; background-color: #444; padding: 5px 10px; border-radius: 5px; border: 2px solid #555; font-size: 14px; font-weight: bold; } #zoom-slider { margin-left: 8px; cursor: ew-resize; } #gameCanvas { flex-grow: 1; cursor: crosshair; } #message { position: absolute; top: 100px; font-size: 18px; color: #8b5a2b; font-weight: bold; pointer-events: none; text-shadow: 1px 1px 0px white; max-width: 80%; text-align: center; z-index: 5; } </style></head><body><div id="ui-bar"> <button class="tool-btn active" id="tool-none" onclick="app.setTool('none')">観察</button> <button class="tool-btn" id="tool-scissors" onclick="app.setTool('scissors')">剪定ばさみ</button> <button class="tool-btn" id="tool-tweezers" onclick="app.setTool('tweezers')">ピンセット</button> <button class="tool-btn" id="tool-special-tweezers" onclick="app.setTool('special-tweezers')">枝葉一括抜き</button> <button class="tool-btn" id="tool-wire" onclick="app.setTool('wire')">針金掛け</button> <div style="width: 2px; background-color: #666; margin: 0 5px;"></div> <button class="tool-btn" id="btn-preview" onclick="app.togglePreview()">仕上がり確認</button> <button class="tool-btn" id="btn-time" onclick="app.toggleTime()">一時停止</button> <button class="tool-btn" id="btn-retry" onclick="app.retryGame()">最初から</button> <button class="tool-btn" id="btn-save" onclick="app.saveBonsai()">画像保存</button> <div class="slider-container"> <label for="zoom-slider">ズーム:</label> <input type="range" id="zoom-slider" min="0.5" max="3" step="0.1" value="1"> </div></div><div id="message"></div><canvas id="gameCanvas"></canvas><script>window.addEventListener('load', function() { const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); const msgEl = document.getElementById('message'); let width, height; function resize() { width = canvas.width = window.innerWidth; let uiBar = document.getElementById('ui-bar'); let uiHeight = uiBar ? uiBar.offsetHeight : 50; height = canvas.height = window.innerHeight - uiHeight; } window.addEventListener('resize', resize); resize(); let currentTool = 'none'; let isPaused = false; let isPreviewMode = false; let zoom = 1.0; const LIMIT_ANGLE = Math.PI / 2.5; let mouseX = 0; let mouseY = 0; let hoveredBranch = null; let hoveredNeedle = null; let activeWireBranch = null; let isDragging = false; window.app = { setTool: function(tool) { currentTool = tool; document.querySelectorAll('.tool-btn:not(#btn-time):not(#btn-retry):not(#btn-preview):not(#btn-save)').forEach(btn => btn.classList.remove('active')); const activeBtn = document.getElementById('tool-' + tool); if(activeBtn) activeBtn.classList.add('active'); if(tool === 'wire') msgMsg("細い枝を曲げます。(オレンジ色に光る枝が対象)"); else if(tool === 'scissors') msgMsg("枝先を切ります。(赤く光る部分が切り落とされます)"); else if(tool === 'tweezers') msgMsg("松葉をピンセットで抜きます。(黄色く光る松葉が対象)"); else if(tool === 'special-tweezers') msgMsg("指定した枝の松葉をすべて一気に抜きます。"); else msgMsg(""); updateHover(); }, toggleTime: function() { isPaused = !isPaused; const btn = document.getElementById('btn-time'); if (isPaused) { btn.textContent = "成長再開"; btn.classList.add('paused-btn'); msgMsg("時間を止めました。じっくり作業してください。"); } else { btn.textContent = "一時停止"; btn.classList.remove('paused-btn'); msgMsg("成長を再開しました。"); } }, togglePreview: function() { isPreviewMode = !isPreviewMode; const btn = document.getElementById('btn-preview'); if (isPreviewMode) { btn.classList.add('active'); msgMsg("仕上がり確認モード:針金を非表示にしました。"); } else { btn.classList.remove('active'); msgMsg("作業モード:針金を表示します。"); } }, retryGame: function() { if(!confirm("最初からやり直しますか?現在の盆栽は失われます。")) return; init(); if (isPaused) this.toggleTime(); this.setTool('none'); activeWireBranch = null; isDragging = false; hoveredBranch = null; hoveredNeedle = null; zoom = 1.0; document.getElementById('zoom-slider').value = 1.0; msgMsg("新しく苗木を植えました。"); }, saveBonsai: function() { this.setTool('none'); hoveredBranch = null; hoveredNeedle = null; activeWireBranch = null; isDragging = false; drawScene(); let dataURL = canvas.toDataURL("image/png"); let link = document.createElement('a'); link.href = dataURL; link.download = "my_bonsai.png"; link.click(); msgMsg("盆栽の写真を保存しました!"); } }; document.getElementById('zoom-slider').addEventListener('input', function(e) { zoom = parseFloat(e.target.value); updateHover(); }); function msgMsg(text) { msgEl.textContent = text; const time = text.length > 20 ? 5000 : 3000; if (msgEl.timeoutId) clearTimeout(msgEl.timeoutId); msgEl.timeoutId = setTimeout(() => { if(msgEl.textContent === text) msgEl.textContent = ''; }, time); } const dist = (x1, y1, x2, y2) => Math.sqrt((x2-x1)**2 + (y2-y1)**2); const distToSegment = (px, py, x1, y1, x2, y2) => { let l2 = (x2-x1)**2 + (y2-y1)**2; if (l2 === 0) return dist(px, py, x1, y1); let t = ((px-x1)*(x2-x1) + (py-y1)*(y2-y1)) / l2; t = Math.max(0, Math.min(1, t)); return dist(px, py, x1 + t*(x2-x1), y1 + t*(y2-y1)); }; class Branch { constructor(parent, angleOffset) { this.parent = parent; this.children = []; this.needles = []; this.angleOffset = angleOffset; this.initialAngleOffset = angleOffset; this.length = 0; this.maxLength = parent ? 15 + Math.random() * 25 : 55 + Math.random() * 20; this.baseThickness = 2; this.startThickness = parent ? parent.thickness : 35; this.thickness = 0.5; this.maxThickness = parent ? parent.maxThickness * 0.82 : 55; this.isGrowing = true; this.isBroken = false; this.opacity = 1.0; this.fading = false; this.dead = false; this.isWired = false; this.wireFixed = false; this.absX = 0; this.absY = 0; this.absEndX = 0; this.absEndY = 0; this.absAngle = 0; this.depth = parent ? parent.depth + 1 : 0; } isDescendantOf(ancestor) { let p = this; while(p) { if (p === ancestor) return true; p = p.parent; } return false; } update() { if (this.dead) return; if (this.fading) { this.opacity -= 0.02; if (this.opacity <= 0) this.dead = true; this.children.forEach(c => c.fading = true); return; } this.children = this.children.filter(c => !c.dead); if (this.isBroken) return; if (!this.parent && this.baseThickness < 80) { this.baseThickness += 0.0025; } if (this.thickness < this.maxThickness) { if (!this.isGrowing && this.children.length === 0) { } else { this.thickness += 0.0015; } } if (this.isWired && this.thickness > 8) { this.isWired = false; this.wireFixed = true; this.initialAngleOffset = this.angleOffset; } if (this.thickness >= 10) { this.needles.forEach(n => { if (!n.removed) n.fading = true; }); } else if (this.depth >= 2) { let targetNeedles = Math.floor(this.length / 3); if (!this.isGrowing && this.children.length === 0) targetNeedles += 4; let activeNeedles = this.needles.filter(n => !n.removed && !n.fading).length; if (activeNeedles < targetNeedles && Math.random() < 0.01) { this.needles.push({ pos: 0.1 + Math.random() * 0.9, removed: false, opacity: 0.0, fading: false, sprouting: true }); } } if (this.isGrowing) { if (this.length < this.maxLength) { this.length += 0.03; } else { if (this.children.length === 0 && this.depth < 9) { let numBranches = Math.random() < 0.4 ? 1 : 2; if (this.depth === 0) numBranches = 3; for (let i = 0; i < numBranches; i++) { let a = (Math.random() - 0.5) * 1.2; if (numBranches === 2) { a = (i === 0 ? 1 : -1) * (0.2 + Math.random() * 0.6); } this.children.push(new Branch(this, a)); } } } } this.needles.forEach(n => { if (n.sprouting) { n.opacity += 0.01; if (n.opacity >= 1.0) { n.opacity = 1.0; n.sprouting = false; } } if (n.fading) { n.opacity -= 0.05; if(n.opacity <= 0) n.removed = true; } }); this.children.forEach(c => c.update()); } calcPositions(startX, startY, parentAngle) { if (this.dead) return; this.absX = startX; this.absY = startY; this.absAngle = parentAngle + this.angleOffset; this.absEndX = startX + Math.cos(this.absAngle) * this.length; this.absEndY = startY + Math.sin(this.absAngle) * this.length; this.children.forEach(c => c.calcPositions(this.absEndX, this.absEndY, this.absAngle)); } draw(ctx, drawMode = 'all') { if (this.dead) return; if (drawMode === 'childrenOnly') { this.children.forEach(c => c.draw(ctx, 'all')); return; } ctx.save(); ctx.globalAlpha = this.opacity; let startT = this.parent ? this.parent.thickness : this.baseThickness; let endT = this.thickness; let vx = this.absEndX - this.absX; let vy = this.absEndY - this.absY; let len = Math.sqrt(vx*vx + vy*vy); if (len > 0) { let nx = -(vy / len); let ny = vx / len; let x1 = this.absX + nx * (startT / 2); let y1 = this.absY + ny * (startT / 2); let x2 = this.absX - nx * (startT / 2); let y2 = this.absY - ny * (startT / 2); let x3 = this.absEndX - nx * (endT / 2); let y3 = this.absEndY - ny * (endT / 2); let x4 = this.absEndX + nx * (endT / 2); let y4 = this.absEndY + ny * (endT / 2); ctx.fillStyle = '#17120e'; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.lineTo(x3, y3); ctx.lineTo(x4, y4); ctx.closePath(); ctx.fill(); let isScissorTarget = currentTool === 'scissors' && hoveredBranch && this.isDescendantOf(hoveredBranch); let isWireTarget = currentTool === 'wire' && this === hoveredBranch; let isSpecialTweezersTarget = currentTool === 'special-tweezers' && this === hoveredBranch; if (isScissorTarget || isWireTarget || isSpecialTweezersTarget) { ctx.fillStyle = isScissorTarget ? 'rgba(244, 67, 54, 0.4)' : isWireTarget ? 'rgba(255, 152, 0, 0.5)' : 'rgba(255, 235, 59, 0.4)'; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.lineTo(x3, y3); ctx.lineTo(x4, y4); ctx.closePath(); ctx.fill(); } if (this.parent) { ctx.beginPath(); ctx.arc(this.absX, this.absY, startT / 2, 0, Math.PI * 2); ctx.fillStyle = (isScissorTarget || isWireTarget || isSpecialTweezersTarget) ? (isScissorTarget ? 'rgba(244, 67, 54, 0.4)' : isWireTarget ? 'rgba(255, 152, 0, 0.5)' : 'rgba(255, 235, 59, 0.4)') : '#17120e'; ctx.fill(); ctx.fillStyle = '#17120e'; } ctx.beginPath(); ctx.arc(this.absEndX, this.absEndY, endT / 2, 0, Math.PI * 2); ctx.fillStyle = (isScissorTarget || isWireTarget || isSpecialTweezersTarget) ? (isScissorTarget ? 'rgba(244, 67, 54, 0.4)' : isWireTarget ? 'rgba(255, 152, 0, 0.5)' : 'rgba(255, 235, 59, 0.4)') : '#17120e'; ctx.fill(); ctx.fillStyle = '#17120e'; if (endT > 6 && !this.isBroken) { let lines = Math.floor((startT + endT) / 2 / 5); ctx.lineCap = 'round'; for (let i = 1; i <= lines; i++) { let r = (i / (lines + 1)) * 2 - 1; r *= 0.65; ctx.beginPath(); ctx.moveTo(this.absX + nx * (startT/2 * r), this.absY + ny * (startT/2 * r)); ctx.lineTo(this.absEndX + nx * (endT/2 * r), this.absEndY + ny * (endT/2 * r)); ctx.strokeStyle = (i % 2 === 0) ? '#2a221b' : '#0a0805'; ctx.lineWidth = Math.max(1.5, endT * 0.08); ctx.stroke(); } } } if (!this.isBroken) { this.needles.forEach(n => { if (n.removed) return; let nx = this.absX + (this.absEndX - this.absX) * n.pos; let ny = this.absY + (this.absEndY - this.absY) * n.pos; ctx.save(); ctx.globalAlpha = this.opacity * n.opacity; ctx.translate(nx, ny); ctx.rotate(this.absAngle); let isNeedleTarget = currentTool === 'tweezers' && n === hoveredNeedle; ctx.strokeStyle = isNeedleTarget ? '#ffeb3b' : '#1e3814'; ctx.lineWidth = isNeedleTarget ? 2.5 : 1.5; for(let i=-2; i<=2; i++) { ctx.beginPath(); ctx.moveTo(0, 0); let angle = i * 0.35; ctx.lineTo(Math.cos(angle)*18, Math.sin(angle)*18); ctx.stroke(); ctx.strokeStyle = isNeedleTarget ? '#fff59d' : '#2a521a'; ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(Math.cos(angle)*15, Math.sin(angle)*15); ctx.stroke(); ctx.strokeStyle = isNeedleTarget ? '#ffeb3b' : '#1e3814'; } ctx.restore(); }); } if (this.isWired && !this.isBroken && !isPreviewMode) { ctx.save(); ctx.translate(this.absX, this.absY); ctx.rotate(this.absAngle); ctx.strokeStyle = '#c68e58'; ctx.lineWidth = Math.max(1.5, endT * 0.3); ctx.beginPath(); for(let i=0; i<this.length; i+=4) { let currentOffset = Math.sin(i * 0.6) * (endT / 2 + 1); if (i===0) ctx.moveTo(i, currentOffset); else ctx.lineTo(i, currentOffset); } ctx.stroke(); ctx.restore(); if (isDragging && activeWireBranch === this) { this.drawWireGuide(ctx); } } ctx.restore(); if (drawMode !== 'rootOnly') { this.children.forEach(c => c.draw(ctx, 'all')); } } drawWireGuide(ctx) { let parentAbsAngle = this.parent ? this.parent.absAngle : (-Math.PI / 2); let originalAbsAngle = parentAbsAngle + this.initialAngleOffset; ctx.save(); ctx.translate(this.absX, this.absY); ctx.beginPath(); ctx.moveTo(0, 0); ctx.arc(0, 0, this.length + 10, originalAbsAngle - LIMIT_ANGLE, originalAbsAngle + LIMIT_ANGLE); ctx.closePath(); ctx.fillStyle = 'rgba(76, 175, 80, 0.1)'; ctx.fill(); ctx.beginPath(); ctx.moveTo(0, 0); ctx.arc(0, 0, this.length + 15, originalAbsAngle - LIMIT_ANGLE - 0.1, originalAbsAngle + LIMIT_ANGLE + 0.1); ctx.arc(0, 0, this.length + 5, originalAbsAngle + LIMIT_ANGLE + 0.1, originalAbsAngle - LIMIT_ANGLE - 0.1, true); ctx.closePath(); ctx.fillStyle = 'rgba(244, 67, 54, 0.2)'; ctx.fill(); ctx.restore(); } getBranchAt(x, y, distThreshold = 10) { if(this.dead || this.fading || this.isBroken) return null; let d = distToSegment(x, y, this.absX, this.absY, this.absEndX, this.absEndY); let startT = this.parent ? this.parent.thickness : this.baseThickness; let endT = this.thickness; let t = Math.max(distThreshold, (startT + endT) / 2); let target = (d < t) ? this : null; for (let c of this.children) { let found = c.getBranchAt(x, y, distThreshold); if (found) target = found; } return target; } getNeedleAt(x, y) { if(this.dead || this.fading || this.isBroken) return null; for (let n of this.needles) { if (n.removed || n.fading) continue; let nx = this.absX + (this.absEndX - this.absX) * n.pos; let ny = this.absY + (this.absEndY - this.absY) * n.pos; if (dist(x, y, nx, ny) < 15) return n; } for (let c of this.children) { let found = c.getNeedleAt(x, y); if (found) return found; } return null; } } function drawPotBack() { let potTopY = -10; let potCenter = 0; let potWidthTop = 220; let potWidthBot = 180; let potHeight = 70; ctx.fillStyle = 'rgba(0,0,0,0.1)'; ctx.beginPath(); ctx.ellipse(potCenter, potTopY + potHeight + 5, potWidthBot/2 + 20, 15, 0, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = '#2b2118'; ctx.beginPath(); ctx.ellipse(potCenter, potTopY + 10, potWidthTop/2 - 8, 12, 0, 0, Math.PI*2); ctx.fill(); let grad = ctx.createLinearGradient(potCenter - potWidthTop/2, 0, potCenter + potWidthTop/2, 0); grad.addColorStop(0, '#2e3235'); grad.addColorStop(0.3, '#4a4e52'); grad.addColorStop(1, '#1b1d1f'); ctx.fillStyle = grad; ctx.beginPath(); ctx.moveTo(potCenter - potWidthTop/2, potTopY); ctx.lineTo(potCenter + potWidthTop/2, potTopY); ctx.lineTo(potCenter + potWidthBot/2, potTopY + potHeight); ctx.lineTo(potCenter - potWidthBot/2, potTopY + potHeight); ctx.closePath(); ctx.fill(); } // 【修正ポイント】鉢の手前側:本体を底まで描画してから縁を重ねる function drawPotFront() { let potTopY = -10; let potCenter = 0; let potWidthTop = 220; let potWidthBotRef = 180; // drawPotBackと同じ底幅 let potHeight = 70; // 鉢の奥側と同じグラデーションで、手前側の「本体」を底まで描画 let grad = ctx.createLinearGradient(potCenter - potWidthTop/2, 0, potCenter + potWidthTop/2, 0); grad.addColorStop(0, '#2e3235'); grad.addColorStop(0.3, '#4a4e52'); grad.addColorStop(1, '#1b1d1f'); ctx.fillStyle = grad; ctx.beginPath(); // 土の表面付近から、鉢全体の底まで隠す台形 ctx.moveTo(potCenter - potWidthTop/2, potTopY + 11); // y=1 ctx.lineTo(potCenter + potWidthTop/2, potTopY + 11); ctx.lineTo(potCenter + potWidthBotRef/2, potTopY + potHeight); // y=60 ctx.lineTo(potCenter - potWidthBotRef/2, potTopY + potHeight); ctx.closePath(); ctx.fill(); // 鉢の縁(手前:本体の上に描画) ctx.fillStyle = '#34383b'; ctx.fillRect(potCenter - potWidthTop/2 - 10, potTopY - 10, potWidthTop + 20, 18); ctx.fillStyle = '#111213'; ctx.fillRect(potCenter - potWidthTop/2 - 10, potTopY + 8, potWidthTop + 20, 3); } let rootBranch; function init() { rootBranch = new Branch(null, -Math.PI / 2); } function updateHover() { if (isDragging && activeWireBranch) return; hoveredBranch = null; hoveredNeedle = null; if (currentTool === 'scissors') { let b = rootBranch.getBranchAt(mouseX, mouseY); if (b && (b.isGrowing || b.children.length > 0)) hoveredBranch = b; } else if (currentTool === 'tweezers') { let n = rootBranch.getNeedleAt(mouseX, mouseY); if (n) hoveredNeedle = n; } else if (currentTool === 'special-tweezers') { let b = rootBranch.getBranchAt(mouseX, mouseY); if (b && b.needles.some(n => !n.removed && !n.fading)) hoveredBranch = b; } else if (currentTool === 'wire') { let b = rootBranch.getBranchAt(mouseX, mouseY, 15); if (b && !b.isBroken && b.thickness < 10) hoveredBranch = b; } if (hoveredBranch || hoveredNeedle) { canvas.style.cursor = 'pointer'; } else { canvas.style.cursor = 'crosshair'; } } canvas.addEventListener('mousemove', (e) => { let rect = canvas.getBoundingClientRect(); let rawMouseX = e.clientX - rect.left; let rawMouseY = e.clientY - rect.top; let rootScreenX = width / 2; let rootScreenY = height - 110; mouseX = (rawMouseX - rootScreenX) / zoom; mouseY = (rawMouseY - rootScreenY) / zoom; updateHover(); if (!isDragging || !activeWireBranch) return; let parentAngle = activeWireBranch.parent ? activeWireBranch.parent.absAngle : (-Math.PI/2); let targetAbsAngle = Math.atan2(mouseY - activeWireBranch.absY, mouseX - activeWireBranch.absX); let newAngleOffset = targetAbsAngle - parentAngle; while(newAngleOffset > Math.PI) newAngleOffset -= Math.PI*2; while(newAngleOffset < -Math.PI) newAngleOffset += Math.PI*2; activeWireBranch.angleOffset = newAngleOffset; let diff = Math.abs(newAngleOffset - activeWireBranch.initialAngleOffset); if (diff > LIMIT_ANGLE) { activeWireBranch.isBroken = true; activeWireBranch.isWired = false; activeWireBranch.fading = true; isDragging = false; activeWireBranch = null; msgMsg("曲げすぎて折れました"); updateHover(); } }); canvas.addEventListener('mousedown', (e) => { if (currentTool === 'scissors' && hoveredBranch) { hoveredBranch.isGrowing = false; hoveredBranch.children.forEach(c => c.fading = true); msgMsg("枝先を切りました"); updateHover(); } else if (currentTool === 'tweezers' && hoveredNeedle) { hoveredNeedle.fading = true; msgMsg("松葉を抜きました"); updateHover(); } else if (currentTool === 'special-tweezers' && hoveredBranch) { let removedCount = 0; hoveredBranch.needles.forEach(n => { if (!n.removed && !n.fading) { n.fading = true; removedCount++; } }); if (removedCount > 0) { msgMsg("枝の松葉を一気に抜きました"); updateHover(); } } else if (currentTool === 'wire' && hoveredBranch) { activeWireBranch = hoveredBranch; isDragging = true; hoveredBranch.isWired = true; } else if (currentTool === 'wire') { let b = rootBranch.getBranchAt(mouseX, mouseY, 15); if (b && b.thickness >= 10) { msgMsg("この枝は太すぎて曲げられません。"); } } }); window.addEventListener('mouseup', () => { isDragging = false; updateHover(); }); function drawScene() { ctx.clearRect(0, 0, width, height); let r = Math.max(1, width / 1.5); let bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, r); bgGrad.addColorStop(0, '#f4eee6'); bgGrad.addColorStop(1, '#c8bcad'); ctx.fillStyle = bgGrad; ctx.fillRect(0, 0, width, height); let rootScreenX = width / 2; let rootScreenY = height - 110; ctx.save(); ctx.translate(rootScreenX, rootScreenY); ctx.scale(zoom, zoom); // 1. 鉢の奥側・土 drawPotBack(); if (!isPaused) { rootBranch.update(); } else { updateFadingOnly(rootBranch); } rootBranch.calcPositions(0, 0, 0); // 2. 幹の根元だけ描画(鉢の中に収める) rootBranch.draw(ctx, 'rootOnly'); // 3. 鉢の手前側(本体・フチ)を描画 drawPotFront(); // 4. 残りのすべての枝葉を描画 rootBranch.draw(ctx, 'childrenOnly'); ctx.restore(); } function loop() { drawScene(); if (!isDragging) activeWireBranch = null; requestAnimationFrame(loop); } function updateFadingOnly(b) { if(b.dead) return; if(b.fading) { b.opacity -= 0.02; if(b.opacity <= 0) b.dead = true; b.children.forEach(c => c.fading = true); return; } b.children = b.children.filter(c => !c.dead); b.needles.forEach(n => { if(n.fading) { n.opacity -= 0.05; if(n.opacity <= 0) n.removed = true; } if (n.sprouting) { n.opacity += 0.01; if (n.opacity >= 1.0) { n.opacity = 1.0; n.sprouting = false; } } }); b.children.forEach(c => updateFadingOnly(c)); } init(); loop();}); // window.load</script></body></html>
■ 今日の学び
🌳 今回の学び:壮大なる一歩と、身近な緑 🏡
今回のゲームは、ちょっと特別な「再挑戦」でもありました。(=v=*)
以前、「異世界ダンジョン × 巨大盆栽育成ゲーム」 という、とんでもなく壮大なゲームを作ろうとして……見事に挫折したことがあります。
〈雑記#02:ゲーム作り、早速壁に当たってしまう〉
「巨大な樹の地下部がダンジョンになっていて、 探索を進めるほど地上の遺跡と融合した植物が成長し、 樹上の街が発展していく……」
そんな世界観で、冒険と育成がつながる“盆栽ゲーム”を目指しました。
でも、、形にできませんでした。
今回はその挫折を思い返しながら、
「まずは基本となる盆栽ゲームを作ってみよう」 と、
初心に返って制作を始めました。🐢
やっぱり、基本って大事ですね。(^_^)
休日に腰を据えて作ったので、AIさんとの相談もいつもよりじっくり。
一つ一つ、こだわりを詰め込んでいきました。(^v^)
実際の盆栽は育てていない私ですが、
今回の制作を通して、
盆栽という 「自然」と「人工」が融合した芸術の奥深さ を改めて感じました。
何年、何十年という時間と手間をかけて、
あのドッシリとした存在感が生まれる……
本当にすごい世界です。
そしてこのゲームを作ってから、
ふと自宅の庭の植物たちを見る目が変わりました。
盆栽ではないけれど、
「ただ生えているだけじゃなくて、
少し手を入れるだけで、もっと個性が出るのかも」
なんて思うようになりました。
(^^*) 「この庭木も、少し剪定してみようかな……」
そんなふうに、リアルな植物への想いも強くなりました。
■ 次回は、
異世界滑空ゲーム
ファンタジー世界を舞台に、
“和紙の鷲(ワシ)”のおじいさんが主人公のゲームです。
空に浮かぶ島々からは滝が流れ落ち、
壮大で神秘的な世界が広がっています。
ただし、主人公は 和紙。 濡れると落ちてしまいます。
滝を避けながら、 より遠くへ、より美しく滑空していきましょう。
次回もぜひ遊びに来てくださいね。(*^v^)ノシ
✨今日も最後まで読んでくださって、ありがとうございます。
みーの実験室は、みなさんの応援で元気に動いています。(*^v^*)






🌈はじめての方へ
🌳 実験一覧
🌱 このブログについて
🍃 プライバシーポリシー
🐣未記事ゲーム展示室