ui-chan-mcp 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/dist/app/main.js +145 -1
- package/dist/app/preload.js +1 -0
- package/dist/renderer/bundle.js +98 -1
- package/dist/renderer/editor.js +50 -1
- package/dist/renderer/index.html +42 -2
- package/docs/CLIENTS.md +3 -1
- package/docs/TROUBLESHOOTING.md +1 -1
- package/docs/images/panel.png +0 -0
- package/package.json +1 -1
- package/ui-chan.config.json +4 -1
package/README.md
CHANGED
|
@@ -97,7 +97,7 @@ ui-chan
|
|
|
97
97
|
**繋いだら、もう終わり。** 次にそのアプリを開けば、画面の右下にわたしが出てくる。
|
|
98
98
|
あとは普通に作業して。勝手に喋るから。
|
|
99
99
|
|
|
100
|
-
###
|
|
100
|
+
### 足元のつまみ
|
|
101
101
|
|
|
102
102
|
<p align="center">
|
|
103
103
|
<img src="docs/images/panel.png" alt="接続パネル" width="360">
|
|
@@ -112,8 +112,9 @@ ui-chan
|
|
|
112
112
|
| ⬇ | 更新する(**更新があるときだけ出る**) |
|
|
113
113
|
| ⏻ | 終了する(呼ばれても起きない) |
|
|
114
114
|
|
|
115
|
-
<sub
|
|
116
|
-
|
|
115
|
+
<sub>つまみはわたしの足元(右下)にいる。押すと上に開くよ。
|
|
116
|
+
複数のセッションから繋がってるときは、そこに一覧が出る(どのツールの、どのプロジェクトか)。
|
|
117
|
+
**行を押すと、そのアプリが前に出てくる。** 更新があるときは、ダウンロードのアイコンが増える。</sub>
|
|
117
118
|
|
|
118
119
|
---
|
|
119
120
|
|
package/dist/app/main.js
CHANGED
|
@@ -370,8 +370,124 @@ function startWsServer() {
|
|
|
370
370
|
* ここを見るので、「起動し直さないと位置が戻らない」ということはない。 */
|
|
371
371
|
function homePosition() {
|
|
372
372
|
const { width, height, margin } = config.window;
|
|
373
|
+
// 実ピクセルの余白が分かっていればそれで合わせる。分かるのは PSD を描いた
|
|
374
|
+
// あとなので、起動直後の一発目だけは窓の矩形で置き、測れた時点で
|
|
375
|
+
// `ui-chan:body-box` が置き直す。リセットと吸い付きが別の場所に着地しない
|
|
376
|
+
// ように、両方ここを見る。
|
|
377
|
+
const ins = bodyInsets ?? { left: 0, top: 0, right: 0, bottom: 0 };
|
|
378
|
+
const gap = config.window.snapGap ?? 12;
|
|
373
379
|
const wa = electron_1.screen.getPrimaryDisplay().workArea;
|
|
374
|
-
|
|
380
|
+
// setPosition は整数しか受け取らない(小数を渡すと main プロセスごと落ちる)。
|
|
381
|
+
// 実ピクセルの計測は dpr 由来で小数になるので、ここで必ず丸める。
|
|
382
|
+
return {
|
|
383
|
+
x: Math.round(wa.x + wa.width - margin - gap - width + ins.right),
|
|
384
|
+
y: Math.round(wa.y + wa.height - margin - height + ins.bottom),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
/** レンダラが測った「彼女に見える範囲」の、窓の内側からの余白(CSS px)。
|
|
388
|
+
* 測れるのは PSD を描いたあとなので、それまでは null=窓の矩形で代用する。 */
|
|
389
|
+
let bodyInsets = null;
|
|
390
|
+
/**
|
|
391
|
+
* ドラッグを離したとき、近ければ画面の下の隅へ吸い付ける。
|
|
392
|
+
*
|
|
393
|
+
* **下2隅だけ**なのは、上に詰められないから。窓の上部120pxは吹き出しの居場所
|
|
394
|
+
* として常時確保されていて(`index.html` の `#bubble-area`)、彼女の頭を画面
|
|
395
|
+
* 上端に付けると、喋った瞬間に吹き出しが画面の外へ出る。置けない場所を候補に
|
|
396
|
+
* 出しても、寄せたのに何も起きないか、変な位置に落ちるかのどちらかになる。
|
|
397
|
+
*
|
|
398
|
+
* **窓の矩形ではなく彼女の実ピクセルで合わせる。** 窓は420x680だが絵はその一部
|
|
399
|
+
* で、左右に90〜110pxの透明な余白がある(→ `PsdStage.contentInsets`)。矩形で
|
|
400
|
+
* 揃えると、画面の隅に吸い付いたはずが彼女は隅から100px浮いて見える——目が見て
|
|
401
|
+
* いるのは彼女であって窓ではないので、しきい値もこの実ピクセルの隅で測る。
|
|
402
|
+
*
|
|
403
|
+
* 見る画面は主ディスプレイではなく **窓がいま乗っている画面**(ドラッグは画面を
|
|
404
|
+
* またげる)。
|
|
405
|
+
*/
|
|
406
|
+
function cornerTargets() {
|
|
407
|
+
if (!win)
|
|
408
|
+
return null;
|
|
409
|
+
const { width, height, margin } = config.window;
|
|
410
|
+
const gap = config.window.snapGap ?? 12;
|
|
411
|
+
const ins = bodyInsets ?? { left: 0, top: 0, right: 0, bottom: 0 };
|
|
412
|
+
const wa = electron_1.screen.getDisplayMatching(win.getBounds()).workArea;
|
|
413
|
+
// 「彼女の左端/右端/下端が、画面の縁から margin」になる窓の座標に直す。
|
|
414
|
+
// 整数必須(→ homePosition の同じ注記)。
|
|
415
|
+
const y = Math.round(wa.y + wa.height - margin - height + ins.bottom);
|
|
416
|
+
// 左下に着地すると立ち絵は反転する(画面の内側を向かせるため)ので、そのとき
|
|
417
|
+
// 身体の左の余白は「反転前の右の余白」になる。入れ替えないと、左に吸い付いた
|
|
418
|
+
// ときだけ画面の縁との間隔が19pxずれる。
|
|
419
|
+
return {
|
|
420
|
+
left: { x: Math.round(wa.x + margin + gap - ins.right), y },
|
|
421
|
+
right: { x: Math.round(wa.x + wa.width - margin - gap - width + ins.right), y },
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
/** 吸い付きの移動そのもの。`setPosition` を1発撃つと瞬間移動になって「寄せられ
|
|
425
|
+
* た」感が出ず、逆に macOS 任せの `animate: true` は遅くて重い。あいだを取って
|
|
426
|
+
* 自前で easeOutCubic を刻む。 */
|
|
427
|
+
let snapTimer = null;
|
|
428
|
+
function cancelSnap() {
|
|
429
|
+
if (snapTimer) {
|
|
430
|
+
clearInterval(snapTimer);
|
|
431
|
+
snapTimer = null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function glideTo(x, y) {
|
|
435
|
+
if (!win)
|
|
436
|
+
return;
|
|
437
|
+
cancelSnap();
|
|
438
|
+
const durationMs = config.window.snapDurationMs ?? 220;
|
|
439
|
+
if (durationMs <= 0) {
|
|
440
|
+
win.setPosition(x, y);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const from = win.getBounds();
|
|
444
|
+
const startedAt = Date.now();
|
|
445
|
+
snapTimer = setInterval(() => {
|
|
446
|
+
if (!win)
|
|
447
|
+
return cancelSnap();
|
|
448
|
+
const t = Math.min(1, (Date.now() - startedAt) / durationMs);
|
|
449
|
+
const e = 1 - (1 - t) ** 3;
|
|
450
|
+
win.setPosition(Math.round(from.x + (x - from.x) * e), Math.round(from.y + (y - from.y) * e));
|
|
451
|
+
if (t >= 1)
|
|
452
|
+
cancelSnap();
|
|
453
|
+
}, 16);
|
|
454
|
+
}
|
|
455
|
+
function snapToCorner() {
|
|
456
|
+
if (!win)
|
|
457
|
+
return;
|
|
458
|
+
const { snapDistance = 320 } = config.window;
|
|
459
|
+
if (snapDistance <= 0)
|
|
460
|
+
return;
|
|
461
|
+
const targets = cornerTargets();
|
|
462
|
+
if (!targets)
|
|
463
|
+
return;
|
|
464
|
+
const b = win.getBounds();
|
|
465
|
+
const best = [targets.left, targets.right]
|
|
466
|
+
.map((t) => ({ ...t, d: Math.hypot(b.x - t.x, b.y - t.y) }))
|
|
467
|
+
.sort((a, c) => a.d - c.d)[0];
|
|
468
|
+
if (best.d > snapDistance || (best.x === b.x && best.y === b.y))
|
|
469
|
+
return;
|
|
470
|
+
glideTo(best.x, best.y);
|
|
471
|
+
// 向き・パネル・吹き出しは動き終わりを待たずに切り替える。移動の途中で
|
|
472
|
+
// 反転したほうが「引き寄せられて向き直った」と読める。
|
|
473
|
+
updatePanelSide();
|
|
474
|
+
}
|
|
475
|
+
/** 彼女が画面のどちら側に居るかをレンダラへ伝える(パネルの寄せ・吹き出しの
|
|
476
|
+
* 伸びる向き・立ち絵の反転がこれで決まる)。吸い付いていない位置でも破綻
|
|
477
|
+
* しないよう、判定は「作業領域の左右どちら寄りに身体があるか」で行う。 */
|
|
478
|
+
let panelSide = null;
|
|
479
|
+
function updatePanelSide() {
|
|
480
|
+
if (!win)
|
|
481
|
+
return;
|
|
482
|
+
const b = win.getBounds();
|
|
483
|
+
const wa = electron_1.screen.getDisplayMatching(b).workArea;
|
|
484
|
+
const ins = bodyInsets ?? { left: 0, top: 0, right: 0, bottom: 0 };
|
|
485
|
+
const bodyCenter = b.x + ins.left + (b.width - ins.left - ins.right) / 2;
|
|
486
|
+
const side = bodyCenter < wa.x + wa.width / 2 ? 'left' : 'right';
|
|
487
|
+
if (side === panelSide)
|
|
488
|
+
return;
|
|
489
|
+
panelSide = side;
|
|
490
|
+
sendToRenderer({ type: 'side', side });
|
|
375
491
|
}
|
|
376
492
|
/**
|
|
377
493
|
* セッション行を押されたとき、その相手のアプリを前面に出す。
|
|
@@ -589,10 +705,13 @@ else {
|
|
|
589
705
|
// fidget input). While the button is held over her body, the window follows
|
|
590
706
|
// the cursor at a fixed grab offset.
|
|
591
707
|
let dragTimer = null;
|
|
708
|
+
let dragOrigin = null;
|
|
592
709
|
electron_1.ipcMain.on('ui-chan:drag-start', () => {
|
|
593
710
|
if (!win)
|
|
594
711
|
return;
|
|
712
|
+
cancelSnap(); // 寄っている途中で掴まれたら、そちらが優先
|
|
595
713
|
const start = electron_1.screen.getCursorScreenPoint();
|
|
714
|
+
dragOrigin = start;
|
|
596
715
|
const [wx, wy] = win.getPosition();
|
|
597
716
|
const offX = start.x - wx;
|
|
598
717
|
const offY = start.y - wy;
|
|
@@ -610,6 +729,31 @@ else {
|
|
|
610
729
|
clearInterval(dragTimer);
|
|
611
730
|
dragTimer = null;
|
|
612
731
|
}
|
|
732
|
+
// drag-start は押した時点で飛んでくるので、つつかれただけ(=動かして
|
|
733
|
+
// いない)のときにまで吸い付くと、触っただけで窓が跳ぶ。実際に動いた
|
|
734
|
+
// ときだけ整える。
|
|
735
|
+
const p = electron_1.screen.getCursorScreenPoint();
|
|
736
|
+
const moved = dragOrigin && Math.hypot(p.x - dragOrigin.x, p.y - dragOrigin.y) > 4;
|
|
737
|
+
dragOrigin = null;
|
|
738
|
+
if (moved)
|
|
739
|
+
snapToCorner();
|
|
740
|
+
});
|
|
741
|
+
electron_1.ipcMain.on('ui-chan:body-box', (_ev, insets) => {
|
|
742
|
+
const wasHome = (() => {
|
|
743
|
+
if (!win)
|
|
744
|
+
return false;
|
|
745
|
+
const h = homePosition(); // まだ bodyInsets 未設定なので「矩形で置いた定位置」
|
|
746
|
+
const b = win.getBounds();
|
|
747
|
+
return b.x === h.x && b.y === h.y;
|
|
748
|
+
})();
|
|
749
|
+
bodyInsets = insets;
|
|
750
|
+
// 起動位置から動かされていなければ、測れた値で置き直す。動かされていたら
|
|
751
|
+
// 触らない——ユーザーが置いた場所を、計測の都合で奪わない。
|
|
752
|
+
if (wasHome && win) {
|
|
753
|
+
const h = homePosition();
|
|
754
|
+
win.setPosition(h.x, h.y);
|
|
755
|
+
}
|
|
756
|
+
updatePanelSide();
|
|
613
757
|
});
|
|
614
758
|
electron_1.ipcMain.on('ui-chan:warnings', (_ev, warnings) => {
|
|
615
759
|
// The renderer reports its full warning set on every applyDirectives()
|
package/dist/app/preload.js
CHANGED
|
@@ -12,4 +12,5 @@ electron_1.contextBridge.exposeInMainWorld('uiChan', {
|
|
|
12
12
|
setClickThrough: (on) => electron_1.ipcRenderer.send('ui-chan:click-through', on),
|
|
13
13
|
dragStart: () => electron_1.ipcRenderer.send('ui-chan:drag-start'),
|
|
14
14
|
dragEnd: () => electron_1.ipcRenderer.send('ui-chan:drag-end'),
|
|
15
|
+
reportBodyBox: (insets) => electron_1.ipcRenderer.send('ui-chan:body-box', insets),
|
|
15
16
|
});
|
package/dist/renderer/bundle.js
CHANGED
|
@@ -17491,12 +17491,17 @@
|
|
|
17491
17491
|
/** Alpha (0–255) of the composited image at a client (viewport) point, for
|
|
17492
17492
|
* hit-testing "is the pointer actually over her body" vs the transparent
|
|
17493
17493
|
* margins. Maps client → canvas pixel coords via the element's box. */
|
|
17494
|
+
/** 立ち絵を左右反転して描いているか。反転は CSS(`#canvas` の scaleX(-1))で
|
|
17495
|
+
* かけるが、`getBoundingClientRect()` は変換後も同じ箱を返すので、当たり判定
|
|
17496
|
+
* だけは自分で座標を折り返さないと左右が入れ替わったまま当たる。 */
|
|
17497
|
+
mirrored = false;
|
|
17494
17498
|
alphaAt(clientX, clientY) {
|
|
17495
17499
|
const rect = this.canvas.getBoundingClientRect();
|
|
17496
17500
|
if (rect.width === 0 || rect.height === 0 || clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) {
|
|
17497
17501
|
return 0;
|
|
17498
17502
|
}
|
|
17499
|
-
|
|
17503
|
+
let px = Math.floor((clientX - rect.left) / rect.width * this.canvas.width);
|
|
17504
|
+
if (this.mirrored) px = this.canvas.width - 1 - px;
|
|
17500
17505
|
const py = Math.floor((clientY - rect.top) / rect.height * this.canvas.height);
|
|
17501
17506
|
try {
|
|
17502
17507
|
return this.ctx.getImageData(px, py, 1, 1).data[3];
|
|
@@ -17504,6 +17509,50 @@
|
|
|
17504
17509
|
return 0;
|
|
17505
17510
|
}
|
|
17506
17511
|
}
|
|
17512
|
+
/**
|
|
17513
|
+
* いま描かれている絵の**不透明な部分**が、canvas 要素の箱の中でどこにあるかを
|
|
17514
|
+
* CSS px の内側余白(inset)で返す。読めなければ null。
|
|
17515
|
+
*
|
|
17516
|
+
* これが要る理由:窓(420x680)と「彼女に見える範囲」は全然違う。PSD は
|
|
17517
|
+
* 3541x5016 あるが、実際に絵があるのはその中央あたりだけで、左右に 785px /
|
|
17518
|
+
* 954px の余白がある(万歳など腕を広げるポーズのために取ってある幅)。
|
|
17519
|
+
* 表示スケールに直すと片側 90〜110px。窓の矩形を画面の隅に合わせても、
|
|
17520
|
+
* 彼女は隅から100px浮いて見える——「吸い付いた」と読めない距離。
|
|
17521
|
+
*/
|
|
17522
|
+
contentInsets(alphaThreshold = 8) {
|
|
17523
|
+
const { canvas } = this;
|
|
17524
|
+
if (canvas.width === 0 || canvas.height === 0) return null;
|
|
17525
|
+
let data;
|
|
17526
|
+
try {
|
|
17527
|
+
data = this.ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
17528
|
+
} catch {
|
|
17529
|
+
return null;
|
|
17530
|
+
}
|
|
17531
|
+
let minX = canvas.width;
|
|
17532
|
+
let minY = canvas.height;
|
|
17533
|
+
let maxX = -1;
|
|
17534
|
+
let maxY = -1;
|
|
17535
|
+
for (let y = 0; y < canvas.height; y++) {
|
|
17536
|
+
const row = y * canvas.width * 4;
|
|
17537
|
+
for (let x = 0; x < canvas.width; x++) {
|
|
17538
|
+
if (data[row + x * 4 + 3] < alphaThreshold) continue;
|
|
17539
|
+
if (x < minX) minX = x;
|
|
17540
|
+
if (x > maxX) maxX = x;
|
|
17541
|
+
if (y < minY) minY = y;
|
|
17542
|
+
maxY = y;
|
|
17543
|
+
}
|
|
17544
|
+
}
|
|
17545
|
+
if (maxX < 0) return null;
|
|
17546
|
+
const rect = canvas.getBoundingClientRect();
|
|
17547
|
+
const sx = rect.width / canvas.width;
|
|
17548
|
+
const sy = rect.height / canvas.height;
|
|
17549
|
+
return {
|
|
17550
|
+
left: minX * sx,
|
|
17551
|
+
top: minY * sy,
|
|
17552
|
+
right: (canvas.width - 1 - maxX) * sx,
|
|
17553
|
+
bottom: (canvas.height - 1 - maxY) * sy
|
|
17554
|
+
};
|
|
17555
|
+
}
|
|
17507
17556
|
// ---- temporary-overlay visibility snapshot/restore (used by blink) ----
|
|
17508
17557
|
// Snapshot the current visibility of every node, draw something temporary
|
|
17509
17558
|
// (closed eyes) on top, then restore the snapshot.
|
|
@@ -17541,7 +17590,49 @@
|
|
|
17541
17590
|
function reportWarnings() {
|
|
17542
17591
|
window.uiChan.reportWarnings(stage.getWarnings());
|
|
17543
17592
|
}
|
|
17593
|
+
var bodyBoxSent = false;
|
|
17594
|
+
var rawInsets = null;
|
|
17595
|
+
var bodySide = "right";
|
|
17596
|
+
var stageEl = document.getElementById("stage");
|
|
17597
|
+
function applySide() {
|
|
17598
|
+
stageEl.classList.toggle("side-left", bodySide === "left");
|
|
17599
|
+
stageEl.classList.toggle("side-right", bodySide === "right");
|
|
17600
|
+
stage.mirrored = bodySide === "left";
|
|
17601
|
+
if (!rawInsets) return;
|
|
17602
|
+
const left = bodySide === "left" ? rawInsets.right : rawInsets.left;
|
|
17603
|
+
const right = bodySide === "left" ? rawInsets.left : rawInsets.right;
|
|
17604
|
+
stageEl.style.setProperty("--body-left", `${left}px`);
|
|
17605
|
+
stageEl.style.setProperty("--body-right", `${right}px`);
|
|
17606
|
+
positionTail();
|
|
17607
|
+
}
|
|
17608
|
+
function positionTail() {
|
|
17609
|
+
if (!rawInsets) return;
|
|
17610
|
+
const left = bodySide === "left" ? rawInsets.right : rawInsets.left;
|
|
17611
|
+
const right = bodySide === "left" ? rawInsets.left : rawInsets.right;
|
|
17612
|
+
const headX = left + (window.innerWidth - left - right) / 2;
|
|
17613
|
+
const r = bubble.getBoundingClientRect();
|
|
17614
|
+
if (r.width === 0) return;
|
|
17615
|
+
const x = Math.min(Math.max(headX - r.left, 16), r.width - 16);
|
|
17616
|
+
bubble.style.setProperty("--tail-x", `${x}px`);
|
|
17617
|
+
}
|
|
17618
|
+
function reportBodyBoxOnce() {
|
|
17619
|
+
if (bodyBoxSent || !stage.loaded) return;
|
|
17620
|
+
const box = stage.contentInsets();
|
|
17621
|
+
if (!box) return;
|
|
17622
|
+
const rect = canvas.getBoundingClientRect();
|
|
17623
|
+
const insets = {
|
|
17624
|
+
left: rect.left + box.left,
|
|
17625
|
+
top: rect.top + box.top,
|
|
17626
|
+
right: window.innerWidth - rect.right + box.right,
|
|
17627
|
+
bottom: window.innerHeight - rect.bottom + box.bottom
|
|
17628
|
+
};
|
|
17629
|
+
bodyBoxSent = true;
|
|
17630
|
+
rawInsets = insets;
|
|
17631
|
+
applySide();
|
|
17632
|
+
window.uiChan.reportBodyBox(insets);
|
|
17633
|
+
}
|
|
17544
17634
|
var overlay = document.createElement("canvas");
|
|
17635
|
+
overlay.id = "cue-overlay";
|
|
17545
17636
|
overlay.style.cssText = "position:absolute;inset:0;width:100%;height:100%;pointer-events:none;opacity:0";
|
|
17546
17637
|
var overlayCtx = overlay.getContext("2d");
|
|
17547
17638
|
canvas.parentElement?.insertBefore(overlay, canvas.nextSibling);
|
|
@@ -17779,6 +17870,7 @@
|
|
|
17779
17870
|
if (text !== null) {
|
|
17780
17871
|
bubble.textContent = bubbleText(text);
|
|
17781
17872
|
bubble.classList.add("visible");
|
|
17873
|
+
positionTail();
|
|
17782
17874
|
} else {
|
|
17783
17875
|
bubble.classList.remove("visible");
|
|
17784
17876
|
hideTextTimer = window.setTimeout(() => {
|
|
@@ -17952,6 +18044,11 @@
|
|
|
17952
18044
|
reportWarnings();
|
|
17953
18045
|
lipCurrentMouth = null;
|
|
17954
18046
|
draw();
|
|
18047
|
+
reportBodyBoxOnce();
|
|
18048
|
+
} else if (cmd.type === "side") {
|
|
18049
|
+
bodySide = cmd.side;
|
|
18050
|
+
panelEl.classList.toggle("left", cmd.side === "left");
|
|
18051
|
+
applySide();
|
|
17955
18052
|
} else if (cmd.type === "backdrop") {
|
|
17956
18053
|
const PRESETS = {
|
|
17957
18054
|
white: "#ffffff",
|
package/dist/renderer/editor.js
CHANGED
|
@@ -17491,12 +17491,17 @@
|
|
|
17491
17491
|
/** Alpha (0–255) of the composited image at a client (viewport) point, for
|
|
17492
17492
|
* hit-testing "is the pointer actually over her body" vs the transparent
|
|
17493
17493
|
* margins. Maps client → canvas pixel coords via the element's box. */
|
|
17494
|
+
/** 立ち絵を左右反転して描いているか。反転は CSS(`#canvas` の scaleX(-1))で
|
|
17495
|
+
* かけるが、`getBoundingClientRect()` は変換後も同じ箱を返すので、当たり判定
|
|
17496
|
+
* だけは自分で座標を折り返さないと左右が入れ替わったまま当たる。 */
|
|
17497
|
+
mirrored = false;
|
|
17494
17498
|
alphaAt(clientX, clientY) {
|
|
17495
17499
|
const rect = this.canvas.getBoundingClientRect();
|
|
17496
17500
|
if (rect.width === 0 || rect.height === 0 || clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) {
|
|
17497
17501
|
return 0;
|
|
17498
17502
|
}
|
|
17499
|
-
|
|
17503
|
+
let px = Math.floor((clientX - rect.left) / rect.width * this.canvas.width);
|
|
17504
|
+
if (this.mirrored) px = this.canvas.width - 1 - px;
|
|
17500
17505
|
const py = Math.floor((clientY - rect.top) / rect.height * this.canvas.height);
|
|
17501
17506
|
try {
|
|
17502
17507
|
return this.ctx.getImageData(px, py, 1, 1).data[3];
|
|
@@ -17504,6 +17509,50 @@
|
|
|
17504
17509
|
return 0;
|
|
17505
17510
|
}
|
|
17506
17511
|
}
|
|
17512
|
+
/**
|
|
17513
|
+
* いま描かれている絵の**不透明な部分**が、canvas 要素の箱の中でどこにあるかを
|
|
17514
|
+
* CSS px の内側余白(inset)で返す。読めなければ null。
|
|
17515
|
+
*
|
|
17516
|
+
* これが要る理由:窓(420x680)と「彼女に見える範囲」は全然違う。PSD は
|
|
17517
|
+
* 3541x5016 あるが、実際に絵があるのはその中央あたりだけで、左右に 785px /
|
|
17518
|
+
* 954px の余白がある(万歳など腕を広げるポーズのために取ってある幅)。
|
|
17519
|
+
* 表示スケールに直すと片側 90〜110px。窓の矩形を画面の隅に合わせても、
|
|
17520
|
+
* 彼女は隅から100px浮いて見える——「吸い付いた」と読めない距離。
|
|
17521
|
+
*/
|
|
17522
|
+
contentInsets(alphaThreshold = 8) {
|
|
17523
|
+
const { canvas } = this;
|
|
17524
|
+
if (canvas.width === 0 || canvas.height === 0) return null;
|
|
17525
|
+
let data;
|
|
17526
|
+
try {
|
|
17527
|
+
data = this.ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
17528
|
+
} catch {
|
|
17529
|
+
return null;
|
|
17530
|
+
}
|
|
17531
|
+
let minX = canvas.width;
|
|
17532
|
+
let minY = canvas.height;
|
|
17533
|
+
let maxX = -1;
|
|
17534
|
+
let maxY = -1;
|
|
17535
|
+
for (let y = 0; y < canvas.height; y++) {
|
|
17536
|
+
const row = y * canvas.width * 4;
|
|
17537
|
+
for (let x = 0; x < canvas.width; x++) {
|
|
17538
|
+
if (data[row + x * 4 + 3] < alphaThreshold) continue;
|
|
17539
|
+
if (x < minX) minX = x;
|
|
17540
|
+
if (x > maxX) maxX = x;
|
|
17541
|
+
if (y < minY) minY = y;
|
|
17542
|
+
maxY = y;
|
|
17543
|
+
}
|
|
17544
|
+
}
|
|
17545
|
+
if (maxX < 0) return null;
|
|
17546
|
+
const rect = canvas.getBoundingClientRect();
|
|
17547
|
+
const sx = rect.width / canvas.width;
|
|
17548
|
+
const sy = rect.height / canvas.height;
|
|
17549
|
+
return {
|
|
17550
|
+
left: minX * sx,
|
|
17551
|
+
top: minY * sy,
|
|
17552
|
+
right: (canvas.width - 1 - maxX) * sx,
|
|
17553
|
+
bottom: (canvas.height - 1 - maxY) * sy
|
|
17554
|
+
};
|
|
17555
|
+
}
|
|
17507
17556
|
// ---- temporary-overlay visibility snapshot/restore (used by blink) ----
|
|
17508
17557
|
// Snapshot the current visibility of every node, draw something temporary
|
|
17509
17558
|
// (closed eyes) on top, then restore the snapshot.
|
package/dist/renderer/index.html
CHANGED
|
@@ -29,12 +29,26 @@
|
|
|
29
29
|
align-items: flex-end;
|
|
30
30
|
justify-content: center;
|
|
31
31
|
padding: 8px 10px 0;
|
|
32
|
+
/* 左右の余白は「彼女の実ピクセルの外側」ぶん。隅に吸い付くと窓はその
|
|
33
|
+
ぶん画面外へはみ出しているので、ここを空けておかないと吹き出しが
|
|
34
|
+
画面の外へ伸びて切れる。--body-* は renderer.ts が計測して入れる。 */
|
|
32
35
|
box-sizing: border-box;
|
|
33
36
|
/* キャラクターの頭上(PSDの傘用余白ぶん)まで吹き出しを下げる */
|
|
34
37
|
transform: translateY(46px);
|
|
35
38
|
z-index: 1;
|
|
36
39
|
position: relative;
|
|
37
40
|
}
|
|
41
|
+
/* 右下に居るなら左へ、左下に居るなら右へ——画面の内側へ向かって伸ばす。
|
|
42
|
+
伸びる向きと反対側の端を彼女の身体の縁に合わせるので、長い行でも
|
|
43
|
+
画面からはみ出さない。 */
|
|
44
|
+
#stage.side-right #bubble-area {
|
|
45
|
+
justify-content: flex-end;
|
|
46
|
+
padding-right: calc(10px + var(--body-right, 0px));
|
|
47
|
+
}
|
|
48
|
+
#stage.side-left #bubble-area {
|
|
49
|
+
justify-content: flex-start;
|
|
50
|
+
padding-left: calc(10px + var(--body-left, 0px));
|
|
51
|
+
}
|
|
38
52
|
#bubble {
|
|
39
53
|
position: relative;
|
|
40
54
|
max-width: 100%;
|
|
@@ -59,11 +73,14 @@
|
|
|
59
73
|
opacity: 1;
|
|
60
74
|
transform: translateY(0);
|
|
61
75
|
}
|
|
76
|
+
/* しっぽは吹き出しの中央ではなく**彼女の頭の真上**を指す。吹き出しの箱は
|
|
77
|
+
画面の内側へ寄るので、中央固定だと指す先が彼女から外れる。位置は
|
|
78
|
+
renderer.ts が測って --tail-x に入れる(既定は従来どおり中央)。 */
|
|
62
79
|
#bubble::after {
|
|
63
80
|
content: '';
|
|
64
81
|
position: absolute;
|
|
65
82
|
bottom: -9px;
|
|
66
|
-
left: 50
|
|
83
|
+
left: var(--tail-x, 50%);
|
|
67
84
|
margin-left: -7px;
|
|
68
85
|
border-width: 9px 7px 0;
|
|
69
86
|
border-style: solid;
|
|
@@ -82,6 +99,14 @@
|
|
|
82
99
|
width: 100%;
|
|
83
100
|
height: 100%;
|
|
84
101
|
}
|
|
102
|
+
/* 左下に居るときは、画面の内側(右)を向かせる。当たり判定は CSS の変換を
|
|
103
|
+
見ていないので、PsdStage.mirrored 側でも折り返している。 */
|
|
104
|
+
/* #cue-overlay は renderer.ts が差し込むクロスフェード用のキャンバス。
|
|
105
|
+
本体と同じ変換をかけないと、切り替えの一瞬だけ反転前の絵が見える。 */
|
|
106
|
+
#stage.side-left #canvas,
|
|
107
|
+
#stage.side-left #cue-overlay {
|
|
108
|
+
transform: scaleX(-1);
|
|
109
|
+
}
|
|
85
110
|
#placeholder {
|
|
86
111
|
display: none;
|
|
87
112
|
position: absolute;
|
|
@@ -116,7 +141,12 @@
|
|
|
116
141
|
#panel {
|
|
117
142
|
position: absolute;
|
|
118
143
|
bottom: 6px;
|
|
119
|
-
|
|
144
|
+
/* 窓の縁ではなく**彼女の実ピクセルの縁**を基準にする。隅への吸い付きは
|
|
145
|
+
彼女を画面の縁に合わせるので、窓のほうは画面外に100px以上はみ出す
|
|
146
|
+
(PSDの左右の透明帯ぶん)。窓の右端に貼り付けたままだと、パネルは
|
|
147
|
+
その一緒に画面の外へ出て押せなくなる。余白は renderer.ts が
|
|
148
|
+
計測して --body-right / --body-left に入れる。 */
|
|
149
|
+
right: calc(6px + var(--body-right, 0px));
|
|
120
150
|
z-index: 5;
|
|
121
151
|
display: flex;
|
|
122
152
|
flex-direction: column-reverse;
|
|
@@ -130,6 +160,13 @@
|
|
|
130
160
|
いる本体だけ。 */
|
|
131
161
|
pointer-events: none;
|
|
132
162
|
}
|
|
163
|
+
/* 彼女が画面の左下に居るときは、パネルも左へ。彼女の立ち位置と同じ側の縁に
|
|
164
|
+
沿わせるので、開いたパネルがデスクトップの中央側へ張り出さない。 */
|
|
165
|
+
#panel.left {
|
|
166
|
+
right: auto;
|
|
167
|
+
left: calc(6px + var(--body-left, 0px));
|
|
168
|
+
align-items: flex-start;
|
|
169
|
+
}
|
|
133
170
|
#panel-tab {
|
|
134
171
|
pointer-events: auto;
|
|
135
172
|
-webkit-app-region: no-drag;
|
|
@@ -196,6 +233,9 @@
|
|
|
196
233
|
transform: translateY(0) scale(1);
|
|
197
234
|
pointer-events: auto;
|
|
198
235
|
}
|
|
236
|
+
#panel.left #panel-body {
|
|
237
|
+
transform-origin: bottom left;
|
|
238
|
+
}
|
|
199
239
|
.panel-row {
|
|
200
240
|
display: flex;
|
|
201
241
|
align-items: center;
|
package/docs/CLIENTS.md
CHANGED
|
@@ -51,7 +51,9 @@ ui-chan update # 最新版にする(--check で確認だけ)
|
|
|
51
51
|
**登録した時点で完了です。** アプリと VoiSona Talk はセッション開始時に自動起動し、人格は
|
|
52
52
|
MCP のハンドシェイク(`instructions`)に乗って渡ります。人格ファイルを貼り付ける作業はありません。
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
マスコット足元(右下)のつまみを開くと、接続中のセッション(クライアント名とプロジェクト名)と
|
|
55
|
+
操作が上に向かって開きます。セッションの行をクリックすると、そのクライアントのアプリが前面に出ます
|
|
56
|
+
(ターミナルの場合はターミナル本体まで。タブの選択まではしません)。
|
|
55
57
|
|
|
56
58
|
---
|
|
57
59
|
|
package/docs/TROUBLESHOOTING.md
CHANGED
|
@@ -62,7 +62,7 @@ npm で入れたなら要らないよ。ビルド済みのものが届くから
|
|
|
62
62
|
<details>
|
|
63
63
|
<summary><b>マスコットを終了させたい</b></summary>
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
マスコット足元(右下)のつまみを開いて、**電源のアイコン ⏻** を押します。これが最短です。
|
|
66
66
|
**エージェントがツールを呼んでも起き直しません**(次にセッションを開くか、
|
|
67
67
|
`ui-chan start` で起動するまで停止したままです)。
|
|
68
68
|
|
package/docs/images/panel.png
CHANGED
|
Binary file
|
package/package.json
CHANGED