aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/index.html
blob: cb766a5c882071e9f3ba738a3a32cb83b84a2abd (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ぶるべー スタジオ</title>
<!-- An inline empty icon stops the browser from requesting /favicon.ico, which would otherwise show up as a confusing 404 in the console. -->
<link rel="icon" href="data:,">
<link rel="stylesheet" href="src/style.css">
<script>
/*
 * Boot watchdog. Runs as a plain (non-module) script so it is always executed,
 * even when the module entry point cannot be loaded at all. It turns a silent
 * "loading forever" screen into an explanation of what went wrong.
 */
(function () {
  var failures = [];
  var started = Date.now();
  window.addEventListener('error', function (event) {
    var target = event.target;
    if (target && (target.src || target.href)) {
      failures.push((target.tagName === 'SCRIPT' ? 'JS: ' : 'ファイル: ') + (target.src || target.href));
    }
  }, true);
  window.__bluebeyFailures = failures;

  var timer = setInterval(function () {
    if (window.__bluebeyReady) {
      clearInterval(timer);
      var done = document.getElementById('loading-error');
      if (done && done.dataset.watchdog) done.textContent = '';
      return;
    }
    if (Date.now() - started < 9000) return;
    clearInterval(timer);

    var text = document.getElementById('loading-text');
    var box = document.getElementById('loading-error');
    if (text) text.textContent = '読み込みが止まっています。';
    if (!box) return;
    box.dataset.watchdog = '1';

    var lines = [];
    if (location.protocol === 'file:') {
      lines.push('このページはファイルとして直接開かれています(' + location.protocol + ')。');
      lines.push('ブラウザの制限により、この開き方ではJavaScriptモジュールを読み込めません。');
      lines.push('');
      lines.push('次のどちらかで開いてください。');
      lines.push('1) bluebey-studio フォルダで  python serve.py  を実行し、');
      lines.push('   表示された http://127.0.0.1:8000/ を開く(推奨)');
      lines.push('2) bluebey-studio-standalone.html をダブルクリックする(1ファイル版)');
    } else {
      lines.push('必要なファイルを読み込めませんでした。ページの場所とフォルダの中身を確認してください。');
      lines.push('(配布するときは bluebey-studio フォルダの中身をすべてコピーしてください)');
    }
    if (failures.length) {
      lines.push('');
      lines.push('読み込めなかったもの:');
      for (var i = 0; i < failures.length && i < 8; i++) lines.push('・' + failures[i]);
    }
    box.textContent = lines.join('\n');
  }, 1000);
})();
</script>
<script type="importmap">
{
  "imports": {
    "three": "./vendor/three/build/three.module.js",
    "three/addons/": "./vendor/three/examples/jsm/",
    "opentype.js": "./vendor/lib/opentype.module.js"
  }
}
</script>
</head>
<body>

<script>
/*
 * On a small screen the control panel is a bottom sheet (see the small-screen
 * rules in src/style.css): the 3D view fills the screen until the user opens it,
 * so it starts folded away there. Setting the class before the panel is parsed
 * keeps the closed sheet from flashing on the first paint.
 */
if (window.matchMedia('(max-width: 768px), (max-height: 500px) and (pointer: coarse)').matches) {
  document.body.classList.add('panel-hidden');
}
</script>

<div id="stage">
  <canvas id="view" aria-label="3D viewport"></canvas>
</div>

<header id="topbar">
  <h1>ぶるべー スタジオ</h1>
  <div class="topbar-actions">
    <button type="button" id="btn-shot" title="今の見た目をPNGで保存 (S)">PNG保存</button>
    <button type="button" id="btn-copy" title="画像をクリップボードにコピー (C)">コピー</button>
    <button type="button" id="btn-help" title="ヘルプ (?)">?</button>
    <button type="button" id="btn-panel" title="パネルの表示/非表示 (Tab)">≡</button>
  </div>
</header>

<aside id="panel" aria-label="コントロールパネル"></aside>

<p id="viewport-hint" class="viewport-hint">
  ドラッグ:回転 / ホイール:ズーム / 右ドラッグ:移動 / モデルをクリック:ボーン選択
</p>
<p id="viewport-hint-touch" class="viewport-hint">
  1本指でドラッグ:回転 / 2本指:ズーム・移動 / タップ:ボーン選択
</p>

<div id="toast" role="status" aria-live="polite"></div>

<div id="loading">
  <div class="loading-box">
    <div class="loading-spinner"></div>
    <p id="loading-text">ぶるべーを読み込んでいます…</p>
    <p id="loading-error"></p>
  </div>
</div>

<div id="help" hidden>
  <div class="help-sheet">
    <button type="button" class="help-close" id="help-close" aria-label="閉じる">×</button>
    <h2>ぶるべー スタジオの使い方</h2>

    <h3>これは何?</h3>
    <p>
      <code>bluebey.glb</code>(ぶるべーの3Dモデル)をブラウザで読み込み、自由にポーズをとらせ、
      顔の表情を自由に作って、2Dの画像(PNG)として書き出せるスタジオです。
      元の手描きパーツ(GIMPで作った目と口の画像)もそのまま使えます。
    </p>

    <h3>ポーズ</h3>
    <ul>
      <li>右パネルの「ポーズ」でボーンを選び、X/Y/Zのスライダーを動かします。</li>
      <li>モデルをクリックすると、その場所にいちばん近いボーンが選ばれます。</li>
      <li>ギズモ(回転の輪)を直接ドラッグしても回せます。</li>
      <li>スライダーの値は「休めの姿勢(レストポーズ)」からの差分です。<code>0,0,0</code> で元の姿勢。</li>
      <li>気に入ったポーズは「ポーズを保存」でファイルに残せます。</li>
    </ul>

    <h3>表情</h3>
    <ul>
      <li>目:開き具合(まばたき・ウインク)、目線(上下左右)、瞳の大きさ、ハイライト、閉じ方の種類。</li>
      <li>口:笑いの深さ、口の開き、舌、口角の点、太さ、色。</li>
      <li>「目線パッド」をドラッグすると両目が一緒に動きます。</li>
      <li>プリセット(にっこり・ウインク・びっくり…)から始めると早いです。</li>
      <li>「手描きパーツを使う」を選ぶと、元のGIMP製パーツに切り替わります。</li>
    </ul>

    <h3>3Dのまま/2Dにする</h3>
    <ul>
      <li>「表示」で リアル/フラット(トゥーン)/線画/線画(透明) を切り替えます。</li>
      <li>線画は輪郭線+白ぬり。線の太さと色を変えられます。</li>
      <li>「正投影」にすると遠近のゆがみがなくなり、図面のような絵になります。</li>
      <li>背景は透明・白・好きな色から選べます。<code>PNG保存</code>は透明のまま保存できます。</li>
    </ul>

    <h3>キーボード</h3>
    <ul class="keys">
      <li><kbd>S</kbd> PNG保存</li>
      <li><kbd>C</kbd> 画像をコピー</li>
      <li><kbd>Tab</kbd> パネルの表示/非表示</li>
      <li><kbd>?</kbd> このヘルプ</li>
      <li><kbd>1</kbd>–<kbd>5</kbd> カメラの向き(正面・斜め・横・後ろ・俯瞰)</li>
      <li><kbd>R</kbd> ポーズをリセット</li>
      <li><kbd>G</kbd> ギズモの切り替え</li>
      <li><kbd>Space</kbd> 生きている演出(ゆれ・まばたき)の開始/停止</li>
    </ul>

    <h3>書き出しのヒント</h3>
    <ul>
      <li>PNGは「書き出しサイズ」を2倍・3倍にすると高解像度になります(印刷やスライド用)。</li>
      <li>ポーズと表情の全設定は「設定を保存」でJSONに残せます。READMEも参照してください。</li>
    </ul>
  </div>
</div>

<div id="start-notice" hidden>
  <div class="start-sheet" role="dialog" aria-modal="true" aria-labelledby="start-title">
    <h2 id="start-title">ご利用の前に</h2>
    <p>このスタジオは、市政・政治活動とは関係のない個人的な制作物です。</p>
    <p>このスタジオで作成した画像などは、自由に使っていただけます。ただし、商用利用やそのほか特殊な使い方をされる場合は、小平市の確認が必要ですので、このサイトの<a href="/contact/" target="_blank" rel="noopener">お問い合わせ</a>からご連絡ください。</p>
    <p>次のような使い方はご遠慮ください。</p>
    <ul>
      <li>公序良俗に反する目的での利用</li>
      <li>ぶるべーのイメージを損なうような攻撃的・差別的・性的・過激な利用</li>
      <li>反社会的勢力や違法行為に関わる利用</li>
      <li>政治的意図があるように見える利用(例:特定の政治家や政党をぶるべーが応援しているような表現)</li>
      <li>特定の宗教・宗派への勧誘や布教と誤解される利用</li>
      <li>小平市が公認・推薦・後援しているように見える利用(市の公式発表や公文書と誤認させる利用を含む)</li>
      <li>特定の企業・商品・思想・団体を、ぶるべーや小平市が推薦・保証しているように見える利用</li>
    </ul>
    <p>ぶるべーの著作権は小平市にあります。このスタジオと3Dモデルは作者(安竹洋平)が自作したものです。</p>
    <div class="start-actions">
      <label class="start-skip"><input type="checkbox" id="start-skip"> 次回から表示しない</label>
      <button type="button" id="start-ok" class="start-ok">同意して使う</button>
    </div>
  </div>
</div>

<script>
/*
 * 起動時のご利用について。毎回出し、「次回から表示しない」を選んだときだけ覚える。
 * アプリが立ち上がってから出す(読み込みに失敗したときは、その説明を隠さないため)。
 * モジュールが読めない場合でも動くよう、module ではない普通のスクリプト。
 */
(function () {
  var KEY = 'bluebey.usage-notice.v1';
  var box = document.getElementById('start-notice');
  if (!box) return;
  try { if (localStorage.getItem(KEY) === 'skip') return; } catch (error) {}
  var timer = setInterval(function () {
    if (!window.__bluebeyReady) return;
    clearInterval(timer);
    box.hidden = false;
  }, 150);
  document.getElementById('start-ok').addEventListener('click', function () {
    box.hidden = true;
    try {
      if (document.getElementById('start-skip').checked) localStorage.setItem(KEY, 'skip');
    } catch (error) {}
  });
})();
</script>

<script type="module" src="src/main.js"></script>
</body>
</html>