@luna-editor/engine 0.3.0 → 0.3.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.
package/dist/Player.js CHANGED
@@ -24,6 +24,7 @@ import { PluginComponentProvider } from "./components/PluginComponentProvider";
24
24
  import { TimeWaitIndicator } from "./components/TimeWaitIndicator";
25
25
  import { AudioProvider } from "./contexts/AudioContext";
26
26
  import { DataProvider } from "./contexts/DataContext";
27
+ import { PlaybackTextProvider } from "./contexts/PlaybackTextContext";
27
28
  import { useBacklog } from "./hooks/useBacklog";
28
29
  import { useConversationBranch } from "./hooks/useConversationBranch";
29
30
  import { useFontLoader } from "./hooks/useFontLoader";
@@ -39,20 +40,26 @@ import { convertBranchBlockToScenarioBlock } from "./utils/branchBlockConverter"
39
40
  import { BranchNavigator } from "./utils/branchNavigator";
40
41
  import { VariableManager } from "./utils/variableManager";
41
42
  export const Player = ({ scenario: scenarioProp, settings, plugins = [], sounds = [], onEnd, onScenarioEnd, onScenarioStart, onScenarioCancelled, onSettingsChange, className, autoplay = false, preventDefaultScroll = true, screenSize: screenSizeProp, disableKeyboardNavigation = false, }) => {
42
- var _a, _b, _c, _d, _e;
43
+ var _a, _b, _c, _d;
43
44
  // scenario.blocks が存在しない場合は空の配列を使用
44
45
  const scenario = useMemo(() => {
45
46
  var _a;
46
47
  return (Object.assign(Object.assign({}, scenarioProp), { blocks: (_a = scenarioProp.blocks) !== null && _a !== void 0 ? _a : [] }));
47
48
  }, [scenarioProp]);
48
49
  // デフォルト値とマージ
49
- const mergedSettings = Object.assign({ textSpeed: 80, autoPlaySpeed: 3, bgmVolume: 0.5, seVolume: 1.0, voiceVolume: 1.0, effectVolume: 1.0, textSoundVolume: 1.0, defaultBackgroundFadeDuration: 0 }, settings);
50
+ const mergedSettings = Object.assign({ textSpeed: 80, autoPlaySpeed: 3, bgmVolume: 0.5, seVolume: 1.0, voiceVolume: 1.0, effectVolume: 1.0, textSoundVolume: 1.0, defaultBackgroundFadeDuration: 0, inactiveCharacterBrightness: 0.8 }, settings);
50
51
  // プラグインからの設定更新ハンドラ
51
52
  const handleSettingsUpdate = useCallback((updatedSettings) => {
52
53
  var _a, _b;
53
54
  const newSettings = Object.assign(Object.assign({ aspectRatio: (_a = settings === null || settings === void 0 ? void 0 : settings.aspectRatio) !== null && _a !== void 0 ? _a : "16:9", bgObjectFit: (_b = settings === null || settings === void 0 ? void 0 : settings.bgObjectFit) !== null && _b !== void 0 ? _b : "cover" }, settings), updatedSettings);
54
55
  onSettingsChange === null || onSettingsChange === void 0 ? void 0 : onSettingsChange(newSettings);
55
56
  }, [settings, onSettingsChange]);
57
+ // 感情エフェクト状態管理
58
+ const [emotionEffectState, setEmotionEffectState] = useState(null);
59
+ // プラグインからの感情エフェクト更新ハンドラ
60
+ const handleEmotionEffectUpdate = useCallback((state) => {
61
+ setEmotionEffectState(state);
62
+ }, []);
56
63
  // 遅延初期化でPluginManagerを作成(毎レンダリングでnew PluginManager()が評価されるのを防ぐ)
57
64
  const pluginManagerRef = useRef(undefined);
58
65
  if (!pluginManagerRef.current) {
@@ -66,6 +73,16 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = [], sounds
66
73
  const uiAPI = pluginManager.getUIAPI();
67
74
  setGlobalUIAPI(uiAPI, pluginManager);
68
75
  }, [pluginManager]);
76
+ // 感情エフェクト更新コールバックを登録
77
+ useEffect(() => {
78
+ pluginManager.setEmotionEffectUpdaterCallback(handleEmotionEffectUpdate);
79
+ }, [pluginManager, handleEmotionEffectUpdate]);
80
+ // DataContextへの参照を保持(プラグインがdata.get()を呼び出せるようにする)
81
+ const dataContextRef = useRef(null);
82
+ // DataContext getterを登録
83
+ useEffect(() => {
84
+ pluginManager.setDataContextGetter(() => dataContextRef.current);
85
+ }, [pluginManager]);
69
86
  // プラグインのミュート状態を設定
70
87
  useEffect(() => {
71
88
  var _a;
@@ -73,6 +90,7 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = [], sounds
73
90
  }, [pluginManager, mergedSettings.muteAudio]);
74
91
  // 画面サイズの初期化
75
92
  const [, setScreenSize] = useScreenSizeAtom();
93
+ const aspectRatioContainerRef = useRef(null);
76
94
  useEffect(() => {
77
95
  // screenSizeが明示的に指定されている場合はそれを使用(プレビュー用)
78
96
  if (screenSizeProp) {
@@ -82,16 +100,40 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = [], sounds
82
100
  // クライアントサイドでのみ実行
83
101
  if (typeof window === "undefined")
84
102
  return;
103
+ // 初期値として一時的にウィンドウサイズを設定
85
104
  setScreenSize({ width: window.innerWidth, height: window.innerHeight });
86
- // リサイズ監視
87
- const handleResize = () => {
105
+ const container = aspectRatioContainerRef.current;
106
+ if (!container)
107
+ return;
108
+ // アスペクト比コンテナのサイズを監視
109
+ const updateScreenSize = () => {
110
+ const rect = container.getBoundingClientRect();
111
+ // サイズが0の場合はスキップ(まだレンダリングされていない)
112
+ if (rect.width === 0 || rect.height === 0)
113
+ return;
114
+ console.log("[Player] screenSize更新:", {
115
+ width: rect.width,
116
+ height: rect.height,
117
+ windowWidth: window.innerWidth,
118
+ windowHeight: window.innerHeight,
119
+ });
88
120
  setScreenSize({
89
- width: window.innerWidth,
90
- height: window.innerHeight,
121
+ width: rect.width,
122
+ height: rect.height,
91
123
  });
92
124
  };
93
- window.addEventListener("resize", handleResize);
94
- return () => window.removeEventListener("resize", handleResize);
125
+ // 初期サイズを設定(次のフレームで実行してレンダリング完了を待つ)
126
+ requestAnimationFrame(() => {
127
+ updateScreenSize();
128
+ });
129
+ // ResizeObserverでコンテナのサイズ変更を監視
130
+ const resizeObserver = new ResizeObserver(() => {
131
+ updateScreenSize();
132
+ });
133
+ resizeObserver.observe(container);
134
+ return () => {
135
+ resizeObserver.disconnect();
136
+ };
95
137
  }, [setScreenSize, screenSizeProp]);
96
138
  // 表示可能なブロックのインデックスを事前計算
97
139
  const displayableBlockIndices = useMemo(() => {
@@ -215,21 +257,34 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = [], sounds
215
257
  getMediaType,
216
258
  mergedSettings.defaultBackgroundFadeDuration,
217
259
  ]);
218
- const [state, setState] = useState({
219
- currentBlockIndex: 0,
220
- isPlaying: autoplay,
221
- isEnded: false,
222
- variables: (_a = variableManagerRef.current) === null || _a === void 0 ? void 0 : _a.getVariablesMap(),
260
+ const [state, setState] = useState(() => {
261
+ var _a;
262
+ return {
263
+ currentBlockIndex: 0,
264
+ isPlaying: autoplay,
265
+ isEnded: false,
266
+ variables: (_a = variableManagerRef.current) === null || _a === void 0 ? void 0 : _a.getVariablesMap(),
267
+ };
223
268
  });
224
269
  const [currentBranchBlock, setCurrentBranchBlock] = useState(null);
225
270
  // 遷移の種類を追跡(クリック、自動、時間待ちなど)
226
271
  const [transitionSource, setTransitionSource] = useState("click");
272
+ // シナリオ開始コールバック(後でcurrentBlockが定義された後に実行)
273
+ const [hasStarted, setHasStarted] = useState(false);
227
274
  // シナリオIDを追跡して、変更時に状態をリセット(プレビュー用)
228
275
  const previousScenarioIdRef = useRef(scenario.id);
229
276
  // シナリオが変更されたときに状態をリセット(keyを変更せずに対応)
230
277
  useEffect(() => {
231
278
  var _a;
232
279
  if (previousScenarioIdRef.current !== scenario.id) {
280
+ const previousId = previousScenarioIdRef.current;
281
+ // 前のシナリオが開始されていた場合、onScenarioEndを呼び出す
282
+ if (hasStarted) {
283
+ pluginManager.callHook("onScenarioEnd", {
284
+ scenarioId: previousId,
285
+ scenarioName: "scenario",
286
+ });
287
+ }
233
288
  previousScenarioIdRef.current = scenario.id;
234
289
  // 状態をリセット
235
290
  setState({
@@ -239,12 +294,33 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = [], sounds
239
294
  variables: (_a = variableManagerRef.current) === null || _a === void 0 ? void 0 : _a.getVariablesMap(),
240
295
  });
241
296
  setCurrentBranchBlock(null);
297
+ setHasStarted(false); // これにより次の useEffect で onScenarioStart が呼ばれる
242
298
  }
243
- }, [scenario.id, autoplay]);
299
+ }, [scenario.id, autoplay, hasStarted, pluginManager]);
244
300
  // 画像を事前読み込み
245
301
  const imagesLoaded = usePreloadImages(scenario);
246
- // フォントを読み込み
247
- const { isLoaded: fontsLoaded } = useFontLoader(scenario.fonts);
302
+ // シナリオ内の全テキストを抽出(フォントのプリロード用)
303
+ // サブセットフォントの分割ファイルを事前に読み込むことで、
304
+ // 文字送り中のフォント切り替わりを防止
305
+ const allScenarioText = useMemo(() => {
306
+ var _a;
307
+ const texts = [];
308
+ for (const block of scenario.blocks) {
309
+ // dialogue, narration, fullscreen_textのcontent
310
+ if (block.content) {
311
+ texts.push(block.content);
312
+ }
313
+ // speaker name
314
+ if ((_a = block.speaker) === null || _a === void 0 ? void 0 : _a.name) {
315
+ texts.push(block.speaker.name);
316
+ }
317
+ }
318
+ // 重複を除去してユニークな文字のみを残す
319
+ const uniqueChars = [...new Set(texts.join(""))].join("");
320
+ return uniqueChars;
321
+ }, [scenario.blocks]);
322
+ // フォントを読み込み(シナリオ内の全テキストでプリロード)
323
+ const { isLoaded: fontsLoaded } = useFontLoader(scenario.fonts, allScenarioText);
248
324
  // プラグインの読み込み状態
249
325
  const [pluginsLoaded, setPluginsLoaded] = useState(false);
250
326
  // 読み込み済みプラグインのパッケージ名を追跡
@@ -266,7 +342,7 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = [], sounds
266
342
  for (const plugin of newPlugins) {
267
343
  if (isCancelled)
268
344
  return;
269
- yield pluginManager.loadPlugin(plugin.packageName, plugin.bundleUrl, plugin.config);
345
+ yield pluginManager.loadPlugin(plugin.packageName, plugin.bundleUrl, plugin.config, plugin.assets);
270
346
  loadedPluginNamesRef.current.add(plugin.packageName);
271
347
  }
272
348
  if (isCancelled)
@@ -287,8 +363,6 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = [], sounds
287
363
  }, [plugins, sounds]);
288
364
  // 初回レンダリング完了フラグ
289
365
  const [isFirstRenderComplete, setIsFirstRenderComplete] = useState(false);
290
- // シナリオ開始コールバック(後でcurrentBlockが定義された後に実行)
291
- const [hasStarted, setHasStarted] = useState(false);
292
366
  // スタイル要素の登録
293
367
  useEffect(() => {
294
368
  if (isFirstRenderComplete) {
@@ -347,7 +421,7 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = [], sounds
347
421
  return prevIdx !== undefined ? scenario.blocks[prevIdx] : null;
348
422
  }, [scenario.blocks, displayableBlockIndices, state.currentBlockIndex]);
349
423
  // usePlayerLogicに渡す実際のブロックインデックス
350
- const actualBlockIndex = (_b = displayableBlockIndices[state.currentBlockIndex]) !== null && _b !== void 0 ? _b : 0;
424
+ const actualBlockIndex = (_a = displayableBlockIndices[state.currentBlockIndex]) !== null && _a !== void 0 ? _a : 0;
351
425
  // バックログ機能
352
426
  const backlog = useBacklog({
353
427
  scenario,
@@ -514,21 +588,40 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = [], sounds
514
588
  });
515
589
  }
516
590
  }, [imagesLoaded, fontsLoaded, currentBlock, isFirstRenderComplete]);
517
- // シナリオ開始コールバック(currentBlock定義後)
591
+ // シナリオ開始コールバック(currentBlock定義後、プラグインロード完了後)
518
592
  useEffect(() => {
519
- if (isFirstRenderComplete && !hasStarted && currentBlock) {
593
+ if (isFirstRenderComplete && !hasStarted && currentBlock && pluginsLoaded) {
520
594
  setHasStarted(true);
521
595
  onScenarioStart === null || onScenarioStart === void 0 ? void 0 : onScenarioStart();
596
+ // プラグインのonScenarioStartフックを呼び出し
597
+ pluginManager.callHook("onScenarioStart", {
598
+ scenarioId: scenario.id,
599
+ scenarioName: scenario.name || "scenario",
600
+ });
522
601
  }
523
- }, [isFirstRenderComplete, hasStarted, currentBlock, onScenarioStart]);
602
+ }, [
603
+ isFirstRenderComplete,
604
+ hasStarted,
605
+ currentBlock,
606
+ pluginsLoaded,
607
+ onScenarioStart,
608
+ pluginManager,
609
+ scenario.id,
610
+ scenario.name,
611
+ ]);
524
612
  // restartをusePlayerLogicの前に定義(分岐状態もリセット + キャンセルコールバック)
525
613
  const restart = useCallback(() => {
526
614
  // リスタート時はシナリオがキャンセルされたとみなす
527
615
  if (hasStarted && !state.isEnded) {
528
616
  onScenarioCancelled === null || onScenarioCancelled === void 0 ? void 0 : onScenarioCancelled();
617
+ // プラグインのonScenarioEndフックを呼び出し(シナリオ中断時)
618
+ pluginManager.callHook("onScenarioEnd", {
619
+ scenarioId: scenario.id,
620
+ scenarioName: scenario.name || "scenario",
621
+ });
529
622
  }
530
623
  setIsFirstRenderComplete(false);
531
- setHasStarted(false);
624
+ setHasStarted(false); // これにより次の useEffect で onScenarioStart が呼ばれる
532
625
  setCurrentBranchBlock(null);
533
626
  branchNavigatorRef.current.reset();
534
627
  resetAccumulated(); // 蓄積テキストをクリア
@@ -543,6 +636,9 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = [], sounds
543
636
  state.isEnded,
544
637
  onScenarioCancelled,
545
638
  resetAccumulated,
639
+ pluginManager,
640
+ scenario.id,
641
+ scenario.name,
546
642
  ]);
547
643
  const { handleNext: handleNextInternal, handlePrevious: handlePreviousInternal, togglePlay: _togglePlay, restart: _restartInternal, displayedCharacters, } = usePlayerLogic({
548
644
  state: Object.assign(Object.assign({}, state), { currentBlockIndex: actualBlockIndex }),
@@ -591,6 +687,29 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = [], sounds
591
687
  pluginsLoaded,
592
688
  transitionSource,
593
689
  });
690
+ // ActionNode実行処理
691
+ useEffect(() => {
692
+ var _a;
693
+ if (currentBlock && currentBlock.blockType === "action_node") {
694
+ const attrs = {};
695
+ // attributeValuesから属性値を収集
696
+ if (currentBlock.attributeValues) {
697
+ for (const attrValue of currentBlock.attributeValues) {
698
+ attrs[attrValue.attribute.name] = attrValue.value;
699
+ }
700
+ }
701
+ // ActionNode実行(actionNode.typeがundefinedでないことを確認)
702
+ if ((_a = currentBlock.actionNode) === null || _a === void 0 ? void 0 : _a.type) {
703
+ pluginManager.executeActionNode(currentBlock.actionNode.type, {
704
+ attributes: attrs,
705
+ currentBlock,
706
+ currentSpeaker: undefined, // TODO: 現在の発話キャラクターを取得
707
+ displayedCharacters: displayedCharacters,
708
+ api: pluginManager,
709
+ });
710
+ }
711
+ }
712
+ }, [currentBlock, displayedCharacters, pluginManager]);
594
713
  // 初期履歴構築(シナリオ開始時)
595
714
  useEffect(() => {
596
715
  if (isFirstRenderComplete && actualBlockIndex >= 0) {
@@ -627,6 +746,11 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = [], sounds
627
746
  }
628
747
  else {
629
748
  setState((prev) => (Object.assign(Object.assign({}, prev), { isEnded: true, isPlaying: false })));
749
+ // プラグインのonScenarioEndフックを呼び出し
750
+ pluginManager.callHook("onScenarioEnd", {
751
+ scenarioId: scenario.id,
752
+ scenarioName: scenario.name || "scenario",
753
+ });
630
754
  onEnd === null || onEnd === void 0 ? void 0 : onEnd();
631
755
  onScenarioEnd === null || onScenarioEnd === void 0 ? void 0 : onScenarioEnd();
632
756
  }
@@ -637,7 +761,10 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = [], sounds
637
761
  displayableBlockIndices.length,
638
762
  handleNextInternal,
639
763
  onEnd,
640
- onScenarioEnd,
764
+ onScenarioEnd, // プラグインのonScenarioEndフックを呼び出し
765
+ pluginManager,
766
+ scenario.id,
767
+ scenario.name,
641
768
  ]);
642
769
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
643
770
  const _handlePrevious = useCallback(() => {
@@ -647,7 +774,16 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = [], sounds
647
774
  }, [state.currentBlockIndex, handlePreviousInternal]);
648
775
  // 現在の背景を計算
649
776
  const currentBackground = useMemo(() => calculateCurrentBackground(actualBlockIndex), [calculateCurrentBackground, actualBlockIndex]);
650
- // DataContext の構築 - displayedCharactersが必要なため usePlayerLogic の後に配置
777
+ // displayText JSX に変換(PlaybackTextProvider 用)
778
+ const displayTextElement = useMemo(() => {
779
+ if (displayText.includes("\n")) {
780
+ return displayText.split("\n").map((line, index, array) => (_jsxs(React.Fragment, { children: [line, index < array.length - 1 && _jsx("br", {})] }, index)));
781
+ }
782
+ return displayText;
783
+ }, [displayText]);
784
+ // DataContext の構築
785
+ // DataRefContext で安定した参照を提供することで、
786
+ // useDataAPI() を使うコンポーネントの再レンダリングを防ぐ
651
787
  const dataContext = useMemo(() => {
652
788
  var _a, _b, _c;
653
789
  return ({
@@ -657,9 +793,7 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = [], sounds
657
793
  scenarioId: scenario.id,
658
794
  scenarioName: scenario.name,
659
795
  currentBlock: currentBlock || null,
660
- displayText: displayText.includes("\n")
661
- ? displayText.split("\n").map((line, index, array) => (_jsxs(React.Fragment, { children: [line, index < array.length - 1 && _jsx("br", {})] }, index)))
662
- : displayText,
796
+ displayText: displayTextElement,
663
797
  isTyping,
664
798
  displayedCharacters,
665
799
  },
@@ -678,6 +812,8 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = [], sounds
678
812
  seVolume: mergedSettings.seVolume,
679
813
  voiceVolume: mergedSettings.voiceVolume,
680
814
  skipMode: "unread",
815
+ selectedFontFamily: mergedSettings.selectedFontFamily,
816
+ selectedUIFontFamily: mergedSettings.selectedUIFontFamily,
681
817
  },
682
818
  pluginAssets: {
683
819
  getAssetUrl: (pluginName, filename) => {
@@ -691,30 +827,38 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = [], sounds
691
827
  fonts: {
692
828
  fonts: (_c = scenario.fonts) !== null && _c !== void 0 ? _c : [],
693
829
  selectedFontFamily: mergedSettings.selectedFontFamily,
830
+ selectedUIFontFamily: mergedSettings.selectedUIFontFamily,
694
831
  isLoaded: fontsLoaded,
695
832
  },
833
+ emotionEffect: emotionEffectState,
696
834
  });
697
835
  }, [
698
836
  pluginManager,
699
837
  actualBlockIndex,
700
838
  scenario,
701
839
  currentBlock,
702
- displayText,
840
+ displayTextElement,
703
841
  isTyping,
704
842
  displayedCharacters,
705
843
  backlog,
706
844
  conversationBranch.branchState,
707
845
  mergedSettings.aspectRatio,
708
846
  mergedSettings.autoPlaySpeed,
847
+ emotionEffectState,
709
848
  mergedSettings.bgObjectFit,
710
849
  mergedSettings.bgmVolume,
711
850
  mergedSettings.seVolume,
712
851
  mergedSettings.textSpeed,
713
852
  mergedSettings.voiceVolume,
714
853
  mergedSettings.selectedFontFamily,
854
+ mergedSettings.selectedUIFontFamily,
715
855
  currentBackground,
716
856
  fontsLoaded,
717
857
  ]);
858
+ // DataContextの参照を更新(プラグインがapi.data.get()を呼び出せるようにする)
859
+ useEffect(() => {
860
+ dataContextRef.current = dataContext;
861
+ }, [dataContext]);
718
862
  // マウスホイールとタッチジェスチャーを無効化(preventDefaultScrollがtrueの場合のみ)
719
863
  useEffect(() => {
720
864
  if (!preventDefaultScroll)
@@ -747,6 +891,12 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = [], sounds
747
891
  const [width, height] = settings.aspectRatio.split(":").map(Number);
748
892
  return `${width}/${height}`;
749
893
  };
894
+ const getAspectRatioValue = () => {
895
+ if (!(settings === null || settings === void 0 ? void 0 : settings.aspectRatio))
896
+ return 16 / 9;
897
+ const [width, height] = settings.aspectRatio.split(":").map(Number);
898
+ return width / height;
899
+ };
750
900
  // 条件付きレンダリングを JSX で処理(フックの後、early return なし)
751
901
  return (_jsxs(_Fragment, { children: [!currentBlock && !state.isEnded && (_jsx("div", { className: clsx("flex items-center justify-center p-8", className), children: _jsx("p", { className: "text-gray-500", children: "\u30B7\u30CA\u30EA\u30AA\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093" }) })), state.isEnded && (_jsx(EndScreen, { scenarioName: scenario.name, onRestart: restart, className: className })), currentBlock && !state.isEnded && (_jsxs(_Fragment, { children: [(!imagesLoaded ||
752
902
  !fontsLoaded ||
@@ -763,16 +913,20 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = [], sounds
763
913
  touchAction: "none",
764
914
  userSelect: "none",
765
915
  WebkitUserSelect: "none",
766
- }, children: _jsx("div", { className: "relative bg-white flex flex-col w-full overflow-hidden h-full", style: { aspectRatio: getAspectRatio() }, children: _jsx(DataProvider, { data: dataContext, onSettingsUpdate: handleSettingsUpdate, children: _jsxs(AudioProvider, { settings: {
916
+ }, children: _jsx("div", { ref: aspectRatioContainerRef, className: "relative bg-white flex flex-col overflow-hidden", style: {
917
+ aspectRatio: getAspectRatio(),
918
+ width: `min(100vw, calc(100vh * ${getAspectRatioValue()}))`,
919
+ height: `min(100vh, calc(100vw / ${getAspectRatioValue()}))`,
920
+ }, children: _jsx(DataProvider, { data: dataContext, onSettingsUpdate: handleSettingsUpdate, onEmotionEffectUpdate: handleEmotionEffectUpdate, children: _jsx(AudioProvider, { settings: {
767
921
  bgmVolume: mergedSettings.bgmVolume,
768
922
  seVolume: mergedSettings.seVolume,
769
923
  voiceVolume: mergedSettings.voiceVolume,
770
924
  effectVolume: mergedSettings.effectVolume,
771
925
  textSoundVolume: mergedSettings.textSoundVolume,
772
- muteAudio: (_c = mergedSettings.muteAudio) !== null && _c !== void 0 ? _c : false,
773
- }, children: [_jsxs("div", { className: "h-full relative", children: [_jsx(PluginComponentProvider, { type: ComponentType.Background, pluginManager: pluginManager, fallback: BackgroundLayer }, "background"), _jsx(GameScreen, { scenario: scenario, currentBlock: currentBlock, previousBlock: previousBlock, displayedCharacters: displayedCharacters }, "game-screen")] }), _jsx(OverlayUI, { children: _jsxs("div", { className: "h-full w-full relative", children: [_jsx(PluginComponentProvider, { type: ComponentType.DialogueBox, pluginManager: pluginManager, fallback: DialogueBox }, "dialogue-box"), (currentBlock === null || currentBlock === void 0 ? void 0 : currentBlock.blockType) === "conversation_branch" && (_jsx(PluginComponentProvider, { type: ComponentType.ConversationBranch, pluginManager: pluginManager, fallback: ConversationBranchBox }, "conversation-branch")), (currentBlock === null || currentBlock === void 0 ? void 0 : currentBlock.blockType) === "fullscreen_text" && (_jsx(FullscreenTextBox, { onComplete: () => handleNext("click") }, "fullscreen-text")), (currentBlock === null || currentBlock === void 0 ? void 0 : currentBlock.blockType) === "click_wait" && (_jsx(ClickWaitIndicator, {}, "click-wait")), (currentBlock === null || currentBlock === void 0 ? void 0 : currentBlock.blockType) === "time_wait" && (_jsx(TimeWaitIndicator, { duration: (_e = (_d = currentBlock.options) === null || _d === void 0 ? void 0 : _d.duration) !== null && _e !== void 0 ? _e : 1, onComplete: () => handleNext("time_wait") }, "time-wait")), pluginManager
774
- .getRegisteredComponents()
775
- .filter((type) => type !== ComponentType.DialogueBox &&
776
- type !== ComponentType.ConversationBranch)
777
- .map((componentType) => (_jsx(PluginComponentProvider, { type: componentType, pluginManager: pluginManager }, componentType)))] }) })] }) }) }) })] }))] }));
926
+ muteAudio: (_b = mergedSettings.muteAudio) !== null && _b !== void 0 ? _b : false,
927
+ }, children: _jsxs(PlaybackTextProvider, { displayText: displayTextElement, isTyping: isTyping, children: [_jsxs("div", { className: "h-full relative", children: [_jsx(PluginComponentProvider, { type: ComponentType.Background, pluginManager: pluginManager, fallback: BackgroundLayer }, "background"), _jsx(GameScreen, { scenario: scenario, currentBlock: currentBlock, previousBlock: previousBlock, displayedCharacters: displayedCharacters }, "game-screen")] }), _jsx(OverlayUI, { children: _jsxs("div", { className: "h-full w-full relative", children: [_jsx(PluginComponentProvider, { type: ComponentType.DialogueBox, pluginManager: pluginManager, fallback: DialogueBox }, "dialogue-box"), (currentBlock === null || currentBlock === void 0 ? void 0 : currentBlock.blockType) === "conversation_branch" && (_jsx(PluginComponentProvider, { type: ComponentType.ConversationBranch, pluginManager: pluginManager, fallback: ConversationBranchBox }, "conversation-branch")), (currentBlock === null || currentBlock === void 0 ? void 0 : currentBlock.blockType) === "fullscreen_text" && (_jsx(FullscreenTextBox, { onComplete: () => handleNext("click") }, "fullscreen-text")), (currentBlock === null || currentBlock === void 0 ? void 0 : currentBlock.blockType) === "click_wait" && (_jsx(ClickWaitIndicator, {}, "click-wait")), (currentBlock === null || currentBlock === void 0 ? void 0 : currentBlock.blockType) === "time_wait" && (_jsx(TimeWaitIndicator, { duration: (_d = (_c = currentBlock.options) === null || _c === void 0 ? void 0 : _c.duration) !== null && _d !== void 0 ? _d : 1, onComplete: () => handleNext("time_wait") }, "time-wait")), pluginManager
928
+ .getRegisteredComponents()
929
+ .filter((type) => type !== ComponentType.DialogueBox &&
930
+ type !== ComponentType.ConversationBranch)
931
+ .map((componentType) => (_jsx(PluginComponentProvider, { type: componentType, pluginManager: pluginManager }, componentType)))] }) })] }) }) }) }) })] }))] }));
778
932
  };
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { memo, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { useDataAPI } from "../contexts/DataContext";
4
4
  /**
5
5
  * iOS/macOSかどうかを判定
@@ -56,8 +56,10 @@ function getMediaType(url) {
56
56
  }
57
57
  /**
58
58
  * 背景メディアコンポーネント
59
+ * React.memo でラップして、props が変わらない限り再レンダリングを防ぐ
60
+ * これにより displayText の更新による不要な再描画を防止
59
61
  */
60
- const BackgroundMedia = ({ background, opacity, zIndex, useAppleFormat }) => {
62
+ const BackgroundMedia = memo(function BackgroundMedia({ background, opacity, zIndex, useAppleFormat }) {
61
63
  var _a, _b;
62
64
  // OS判定に基づいて使用するURLを決定
63
65
  // iOS/macOS: mp4 (imageUrl)
@@ -84,7 +86,7 @@ const BackgroundMedia = ({ background, opacity, zIndex, useAppleFormat }) => {
84
86
  transition: "none", // requestAnimationFrameでアニメーション
85
87
  pointerEvents: isHighLayer ? "none" : undefined,
86
88
  }, "data-background-object-id": background.objectId, "data-background-state-id": background.stateId, "data-background-layer": layer, children: mediaType === "video" ? (_jsx("video", { src: videoUrl, className: `w-full h-full ${objectFitClass}`, autoPlay: true, muted: true, playsInline: true, loop: background.loop }, videoUrl)) : (_jsx("img", { src: background.imageUrl, alt: background.stateName, className: `w-full h-full ${objectFitClass}` }, background.imageUrl)) }));
87
- };
89
+ });
88
90
  /**
89
91
  * 背景の配列が同じかどうかを比較
90
92
  */
@@ -107,7 +109,7 @@ function areBackgroundsEqual(a, b) {
107
109
  * プラグインによって置き換え可能
108
110
  * フェードイン・フェードアウト対応
109
111
  */
110
- export const BackgroundLayer = ({ className, }) => {
112
+ export const BackgroundLayer = memo(({ className }) => {
111
113
  const dataAPI = useDataAPI();
112
114
  // Apple環境かどうかを判定(iOS/macOSではmp4を使用)
113
115
  const useAppleFormat = useMemo(() => isApplePlatform(), []);
@@ -214,5 +216,5 @@ export const BackgroundLayer = ({ className, }) => {
214
216
  const zIndex = getLayerZIndex(layer, index);
215
217
  return (_jsx(BackgroundMedia, { background: bg, opacity: fadeState.isFading ? fadeState.fadeProgress : 1, zIndex: zIndex, useAppleFormat: useAppleFormat }, `curr-${bg.objectId}-${bg.stateId}-${index}`));
216
218
  })] }));
217
- };
219
+ });
218
220
  export { getMediaType };
@@ -1,10 +1,16 @@
1
- import type React from "react";
2
1
  import type { DisplayedCharacter, PublishedScenario, ScenarioBlock } from "../types";
3
2
  interface GameScreenProps {
4
3
  scenario: PublishedScenario;
5
4
  currentBlock: ScenarioBlock;
6
5
  previousBlock?: ScenarioBlock | null;
7
6
  displayedCharacters: DisplayedCharacter[];
7
+ inactiveCharacterBrightness?: number;
8
+ characterSpacing?: number;
8
9
  }
9
- export declare const GameScreen: React.FC<GameScreenProps>;
10
+ /**
11
+ * ゲームスクリーンコンポーネント
12
+ * React.memo でラップして、props が変わらない限り再レンダリングを防ぐ
13
+ * これにより displayText の更新による不要な再描画を防止
14
+ */
15
+ export declare const GameScreen: import("react").NamedExoticComponent<GameScreenProps>;
10
16
  export {};
@@ -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.1, }) {
4
9
  var _a;
5
10
  // キャラクターごとのフェード状態を管理
6
11
  const [fadeStates, setFadeStates] = useState(new Map());
@@ -222,6 +227,28 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
222
227
  const currentSpeaker = (currentBlock === null || currentBlock === void 0 ? void 0 : currentBlock.speakerId)
223
228
  ? displayedCharacters.find((char) => char.objectId === currentBlock.speakerId)
224
229
  : null;
230
+ // 簡易モード用の自動配置計算
231
+ const calculateAutoLayout = (characters, spacing) => {
232
+ const total = characters.length;
233
+ const positions = new Map();
234
+ if (total === 0)
235
+ return positions;
236
+ if (total === 1) {
237
+ // 1人の場合は中央
238
+ positions.set(characters[0].objectId, { x: 0, y: 0 });
239
+ return positions;
240
+ }
241
+ // 複数の場合は均等配置
242
+ // spacing: 画面幅に対する割合(例: 0.1 = 10%)
243
+ // 座標系: -1(左端)〜 1(右端)
244
+ const totalWidth = (total - 1) * spacing * 2;
245
+ const startX = -totalWidth / 2;
246
+ characters.forEach((char, index) => {
247
+ const x = startX + index * spacing * 2;
248
+ positions.set(char.objectId, { x, y: 0 });
249
+ });
250
+ return positions;
251
+ };
225
252
  // キャラクター描画用のヘルパー関数
226
253
  const renderCharacter = (image, displayedChar, isCurrentSpeaker, keyPrefix) => {
227
254
  var _a, _b, _c, _d, _e, _f, _g, _h;
@@ -257,7 +284,7 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
257
284
  // 複数キャラクター表示で、現在の話者でない場合
258
285
  if (currentSpeaker &&
259
286
  currentSpeaker.objectId !== displayedChar.objectId) {
260
- brightness = 0.8;
287
+ brightness = inactiveCharacterBrightness;
261
288
  }
262
289
  }
263
290
  // z-indexを決定
@@ -270,9 +297,9 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
270
297
  : ((_d = image.scale) !== null && _d !== void 0 ? _d : 1);
271
298
  // 位置を決定(カスタム位置またはデフォルト位置)
272
299
  // 新座標系: x: -1=左見切れ, 0=中央, 1=右見切れ / y: -1=上見切れ, 0=中央, 1=下見切れ
273
- let finalPosition = { x: 0, y: 1.0 }; // デフォルトは中央、下端
300
+ let finalPosition = { x: 0, y: 0 };
274
301
  if (displayedChar) {
275
- // カスタム位置が設定されている場合
302
+ // カスタム位置が設定されている場合(詳細モード)
276
303
  if (displayedChar.positionX !== null &&
277
304
  displayedChar.positionY !== null) {
278
305
  finalPosition = {
@@ -280,7 +307,14 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
280
307
  y: (_f = displayedChar.positionY) !== null && _f !== void 0 ? _f : 0,
281
308
  };
282
309
  }
283
- // カスタム位置が設定されていない場合はデフォルト位置のまま
310
+ else {
311
+ // 簡易モード: 自動配置
312
+ const autoPositions = calculateAutoLayout(displayedCharacters, characterSpacing !== null && characterSpacing !== void 0 ? characterSpacing : 0.25);
313
+ const autoPos = autoPositions.get(displayedChar.objectId);
314
+ if (autoPos) {
315
+ finalPosition = autoPos;
316
+ }
317
+ }
284
318
  }
285
319
  // コンテナ相対の位置計算(パーセンテージ使用)
286
320
  // positionX: -1.0 = 完全に左に見切れ, 0.0 = 中央, 1.0 = 完全に右に見切れ
@@ -390,4 +424,4 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
390
424
  return null;
391
425
  return renderCharacter(image, null, false, "fadeout");
392
426
  })()] }))] }) }));
393
- };
427
+ });
@@ -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", { 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
  };
@@ -8,7 +8,7 @@ interface PluginComponentProviderProps {
8
8
  }
9
9
  /**
10
10
  * Wrapper component that renders a plugin-registered component or a fallback
11
- * All data is provided via DataContext, so no props are passed
11
+ * DataAPI is provided via props to avoid React context issues across different React instances
12
12
  */
13
13
  export declare function PluginComponentProvider({ type, pluginManager, fallback: FallbackComponent, }: PluginComponentProviderProps): import("react/jsx-runtime").JSX.Element;
14
14
  export {};