@luna-editor/engine 0.5.0 → 0.5.2

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.
@@ -1,6 +1,11 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
- export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCharacters, }) => {
2
+ import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react";
3
+ /**
4
+ * ゲームスクリーンコンポーネント
5
+ * React.memo でラップして、props が変わらない限り再レンダリングを防ぐ
6
+ * これにより displayText の更新による不要な再描画を防止
7
+ */
8
+ export const GameScreen = memo(function GameScreen({ scenario, currentBlock, previousBlock, displayedCharacters, inactiveCharacterBrightness = 0.8, characterSpacing = 0.2, }) {
4
9
  var _a;
5
10
  // キャラクターごとのフェード状態を管理
6
11
  const [fadeStates, setFadeStates] = useState(new Map());
@@ -8,6 +13,142 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
8
13
  const fadeStartTimeRef = useRef(new Map());
9
14
  // 完了したフェードを追跡(同じpendingFadeで再度フェードしないため)
10
15
  const completedFadesRef = useRef(new Set());
16
+ // 登場・退場フェード管理
17
+ const ENTRANCE_FADE_DURATION = 300; // ms
18
+ const [entranceFades, setEntranceFades] = useState(new Map()); // objectId -> opacity (0-1)
19
+ const [exitingCharacters, setExitingCharacters] = useState([]);
20
+ const prevDisplayedCharIdsRef = useRef(new Set());
21
+ const entranceFadeStartRef = useRef(new Map());
22
+ const entranceAnimFrameRef = useRef(null);
23
+ const prevDisplayedCharsRef = useRef([]);
24
+ const pendingEntranceRef = useRef([]);
25
+ // 退場キャラの最終位置を保存(auto-layout再計算に依存しないため)
26
+ const exitPositionsRef = useRef(new Map());
27
+ // 登場・退場の検出
28
+ useEffect(() => {
29
+ var _a, _b;
30
+ const currentIds = new Set(displayedCharacters.map((c) => c.objectId));
31
+ const prevIds = prevDisplayedCharIdsRef.current;
32
+ const prevChars = prevDisplayedCharsRef.current;
33
+ // 初回レンダリング(前回データなし)はスキップ
34
+ if (prevIds.size === 0 && prevChars.length === 0) {
35
+ prevDisplayedCharIdsRef.current = currentIds;
36
+ prevDisplayedCharsRef.current = displayedCharacters;
37
+ return;
38
+ }
39
+ // キャラ構成が変わったか判定(IDセットまたはentityStateIdの変化)
40
+ const hasChange = currentIds.size !== prevIds.size ||
41
+ [...currentIds].some((id) => !prevIds.has(id)) ||
42
+ [...prevIds].some((id) => !currentIds.has(id));
43
+ if (!hasChange) {
44
+ prevDisplayedCharsRef.current = displayedCharacters;
45
+ return;
46
+ }
47
+ // キャラ構成が変わった場合: 前回の全キャラを退場、現在の全キャラを登場
48
+ // これにより同じキャラが含まれていても必ずフェードイン/アウトが発生
49
+ const allExitChars = [...prevChars];
50
+ const allEnterIds = [...currentIds];
51
+ // 退場フェードアウト開始
52
+ if (allExitChars.length > 0) {
53
+ const now = performance.now();
54
+ // 退場キャラの現在の位置を保存
55
+ for (const char of allExitChars) {
56
+ if (char.positionX !== null && char.positionY !== null) {
57
+ exitPositionsRef.current.set(char.objectId, {
58
+ x: (_a = char.positionX) !== null && _a !== void 0 ? _a : 0,
59
+ y: (_b = char.positionY) !== null && _b !== void 0 ? _b : 0,
60
+ });
61
+ }
62
+ else {
63
+ const autoPositions = calculateAutoLayout(prevChars, characterSpacing !== null && characterSpacing !== void 0 ? characterSpacing : 0.2);
64
+ const pos = autoPositions.get(char.objectId);
65
+ if (pos) {
66
+ exitPositionsRef.current.set(char.objectId, pos);
67
+ }
68
+ }
69
+ }
70
+ setExitingCharacters((prev) => [...prev, ...allExitChars]);
71
+ const newFades = new Map(entranceFades);
72
+ for (const char of allExitChars) {
73
+ newFades.set(char.objectId, 1);
74
+ entranceFadeStartRef.current.set(char.objectId, now);
75
+ }
76
+ setEntranceFades(newFades);
77
+ }
78
+ // 登場フェードイン開始(退場完了を待ってから)
79
+ if (allEnterIds.length > 0) {
80
+ const delay = allExitChars.length > 0 ? ENTRANCE_FADE_DURATION + 300 : 0;
81
+ pendingEntranceRef.current = allEnterIds;
82
+ setTimeout(() => {
83
+ const ids = pendingEntranceRef.current;
84
+ if (ids.length === 0)
85
+ return;
86
+ pendingEntranceRef.current = [];
87
+ const now = performance.now();
88
+ setEntranceFades((prev) => {
89
+ const newFades = new Map(prev);
90
+ for (const id of ids) {
91
+ newFades.set(id, 0);
92
+ entranceFadeStartRef.current.set(id, now);
93
+ }
94
+ return newFades;
95
+ });
96
+ }, delay);
97
+ }
98
+ prevDisplayedCharIdsRef.current = currentIds;
99
+ prevDisplayedCharsRef.current = displayedCharacters;
100
+ }, [displayedCharacters]); // eslint-disable-line react-hooks/exhaustive-deps
101
+ // 登場・退場フェードのアニメーションループ
102
+ useEffect(() => {
103
+ if (entranceFades.size === 0)
104
+ return;
105
+ const exitingIds = new Set(exitingCharacters.map((c) => c.objectId));
106
+ const animate = () => {
107
+ const now = performance.now();
108
+ let hasActive = false;
109
+ const newFades = new Map();
110
+ const completedExitIds = [];
111
+ for (const [objectId] of entranceFades) {
112
+ const startTime = entranceFadeStartRef.current.get(objectId);
113
+ if (!startTime)
114
+ continue;
115
+ const elapsed = now - startTime;
116
+ const progress = Math.min(elapsed / ENTRANCE_FADE_DURATION, 1);
117
+ const isExiting = exitingIds.has(objectId);
118
+ // 退場: 1→0, 登場: 0→1
119
+ const opacity = isExiting ? 1 - progress : progress;
120
+ newFades.set(objectId, opacity);
121
+ if (progress < 1) {
122
+ hasActive = true;
123
+ }
124
+ else if (isExiting) {
125
+ completedExitIds.push(objectId);
126
+ }
127
+ }
128
+ setEntranceFades(newFades);
129
+ // 退場フェード完了したキャラを削除
130
+ if (completedExitIds.length > 0) {
131
+ setExitingCharacters((prev) => prev.filter((c) => !completedExitIds.includes(c.objectId)));
132
+ // 保存位置もクリーンアップ
133
+ for (const id of completedExitIds) {
134
+ exitPositionsRef.current.delete(id);
135
+ }
136
+ }
137
+ if (hasActive) {
138
+ entranceAnimFrameRef.current = requestAnimationFrame(animate);
139
+ }
140
+ else {
141
+ setEntranceFades(new Map());
142
+ entranceFadeStartRef.current.clear();
143
+ }
144
+ };
145
+ entranceAnimFrameRef.current = requestAnimationFrame(animate);
146
+ return () => {
147
+ if (entranceAnimFrameRef.current) {
148
+ cancelAnimationFrame(entranceAnimFrameRef.current);
149
+ }
150
+ };
151
+ }, [entranceFades.size > 0, exitingCharacters]); // eslint-disable-line react-hooks/exhaustive-deps
11
152
  // コンテナサイズを取得(ResizeObserverで確実に取得)
12
153
  const containerRef = useRef(null);
13
154
  const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
@@ -54,7 +195,7 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
54
195
  // character_entranceブロックのキャラクター画像(レイヤー情報含む)
55
196
  if (block.characters) {
56
197
  block.characters.forEach((char) => {
57
- var _a, _b, _c, _d, _e, _f;
198
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
58
199
  if (char.entityState.imageUrl || ((_a = char.baseBodyState) === null || _a === void 0 ? void 0 : _a.imageUrl)) {
59
200
  const key = `${char.objectId}-${char.entityStateId}`;
60
201
  images.set(key, {
@@ -70,6 +211,10 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
70
211
  baseBodyTranslateX: (_e = char.baseBodyState) === null || _e === void 0 ? void 0 : _e.translateX,
71
212
  baseBodyTranslateY: (_f = char.baseBodyState) === null || _f === void 0 ? void 0 : _f.translateY,
72
213
  layers: char.entityState.layers,
214
+ // EntityStateのcrop値を優先、なければScenarioBlockCharacterのcrop値
215
+ cropLeft: (_g = char.entityState.cropLeft) !== null && _g !== void 0 ? _g : char.cropLeft,
216
+ cropRight: (_h = char.entityState.cropRight) !== null && _h !== void 0 ? _h : char.cropRight,
217
+ cropFade: (_j = char.entityState.cropFade) !== null && _j !== void 0 ? _j : char.cropFade,
73
218
  });
74
219
  }
75
220
  });
@@ -222,9 +367,31 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
222
367
  const currentSpeaker = (currentBlock === null || currentBlock === void 0 ? void 0 : currentBlock.speakerId)
223
368
  ? displayedCharacters.find((char) => char.objectId === currentBlock.speakerId)
224
369
  : null;
370
+ // 簡易モード用の自動配置計算
371
+ const calculateAutoLayout = (characters, spacing) => {
372
+ const total = characters.length;
373
+ const positions = new Map();
374
+ if (total === 0)
375
+ return positions;
376
+ if (total === 1) {
377
+ // 1人の場合は中央
378
+ positions.set(characters[0].objectId, { x: 0, y: 0 });
379
+ return positions;
380
+ }
381
+ // 複数の場合は均等配置
382
+ // spacing: 画面幅に対する割合(例: 0.1 = 10%)
383
+ // 座標系: -1(左端)〜 1(右端)
384
+ const totalWidth = (total - 1) * spacing * 2;
385
+ const startX = -totalWidth / 2;
386
+ characters.forEach((char, index) => {
387
+ const x = startX + index * spacing * 2;
388
+ positions.set(char.objectId, { x, y: 0 });
389
+ });
390
+ return positions;
391
+ };
225
392
  // キャラクター描画用のヘルパー関数
226
393
  const renderCharacter = (image, displayedChar, isCurrentSpeaker, keyPrefix) => {
227
- var _a, _b, _c, _d, _e, _f, _g, _h;
394
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
228
395
  // フェード状態を確認
229
396
  const fadeState = fadeStates.get(image.objectId);
230
397
  const isFadingIn = fadeState && fadeState.previousEntityStateId !== image.entityStateId;
@@ -239,8 +406,14 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
239
406
  (pendingFade === null || pendingFade === void 0 ? void 0 : pendingFade.objectId) === image.objectId &&
240
407
  pendingFade.previousEntityStateId === image.entityStateId &&
241
408
  !completedFadesRef.current.has(pendingFade.fadeKey);
409
+ // 退場中のキャラクターか判定
410
+ const isExitingChar = exitingCharacters.some((c) => c.objectId === image.objectId);
242
411
  // 表示すべきかどうか
243
- const shouldDisplay = displayedChar || isCurrentSpeaker || isFadingOut || isPendingFadeOut;
412
+ const shouldDisplay = displayedChar ||
413
+ isCurrentSpeaker ||
414
+ isFadingOut ||
415
+ isPendingFadeOut ||
416
+ isExitingChar;
244
417
  // opacityを決定
245
418
  let opacity = 1;
246
419
  if (isFadingIn || isPendingFadeIn) {
@@ -251,13 +424,22 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
251
424
  // フェードアウト中
252
425
  opacity = fadeState ? 1 - fadeState.fadeProgress : 1;
253
426
  }
427
+ // 登場・退場フェード(character_entrance/exit時)
428
+ const entranceFadeOpacity = entranceFades.get(image.objectId);
429
+ if (entranceFadeOpacity !== undefined) {
430
+ opacity = entranceFadeOpacity;
431
+ }
432
+ // 登場待ち(退場フェード完了待ち)のキャラは非表示
433
+ if (pendingEntranceRef.current.includes(image.objectId)) {
434
+ opacity = 0;
435
+ }
254
436
  // 明るさを決定
255
437
  let brightness = 1;
256
438
  if (displayedCharacters.length > 0 && displayedChar) {
257
439
  // 複数キャラクター表示で、現在の話者でない場合
258
440
  if (currentSpeaker &&
259
441
  currentSpeaker.objectId !== displayedChar.objectId) {
260
- brightness = 0.8;
442
+ brightness = inactiveCharacterBrightness;
261
443
  }
262
444
  }
263
445
  // z-indexを決定
@@ -270,17 +452,36 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
270
452
  : ((_d = image.scale) !== null && _d !== void 0 ? _d : 1);
271
453
  // 位置を決定(カスタム位置またはデフォルト位置)
272
454
  // 新座標系: x: -1=左見切れ, 0=中央, 1=右見切れ / y: -1=上見切れ, 0=中央, 1=下見切れ
273
- let finalPosition = { x: 0, y: 1.0 }; // デフォルトは中央、下端
274
- if (displayedChar) {
275
- // カスタム位置が設定されている場合
276
- if (displayedChar.positionX !== null &&
277
- displayedChar.positionY !== null) {
455
+ let finalPosition = { x: 0, y: 0 };
456
+ // 退場中のキャラは保存された位置情報を使用(移動しない)
457
+ const exitingChar = isExitingChar
458
+ ? exitingCharacters.find((c) => c.objectId === image.objectId)
459
+ : null;
460
+ const positionSource = displayedChar !== null && displayedChar !== void 0 ? displayedChar : exitingChar;
461
+ if (isExitingChar) {
462
+ // 退場中のキャラ: 保存された位置を使用(絶対に移動しない)
463
+ const savedPos = exitPositionsRef.current.get(image.objectId);
464
+ if (savedPos) {
465
+ finalPosition = savedPos;
466
+ }
467
+ }
468
+ else if (positionSource) {
469
+ // カスタム位置が設定されている場合(詳細モード)
470
+ if (positionSource.positionX !== null &&
471
+ positionSource.positionY !== null) {
278
472
  finalPosition = {
279
- x: (_e = displayedChar.positionX) !== null && _e !== void 0 ? _e : 0,
280
- y: (_f = displayedChar.positionY) !== null && _f !== void 0 ? _f : 0,
473
+ x: (_e = positionSource.positionX) !== null && _e !== void 0 ? _e : 0,
474
+ y: (_f = positionSource.positionY) !== null && _f !== void 0 ? _f : 0,
281
475
  };
282
476
  }
283
- // カスタム位置が設定されていない場合はデフォルト位置のまま
477
+ else {
478
+ // 簡易モード: 自動配置
479
+ const autoPositions = calculateAutoLayout(displayedCharacters, characterSpacing !== null && characterSpacing !== void 0 ? characterSpacing : 0.2);
480
+ const autoPos = autoPositions.get(positionSource.objectId);
481
+ if (autoPos) {
482
+ finalPosition = autoPos;
483
+ }
484
+ }
284
485
  }
285
486
  // コンテナ相対の位置計算(パーセンテージ使用)
286
487
  // positionX: -1.0 = 完全に左に見切れ, 0.0 = 中央, 1.0 = 完全に右に見切れ
@@ -312,19 +513,24 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
312
513
  // 位置をピクセルで計算(CharacterEditDialogと同じ)
313
514
  const leftPx = (leftPercent / 100) * containerSize.width;
314
515
  const topPx = (topPercent / 100) * containerSize.height;
315
- return (_jsx("div", { style: {
316
- position: "absolute",
317
- visibility: shouldDisplay ? "visible" : "hidden",
318
- opacity: opacity,
319
- filter: `brightness(${brightness})`,
320
- zIndex: zIndex,
516
+ // トリミング用のCSS mask計算
517
+ // displayedCharacterのcrop値を優先(キャラクター登場ブロックで設定)
518
+ const cLeft = (_k = (_j = displayedChar === null || displayedChar === void 0 ? void 0 : displayedChar.cropLeft) !== null && _j !== void 0 ? _j : image.cropLeft) !== null && _k !== void 0 ? _k : 0;
519
+ const cRight = (_m = (_l = displayedChar === null || displayedChar === void 0 ? void 0 : displayedChar.cropRight) !== null && _l !== void 0 ? _l : image.cropRight) !== null && _m !== void 0 ? _m : 0;
520
+ const cFade = (_p = (_o = displayedChar === null || displayedChar === void 0 ? void 0 : displayedChar.cropFade) !== null && _o !== void 0 ? _o : image.cropFade) !== null && _p !== void 0 ? _p : 0;
521
+ const hasCrop = cLeft > 0 || cRight > 0;
522
+ // フェード量をpx基準値からコンテナ高さ比でスケーリング
523
+ const fadePx = hasCrop && cFade > 0 ? (cFade / 1080) * containerSize.height : 0;
524
+ const cropMaskStyle = hasCrop
525
+ ? {
526
+ WebkitMaskImage: `linear-gradient(to right, transparent ${cLeft * 100}%, black ${cLeft * 100 + (fadePx > 0 ? (fadePx / baseHeight) * 100 : 0)}%, black ${(1 - cRight) * 100 - (fadePx > 0 ? (fadePx / baseHeight) * 100 : 0)}%, transparent ${(1 - cRight) * 100}%)`,
527
+ maskImage: `linear-gradient(to right, transparent ${cLeft * 100}%, black ${cLeft * 100 + (fadePx > 0 ? (fadePx / baseHeight) * 100 : 0)}%, black ${(1 - cRight) * 100 - (fadePx > 0 ? (fadePx / baseHeight) * 100 : 0)}%, transparent ${(1 - cRight) * 100}%)`,
528
+ }
529
+ : {};
530
+ return (_jsx("div", { style: Object.assign({ position: "absolute", visibility: shouldDisplay ? "visible" : "hidden", opacity: opacity, filter: `brightness(${brightness})`, zIndex: zIndex,
321
531
  // CharacterEditDialogと完全に同じ: left/topを0にしてtransformで位置制御
322
532
  // translate(%)は画像サイズに対する割合
323
- left: 0,
324
- top: 0,
325
- transform: `translate(${leftPx}px, ${topPx}px) translate(${translateXPercent}%, ${translateYPercent}%)`,
326
- transition: fadeState ? "none" : undefined,
327
- }, "data-character-id": image.objectId, "data-character-sprite": true, children: hasLayerFeature && image.baseBodyUrl ? (_jsxs(_Fragment, { children: [_jsx("img", { src: image.baseBodyUrl, alt: "", style: {
533
+ left: 0, top: 0, transform: `translate(${leftPx}px, ${topPx}px) translate(${translateXPercent}%, ${translateYPercent}%)`, transition: fadeState ? "none" : undefined }, cropMaskStyle), "data-character-id": image.objectId, "data-character-sprite": true, children: hasLayerFeature && image.baseBodyUrl ? (_jsxs(_Fragment, { children: [_jsx("img", { src: image.baseBodyUrl, alt: "", style: {
328
534
  height: `${baseHeight}px`,
329
535
  width: "auto",
330
536
  objectFit: "contain",
@@ -373,6 +579,27 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
373
579
  return renderCharacter(fallbackImage, char, false, "char");
374
580
  }
375
581
  return renderCharacter(image, char, false, "char");
582
+ }), exitingCharacters.map((char) => {
583
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
584
+ const imageKey = `${char.objectId}-${char.entityStateId}`;
585
+ const image = imageDataMap.get(imageKey);
586
+ if (!image) {
587
+ const fallbackImage = {
588
+ url: (_b = (_a = char.entityState) === null || _a === void 0 ? void 0 : _a.imageUrl) !== null && _b !== void 0 ? _b : "",
589
+ objectId: char.objectId,
590
+ entityStateId: char.entityStateId,
591
+ scale: (_c = char.entityState) === null || _c === void 0 ? void 0 : _c.scale,
592
+ translateX: (_d = char.entityState) === null || _d === void 0 ? void 0 : _d.translateX,
593
+ translateY: (_e = char.entityState) === null || _e === void 0 ? void 0 : _e.translateY,
594
+ baseBodyUrl: (_f = char.baseBodyState) === null || _f === void 0 ? void 0 : _f.imageUrl,
595
+ baseBodyScale: (_g = char.baseBodyState) === null || _g === void 0 ? void 0 : _g.scale,
596
+ baseBodyTranslateX: (_h = char.baseBodyState) === null || _h === void 0 ? void 0 : _h.translateX,
597
+ baseBodyTranslateY: (_j = char.baseBodyState) === null || _j === void 0 ? void 0 : _j.translateY,
598
+ layers: (_k = char.entityState) === null || _k === void 0 ? void 0 : _k.layers,
599
+ };
600
+ return renderCharacter(fallbackImage, char, false, "exit");
601
+ }
602
+ return renderCharacter(image, char, false, "exit");
376
603
  }), displayedCharacters.length === 0 &&
377
604
  currentBlock.speakerId &&
378
605
  currentBlock.speakerStateId && (_jsxs(_Fragment, { children: [(() => {
@@ -390,4 +617,4 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
390
617
  return null;
391
618
  return renderCharacter(image, null, false, "fadeout");
392
619
  })()] }))] }) }));
393
- };
620
+ });
@@ -5,9 +5,8 @@ interface OverlayUIProps {
5
5
  /**
6
6
  * OverlayUI コンポーネント
7
7
  *
8
- * 16:9のアスペクト比を維持しながら、様々な画面サイズに対応するコンテナ
9
- * - スマートフォン(幅 < 1000px): フルサイズ表示
10
- * - タブレット(幅 ≥ 1000px): 70%に縮小して中央配置
8
+ * アスペクト比コンテナ全体に配置されるUIオーバーレイ
9
+ * 親コンテナ(アスペクト比コンテナ)のサイズに合わせて自動調整される
11
10
  */
12
11
  export declare const OverlayUI: React.FC<OverlayUIProps>;
13
12
  export {};
@@ -1,21 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useScreenSizeAtom } from "../atoms/screen-size";
3
2
  /**
4
3
  * OverlayUI コンポーネント
5
4
  *
6
- * 16:9のアスペクト比を維持しながら、様々な画面サイズに対応するコンテナ
7
- * - スマートフォン(幅 < 1000px): フルサイズ表示
8
- * - タブレット(幅 ≥ 1000px): 70%に縮小して中央配置
5
+ * アスペクト比コンテナ全体に配置されるUIオーバーレイ
6
+ * 親コンテナ(アスペクト比コンテナ)のサイズに合わせて自動調整される
9
7
  */
10
8
  export const OverlayUI = ({ children }) => {
11
- // 16:9のアスペクト比を基準
12
- let [{ height: screenHeight }] = useScreenSizeAtom();
13
- let screenWidth = (screenHeight * 16) / 9;
14
- // タブレット(幅1000px以上)では70%に縮小
15
- const [{ width: checkWidth }] = useScreenSizeAtom();
16
- if (checkWidth >= 1000) {
17
- screenHeight = (screenHeight * 7) / 10;
18
- screenWidth = (screenHeight * 16) / 9;
19
- }
20
- return (_jsx("div", { className: "absolute inset-0 pointer-events-none z-[9999] flex justify-center items-center", children: _jsx("div", { "data-overlay-container": true, className: "relative", style: { width: screenWidth, height: screenHeight }, children: children }) }));
9
+ return (_jsx("div", { className: "absolute inset-0 pointer-events-none z-[9999]", children: _jsx("div", { className: "w-full h-full", children: children }) }));
21
10
  };
@@ -20,7 +20,7 @@ export function PluginComponentProvider({ type, pluginManager, fallback: Fallbac
20
20
  return () => {
21
21
  unsubscribe();
22
22
  };
23
- }, [dataAPI, type]);
23
+ }, [dataAPI]);
24
24
  const Component = pluginManager.getComponent(type);
25
25
  if (Component) {
26
26
  // Pass DataAPI as prop to avoid context issues
@@ -1,6 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, } from "react";
3
3
  const DataContextInstance = createContext(null);
4
+ // データへの参照を共有するContext(Context value自体は変わらない)
5
+ const DataRefContext = createContext(null);
4
6
  const SubscribersContext = createContext(null);
5
7
  const SettingsUpdaterContext = createContext(null);
6
8
  const EmotionEffectUpdaterContext = createContext(null);
@@ -21,6 +23,12 @@ const ScenarioCancellerContext = createContext(null);
21
23
  export const DataProvider = ({ data, onSettingsUpdate, onEmotionEffectUpdate, onCancelScenario, children }) => {
22
24
  const subscribers = useMemo(() => new Map(), []);
23
25
  const previousDataRef = useRef(data);
26
+ // 安定したデータ参照ホルダー(Context valueとして提供)
27
+ // オブジェクト自体の参照は変わらず、currentプロパティを更新
28
+ // これにより、useContextを使うコンポーネントは再レンダリングされない
29
+ const dataRefHolder = useMemo(() => ({ current: data }), [data]);
30
+ // dataが変わるたびにcurrentを更新
31
+ dataRefHolder.current = data;
24
32
  // データ変更時に購読者に通知
25
33
  useEffect(() => {
26
34
  const prevData = previousDataRef.current;
@@ -54,31 +62,39 @@ export const DataProvider = ({ data, onSettingsUpdate, onEmotionEffectUpdate, on
54
62
  }
55
63
  previousDataRef.current = data;
56
64
  }, [data, subscribers]);
57
- return (_jsx(SubscribersContext.Provider, { value: subscribers, children: _jsx(SettingsUpdaterContext.Provider, { value: onSettingsUpdate !== null && onSettingsUpdate !== void 0 ? onSettingsUpdate : null, children: _jsx(EmotionEffectUpdaterContext.Provider, { value: onEmotionEffectUpdate !== null && onEmotionEffectUpdate !== void 0 ? onEmotionEffectUpdate : null, children: _jsx(ScenarioCancellerContext.Provider, { value: onCancelScenario !== null && onCancelScenario !== void 0 ? onCancelScenario : null, children: _jsx(DataContextInstance.Provider, { value: data, children: children }) }) }) }) }));
65
+ return (_jsx(SubscribersContext.Provider, { value: subscribers, children: _jsx(SettingsUpdaterContext.Provider, { value: onSettingsUpdate !== null && onSettingsUpdate !== void 0 ? onSettingsUpdate : null, children: _jsx(EmotionEffectUpdaterContext.Provider, { value: onEmotionEffectUpdate !== null && onEmotionEffectUpdate !== void 0 ? onEmotionEffectUpdate : null, children: _jsx(ScenarioCancellerContext.Provider, { value: onCancelScenario !== null && onCancelScenario !== void 0 ? onCancelScenario : null, children: _jsx(DataRefContext.Provider, { value: dataRefHolder, children: _jsx(DataContextInstance.Provider, { value: data, children: children }) }) }) }) }) }));
58
66
  };
59
67
  /**
60
68
  * DataAPI実装を提供するフック
61
69
  * プラグインから呼び出される
62
70
  */
63
71
  export function useDataAPI() {
64
- const data = useContext(DataContextInstance);
72
+ var _a;
73
+ // DataRefContextから安定したデータ参照を取得
74
+ // これはContext valueが変わらないので、再レンダリングを引き起こさない
75
+ const dataRefHolder = useContext(DataRefContext);
65
76
  const subscribers = useContext(SubscribersContext);
66
77
  const settingsUpdater = useContext(SettingsUpdaterContext);
67
78
  const emotionEffectUpdater = useContext(EmotionEffectUpdaterContext);
68
79
  const scenarioCanceller = useContext(ScenarioCancellerContext);
69
- if (!data || !subscribers) {
80
+ // 注意: usePlaybackTextOptional()をここで呼ぶと、PlaybackTextContextの更新で
81
+ // useDataAPIを使う全コンポーネントが再レンダリングされてしまう
82
+ // 代わりに、displayTextが必要なコンポーネントはusePlaybackText()を直接使う
83
+ if (!dataRefHolder || !subscribers) {
70
84
  throw new Error("useDataAPI must be used within DataProvider");
71
85
  }
72
86
  const get = useCallback((key, property) => {
87
+ const currentData = dataRefHolder.current;
73
88
  if (property !== undefined) {
74
- const value = data[key];
89
+ const value = currentData[key];
75
90
  if (value && typeof value === "object" && property in value) {
76
91
  return value[property];
77
92
  }
78
93
  return undefined;
79
94
  }
80
- return data[key];
81
- }, [data]);
95
+ return currentData[key];
96
+ }, [dataRefHolder.current] // 依存配列を空にして、get関数を安定化
97
+ );
82
98
  const subscribe = useCallback((key, callback) => {
83
99
  var _a;
84
100
  const subscriberKey = key;
@@ -144,12 +160,13 @@ export function useDataAPI() {
144
160
  }, [updateSettings]);
145
161
  const getBlockOption = useCallback((key) => {
146
162
  var _a;
147
- const currentBlock = (_a = data.playback) === null || _a === void 0 ? void 0 : _a.currentBlock;
163
+ const currentBlock = (_a = dataRefHolder.current.playback) === null || _a === void 0 ? void 0 : _a.currentBlock;
148
164
  if (!(currentBlock === null || currentBlock === void 0 ? void 0 : currentBlock.options)) {
149
165
  return undefined;
150
166
  }
151
167
  return currentBlock.options[key];
152
- }, [data]);
168
+ }, [(_a = dataRefHolder.current.playback) === null || _a === void 0 ? void 0 : _a.currentBlock] // 依存配列を空にして安定化
169
+ );
153
170
  const updateEmotionEffect = useCallback((state) => {
154
171
  if (emotionEffectUpdater) {
155
172
  emotionEffectUpdater(state);
@@ -0,0 +1,32 @@
1
+ import { type ReactNode } from "react";
2
+ /**
3
+ * 文字送り用の軽量コンテキスト
4
+ * displayText と isTyping は頻繁に更新されるため、
5
+ * DataContext から分離して不要な再レンダリングを防ぐ
6
+ */
7
+ interface PlaybackTextContextValue {
8
+ /** 現在表示中のテキスト(文字送り中は部分テキスト) */
9
+ displayText: string | React.ReactNode;
10
+ /** 文字送り中かどうか */
11
+ isTyping: boolean;
12
+ }
13
+ /**
14
+ * PlaybackTextProvider
15
+ * 文字送り状態を提供するProvider
16
+ * DataProviderとは別に、displayTextとisTypingのみを管理
17
+ */
18
+ export declare const PlaybackTextProvider: React.FC<{
19
+ displayText: string | React.ReactNode;
20
+ isTyping: boolean;
21
+ children: ReactNode;
22
+ }>;
23
+ /**
24
+ * 文字送り状態を取得するフック
25
+ */
26
+ export declare function usePlaybackText(): PlaybackTextContextValue;
27
+ /**
28
+ * 文字送り状態を取得するフック(オプショナル版)
29
+ * PlaybackTextProviderの外でも使用可能(その場合はnullを返す)
30
+ */
31
+ export declare function usePlaybackTextOptional(): PlaybackTextContextValue | null;
32
+ export {};
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useMemo } from "react";
3
+ const PlaybackTextContextInstance = createContext(null);
4
+ /**
5
+ * PlaybackTextProvider
6
+ * 文字送り状態を提供するProvider
7
+ * DataProviderとは別に、displayTextとisTypingのみを管理
8
+ */
9
+ export const PlaybackTextProvider = ({ displayText, isTyping, children }) => {
10
+ const value = useMemo(() => ({ displayText, isTyping }), [displayText, isTyping]);
11
+ return (_jsx(PlaybackTextContextInstance.Provider, { value: value, children: children }));
12
+ };
13
+ /**
14
+ * 文字送り状態を取得するフック
15
+ */
16
+ export function usePlaybackText() {
17
+ const context = useContext(PlaybackTextContextInstance);
18
+ if (!context) {
19
+ throw new Error("usePlaybackText must be used within PlaybackTextProvider");
20
+ }
21
+ return context;
22
+ }
23
+ /**
24
+ * 文字送り状態を取得するフック(オプショナル版)
25
+ * PlaybackTextProviderの外でも使用可能(その場合はnullを返す)
26
+ */
27
+ export function usePlaybackTextOptional() {
28
+ return useContext(PlaybackTextContextInstance);
29
+ }
@@ -51,8 +51,10 @@ export interface PlayerSettingsData {
51
51
  voiceVolume: number;
52
52
  /** スキップ設定 */
53
53
  skipMode?: "all" | "unread" | "none";
54
- /** 選択されたフォントファミリー */
54
+ /** 選択されたフォントファミリー(テキスト用) */
55
55
  selectedFontFamily?: string;
56
+ /** 選択されたUIフォントファミリー(メニュー・ラベル等) */
57
+ selectedUIFontFamily?: string;
56
58
  }
57
59
  /**
58
60
  * プラグインアセットデータ
@@ -72,8 +74,10 @@ export interface PluginAssetsData {
72
74
  export interface FontsData {
73
75
  /** 利用可能なフォント一覧 */
74
76
  fonts: WorkFont[];
75
- /** 選択されているフォントファミリー */
77
+ /** 選択されているフォントファミリー(テキスト用) */
76
78
  selectedFontFamily: string | undefined;
79
+ /** 選択されているUIフォントファミリー(メニュー・ラベル等) */
80
+ selectedUIFontFamily: string | undefined;
77
81
  /** フォントが読み込み完了しているかどうか */
78
82
  isLoaded: boolean;
79
83
  }
@@ -3,9 +3,11 @@ import type { WorkFont } from "../types";
3
3
  * フォントを読み込むカスタムフック
4
4
  * - Googleフォント: <link>タグで読み込み + document.fonts.load()でプリロード
5
5
  * - カスタムフォント: FontFace APIで読み込み
6
- * - すべてのフォントがプリロード&キャッシュされてからisLoaded=trueになる
6
+ * - すべてのフォントが完全にダウンロード&キャッシュされてからisLoaded=trueになる
7
+ * - 再生開始前に全フォントを一括ダウンロードし、再生中のフォントスワップを防止する
8
+ * - preloadText: シナリオ内の全テキストを渡すと、使用される全ての文字でフォントをプリロード
7
9
  */
8
- export declare function useFontLoader(fonts: WorkFont[] | undefined): {
10
+ export declare function useFontLoader(fonts: WorkFont[] | undefined, preloadText?: string): {
9
11
  isLoaded: boolean;
10
12
  loadedFonts: string[];
11
13
  };
@@ -13,6 +15,11 @@ export declare function useFontLoader(fonts: WorkFont[] | undefined): {
13
15
  * フォントファミリーに基づいてCSSのfont-family値を生成
14
16
  */
15
17
  export declare function getFontFamilyStyle(selectedFontFamily: string | undefined, fonts: WorkFont[] | undefined): string;
18
+ /**
19
+ * UIフォントファミリーに基づいてCSSのfont-family値を生成
20
+ * メニュー、ボタン、ラベルなどのUI要素に使用
21
+ */
22
+ export declare function getUIFontFamilyStyle(selectedUIFontFamily: string | undefined, fonts: WorkFont[] | undefined): string;
16
23
  /**
17
24
  * フォントがキャッシュに存在するかチェック
18
25
  */