実験#73:人の手による自然の制御(盆栽職人~松の造形~)

■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)
  • ポイント  和紙のようなあたたかい背景ごと保存されます。

以上が『盆栽職人』の操作方法でした!

“コードは長いので折りたたんでいます。必要な方は開いてご覧ください。”

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<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^*)

~このブログについて~

AIと一緒に遊び、
100の実験でゲームを育てるブログ

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


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

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

続きを読む