@luna-editor/engine 0.2.0 → 0.3.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.
Files changed (64) hide show
  1. package/dist/Player.d.ts +1 -1
  2. package/dist/Player.js +504 -77
  3. package/dist/api/conversationBranch.d.ts +4 -0
  4. package/dist/api/conversationBranch.js +83 -0
  5. package/dist/components/BackgroundLayer.d.ts +19 -0
  6. package/dist/components/BackgroundLayer.js +218 -0
  7. package/dist/components/ClickWaitIndicator.d.ts +10 -0
  8. package/dist/components/ClickWaitIndicator.js +31 -0
  9. package/dist/components/ConversationBranchBox.d.ts +2 -0
  10. package/dist/components/ConversationBranchBox.js +29 -0
  11. package/dist/components/DialogueBox.js +16 -1
  12. package/dist/components/FontSettingsPanel.d.ts +10 -0
  13. package/dist/components/FontSettingsPanel.js +30 -0
  14. package/dist/components/FullscreenTextBox.d.ts +6 -0
  15. package/dist/components/FullscreenTextBox.js +70 -0
  16. package/dist/components/GameScreen.d.ts +1 -0
  17. package/dist/components/GameScreen.js +363 -81
  18. package/dist/components/PluginComponentProvider.d.ts +2 -2
  19. package/dist/components/PluginComponentProvider.js +3 -3
  20. package/dist/components/TimeWaitIndicator.d.ts +15 -0
  21. package/dist/components/TimeWaitIndicator.js +17 -0
  22. package/dist/contexts/AudioContext.d.ts +1 -0
  23. package/dist/contexts/AudioContext.js +1 -0
  24. package/dist/contexts/DataContext.js +69 -11
  25. package/dist/hooks/useBacklog.js +3 -0
  26. package/dist/hooks/useConversationBranch.d.ts +16 -0
  27. package/dist/hooks/useConversationBranch.js +125 -0
  28. package/dist/hooks/useFontLoader.d.ts +23 -0
  29. package/dist/hooks/useFontLoader.js +153 -0
  30. package/dist/hooks/useFullscreenText.d.ts +17 -0
  31. package/dist/hooks/useFullscreenText.js +120 -0
  32. package/dist/hooks/usePlayerLogic.d.ts +10 -3
  33. package/dist/hooks/usePlayerLogic.js +115 -18
  34. package/dist/hooks/usePluginEvents.d.ts +4 -1
  35. package/dist/hooks/usePluginEvents.js +16 -11
  36. package/dist/hooks/usePreloadImages.js +27 -7
  37. package/dist/hooks/useSoundPlayer.d.ts +15 -0
  38. package/dist/hooks/useSoundPlayer.js +209 -0
  39. package/dist/hooks/useTypewriter.d.ts +6 -2
  40. package/dist/hooks/useTypewriter.js +42 -6
  41. package/dist/hooks/useVoice.js +4 -1
  42. package/dist/index.d.ts +4 -3
  43. package/dist/index.js +2 -1
  44. package/dist/plugin/PluginManager.d.ts +66 -2
  45. package/dist/plugin/PluginManager.js +349 -79
  46. package/dist/sdk.d.ts +178 -21
  47. package/dist/sdk.js +27 -2
  48. package/dist/types.d.ts +288 -4
  49. package/dist/utils/branchBlockConverter.d.ts +2 -0
  50. package/dist/utils/branchBlockConverter.js +21 -0
  51. package/dist/utils/branchNavigator.d.ts +14 -0
  52. package/dist/utils/branchNavigator.js +55 -0
  53. package/dist/utils/facePositionCalculator.js +0 -1
  54. package/dist/utils/variableManager.d.ts +18 -0
  55. package/dist/utils/variableManager.js +159 -0
  56. package/package.json +1 -1
  57. package/dist/components/ConversationLogUI.d.ts +0 -2
  58. package/dist/components/ConversationLogUI.js +0 -115
  59. package/dist/hooks/useConversationLog.d.ts +0 -14
  60. package/dist/hooks/useConversationLog.js +0 -82
  61. package/dist/hooks/useUIVisibility.d.ts +0 -9
  62. package/dist/hooks/useUIVisibility.js +0 -19
  63. package/dist/plugin/luna-react.d.ts +0 -41
  64. package/dist/plugin/luna-react.js +0 -99
@@ -22,6 +22,8 @@ const calculateDisplayedCharacters = (blocks, currentIndex) => {
22
22
  scale: char.scale,
23
23
  object: char.object,
24
24
  entityState: char.entityState,
25
+ // レイヤー機能用
26
+ baseBodyState: char.baseBodyState,
25
27
  }));
26
28
  characters = [
27
29
  ...characters.filter((existing) => !newCharacters.some((newChar) => newChar.objectId === existing.objectId)),
@@ -29,20 +31,70 @@ const calculateDisplayedCharacters = (blocks, currentIndex) => {
29
31
  ];
30
32
  }
31
33
  break;
32
- case "character_exit":
33
- // character_exitに到達したら、キャラクター表示範囲外
34
- return [];
35
34
  case "dialogue":
36
35
  if (processBlock.speakerId && processBlock.speakerStateId) {
37
36
  characters = characters.map((char) => {
38
- var _a, _b, _c, _d;
37
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
39
38
  return char.objectId === processBlock.speakerId
40
39
  ? Object.assign(Object.assign({}, char), { entityStateId: processBlock.speakerStateId || "", entityState: {
41
40
  id: (_a = processBlock.speakerStateId) !== null && _a !== void 0 ? _a : "",
42
41
  name: ((_b = processBlock.speakerState) === null || _b === void 0 ? void 0 : _b.name) || "",
43
42
  imageUrl: ((_c = processBlock.speakerState) === null || _c === void 0 ? void 0 : _c.imageUrl) || null,
44
43
  cropArea: ((_d = processBlock.speakerState) === null || _d === void 0 ? void 0 : _d.cropArea) || null,
45
- } }) : char;
44
+ scale: (_e = processBlock.speakerState) === null || _e === void 0 ? void 0 : _e.scale,
45
+ translateX: (_f = processBlock.speakerState) === null || _f === void 0 ? void 0 : _f.translateX,
46
+ translateY: (_g = processBlock.speakerState) === null || _g === void 0 ? void 0 : _g.translateY,
47
+ isDefault: (_h = processBlock.speakerState) === null || _h === void 0 ? void 0 : _h.isDefault,
48
+ // speakerStateにレイヤー情報があればそれを使用、なければ既存を保持
49
+ layers: (_k = (_j = processBlock.speakerState) === null || _j === void 0 ? void 0 : _j.layers) !== null && _k !== void 0 ? _k : char.entityState.layers,
50
+ },
51
+ // baseBodyStateを保持
52
+ baseBodyState: char.baseBodyState }) : char;
53
+ });
54
+ }
55
+ break;
56
+ case "character_state_change":
57
+ // 状態変更ブロック: 登場済みキャラクターの状態のみを変更(位置は維持)
58
+ // characters配列がある場合はそれを使用(複数キャラクター対応)
59
+ if (processBlock.characters && processBlock.characters.length > 0) {
60
+ for (const stateChange of processBlock.characters) {
61
+ characters = characters.map((char) => {
62
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
63
+ return char.objectId === stateChange.objectId
64
+ ? Object.assign(Object.assign({}, char), { entityStateId: stateChange.entityStateId, entityState: {
65
+ id: stateChange.entityStateId,
66
+ name: ((_a = stateChange.entityState) === null || _a === void 0 ? void 0 : _a.name) || "",
67
+ imageUrl: ((_b = stateChange.entityState) === null || _b === void 0 ? void 0 : _b.imageUrl) || null,
68
+ cropArea: ((_c = stateChange.entityState) === null || _c === void 0 ? void 0 : _c.cropArea) || null,
69
+ scale: (_d = stateChange.entityState) === null || _d === void 0 ? void 0 : _d.scale,
70
+ translateX: (_e = stateChange.entityState) === null || _e === void 0 ? void 0 : _e.translateX,
71
+ translateY: (_f = stateChange.entityState) === null || _f === void 0 ? void 0 : _f.translateY,
72
+ isDefault: (_g = stateChange.entityState) === null || _g === void 0 ? void 0 : _g.isDefault,
73
+ layers: (_j = (_h = stateChange.entityState) === null || _h === void 0 ? void 0 : _h.layers) !== null && _j !== void 0 ? _j : char.entityState.layers,
74
+ },
75
+ // baseBodyStateと位置情報を保持
76
+ baseBodyState: char.baseBodyState }) : char;
77
+ });
78
+ }
79
+ }
80
+ else if (processBlock.speakerId && processBlock.speakerStateId) {
81
+ // speakerId/speakerStateIdを使用した単一キャラクター状態変更(インラインエディタ形式)
82
+ characters = characters.map((char) => {
83
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
84
+ return char.objectId === processBlock.speakerId
85
+ ? Object.assign(Object.assign({}, char), { entityStateId: processBlock.speakerStateId || "", entityState: {
86
+ id: (_a = processBlock.speakerStateId) !== null && _a !== void 0 ? _a : "",
87
+ name: ((_b = processBlock.speakerState) === null || _b === void 0 ? void 0 : _b.name) || "",
88
+ imageUrl: ((_c = processBlock.speakerState) === null || _c === void 0 ? void 0 : _c.imageUrl) || null,
89
+ cropArea: ((_d = processBlock.speakerState) === null || _d === void 0 ? void 0 : _d.cropArea) || null,
90
+ scale: (_e = processBlock.speakerState) === null || _e === void 0 ? void 0 : _e.scale,
91
+ translateX: (_f = processBlock.speakerState) === null || _f === void 0 ? void 0 : _f.translateX,
92
+ translateY: (_g = processBlock.speakerState) === null || _g === void 0 ? void 0 : _g.translateY,
93
+ isDefault: (_h = processBlock.speakerState) === null || _h === void 0 ? void 0 : _h.isDefault,
94
+ layers: (_k = (_j = processBlock.speakerState) === null || _j === void 0 ? void 0 : _j.layers) !== null && _k !== void 0 ? _k : char.entityState.layers,
95
+ },
96
+ // baseBodyStateと位置情報を保持
97
+ baseBodyState: char.baseBodyState }) : char;
46
98
  });
47
99
  }
48
100
  break;
@@ -50,36 +102,57 @@ const calculateDisplayedCharacters = (blocks, currentIndex) => {
50
102
  }
51
103
  break; // 最初のcharacter_entranceを見つけたら終了
52
104
  }
53
- if (block.blockType === "character_exit") {
54
- // character_exitを見つけたら、キャラクター表示範囲外
55
- return [];
56
- }
57
105
  }
58
106
  return characters;
59
107
  };
60
- export const usePlayerLogic = ({ state, setState, scenario, isTyping, currentBlock, skipTyping, onEnd, onScenarioEnd, autoplay, }) => {
108
+ export const usePlayerLogic = ({ state, setState, scenario, isTyping, currentBlock, skipTyping, onEnd, onScenarioEnd, autoplay, branchState, branchNavigator: _branchNavigator, customRestart, variableManager, disableKeyboardNavigation = false, }) => {
61
109
  const isLastBlock = state.currentBlockIndex === scenario.blocks.length - 1;
62
110
  // 現在のインデックスに基づいて表示すべきキャラクターを計算
63
111
  const displayedCharacters = useMemo(() => calculateDisplayedCharacters(scenario.blocks, state.currentBlockIndex), [scenario.blocks, state.currentBlockIndex]);
64
112
  const handleNext = useCallback(() => {
65
- var _a;
113
+ var _a, _b;
66
114
  // タイピング中の場合はスキップ
67
115
  if (isTyping && currentBlock) {
68
- skipTyping(currentBlock.content || "");
116
+ skipTyping();
69
117
  return;
70
118
  }
119
+ // 分岐ブロックでの進行制御
120
+ if ((currentBlock === null || currentBlock === void 0 ? void 0 : currentBlock.blockType) === "conversation_branch") {
121
+ // エラー時は次のブロックに進む
122
+ if (branchState === null || branchState === void 0 ? void 0 : branchState.errorState) {
123
+ // 進行を許可
124
+ }
125
+ else if (!(branchState === null || branchState === void 0 ? void 0 : branchState.selectedChoiceId)) {
126
+ // ローディング中または選択肢未選択の場合は停止
127
+ return;
128
+ }
129
+ }
71
130
  if (isLastBlock) {
72
131
  setState((prev) => (Object.assign(Object.assign({}, prev), { isEnded: true, isPlaying: false })));
73
132
  onEnd === null || onEnd === void 0 ? void 0 : onEnd();
74
133
  onScenarioEnd === null || onScenarioEnd === void 0 ? void 0 : onScenarioEnd();
75
- // iframe向けのイベント発火
76
134
  if (typeof window !== "undefined") {
77
135
  window.postMessage({ type: "scenario-end" }, "*");
78
136
  (_a = window.parent) === null || _a === void 0 ? void 0 : _a.postMessage({ type: "scenario-end" }, "*");
79
137
  }
80
138
  }
81
139
  else {
82
- setState((prev) => (Object.assign(Object.assign({}, prev), { currentBlockIndex: prev.currentBlockIndex + 1 })));
140
+ const nextIndex = state.currentBlockIndex + 1;
141
+ const nextBlock = scenario.blocks[nextIndex];
142
+ if ((nextBlock === null || nextBlock === void 0 ? void 0 : nextBlock.blockType) === "variable_operation" && variableManager) {
143
+ const operation = (_b = scenario.variableOperations) === null || _b === void 0 ? void 0 : _b.find((op) => op.blockId === nextBlock.id);
144
+ if (operation) {
145
+ variableManager.executeOperation(operation);
146
+ setState((prev) => (Object.assign(Object.assign({}, prev), { currentBlockIndex: nextIndex, variables: variableManager.getVariablesMap() })));
147
+ }
148
+ else {
149
+ console.error("変数操作ブロックに対応する操作が見つかりません:", nextBlock.id);
150
+ setState((prev) => (Object.assign(Object.assign({}, prev), { currentBlockIndex: nextIndex })));
151
+ }
152
+ }
153
+ else {
154
+ setState((prev) => (Object.assign(Object.assign({}, prev), { currentBlockIndex: nextIndex })));
155
+ }
83
156
  }
84
157
  }, [
85
158
  isLastBlock,
@@ -89,6 +162,11 @@ export const usePlayerLogic = ({ state, setState, scenario, isTyping, currentBlo
89
162
  currentBlock,
90
163
  skipTyping,
91
164
  setState,
165
+ branchState,
166
+ state.currentBlockIndex,
167
+ scenario.blocks,
168
+ scenario.variableOperations,
169
+ variableManager,
92
170
  ]);
93
171
  const handlePrevious = useCallback(() => {
94
172
  if (state.currentBlockIndex > 0) {
@@ -101,15 +179,22 @@ export const usePlayerLogic = ({ state, setState, scenario, isTyping, currentBlo
101
179
  setState((prev) => (Object.assign(Object.assign({}, prev), { isPlaying: !prev.isPlaying })));
102
180
  }, [state.isEnded, setState]);
103
181
  const restart = useCallback(() => {
104
- console.log("restart");
182
+ if (variableManager) {
183
+ variableManager.reset();
184
+ }
105
185
  setState({
106
186
  currentBlockIndex: 0,
107
187
  isPlaying: autoplay,
108
188
  isEnded: false,
189
+ variables: variableManager === null || variableManager === void 0 ? void 0 : variableManager.getVariablesMap(),
109
190
  });
110
- }, [autoplay, setState]);
191
+ }, [autoplay, setState, variableManager]);
111
192
  // キーボードナビゲーション
112
193
  useEffect(() => {
194
+ // プレビューモードなど、キーボードナビゲーションを無効化する場合はリスナーを追加しない
195
+ if (disableKeyboardNavigation) {
196
+ return;
197
+ }
113
198
  const handleKeyDown = (event) => {
114
199
  switch (event.key) {
115
200
  case "ArrowRight":
@@ -127,13 +212,25 @@ export const usePlayerLogic = ({ state, setState, scenario, isTyping, currentBlo
127
212
  break;
128
213
  case "Escape":
129
214
  event.preventDefault();
130
- restart();
215
+ if (customRestart) {
216
+ customRestart();
217
+ }
218
+ else {
219
+ restart();
220
+ }
131
221
  break;
132
222
  }
133
223
  };
134
224
  window.addEventListener("keydown", handleKeyDown);
135
225
  return () => window.removeEventListener("keydown", handleKeyDown);
136
- }, [handleNext, handlePrevious, togglePlay, restart]);
226
+ }, [
227
+ handleNext,
228
+ handlePrevious,
229
+ togglePlay,
230
+ restart,
231
+ customRestart,
232
+ disableKeyboardNavigation,
233
+ ]);
137
234
  return {
138
235
  handleNext,
139
236
  handlePrevious,
@@ -1,4 +1,5 @@
1
1
  import type { PluginManager } from "../plugin/PluginManager";
2
+ import type { TransitionSource } from "../sdk";
2
3
  import type { DisplayedCharacter, ScenarioBlock } from "../types";
3
4
  interface UsePluginEventsProps {
4
5
  pluginManager: PluginManager;
@@ -9,6 +10,8 @@ interface UsePluginEventsProps {
9
10
  allBlocks: ScenarioBlock[];
10
11
  realBlockIndex: number;
11
12
  pluginsLoaded: boolean;
13
+ /** 遷移の種類(クリック、自動、時間待ち) */
14
+ transitionSource?: TransitionSource;
12
15
  }
13
- export declare const usePluginEvents: ({ pluginManager, currentBlock, displayedCharacters, blockIndex, isFirstRenderComplete, allBlocks, realBlockIndex, pluginsLoaded, }: UsePluginEventsProps) => void;
16
+ export declare const usePluginEvents: ({ pluginManager, currentBlock, displayedCharacters, blockIndex, isFirstRenderComplete, allBlocks, realBlockIndex, pluginsLoaded, transitionSource, }: UsePluginEventsProps) => void;
14
17
  export {};
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useRef } from "react";
2
2
  import { normalizeAttributes } from "../utils/attributeNormalizer";
3
3
  import { calculateFacePosition } from "../utils/facePositionCalculator";
4
- export const usePluginEvents = ({ pluginManager, currentBlock, displayedCharacters, blockIndex, isFirstRenderComplete, allBlocks, realBlockIndex, pluginsLoaded, }) => {
4
+ export const usePluginEvents = ({ pluginManager, currentBlock, displayedCharacters, blockIndex, isFirstRenderComplete, allBlocks, realBlockIndex, pluginsLoaded, transitionSource = "click", }) => {
5
5
  const previousSpeakerRef = useRef({});
6
6
  const previousRealBlockIndexRef = useRef(-1);
7
7
  const scenarioReadyCalledRef = useRef(false);
@@ -45,26 +45,22 @@ export const usePluginEvents = ({ pluginManager, currentBlock, displayedCharacte
45
45
  const startIndex = previousIndex === -1 ? 0 : previousIndex + 1;
46
46
  for (let i = startIndex; i <= currentIndex; i++) {
47
47
  const block = allBlocks[i];
48
- console.log("block", block);
49
48
  if ((block === null || block === void 0 ? void 0 : block.blockType) === "action_node" && block.actionNode) {
50
- // Normalize attributes from database format
51
49
  const normalizedAttributes = normalizeAttributes(block);
52
- console.log("Normalized attributes:", normalizedAttributes);
53
50
  // Try multiple ways to find the ActionNode type - prioritize displayName matching
54
51
  const possibleTypes = [
55
52
  block.actionNode.nodeType,
56
53
  block.actionNode.name, // This will be "背景" from database
57
54
  ].filter(Boolean);
58
55
  let actionNodeDef;
59
- let foundActionNodeType;
56
+ let _foundActionNodeType;
60
57
  // First try direct type matching
61
58
  for (const actionNodeType of possibleTypes) {
62
59
  if (actionNodeType) {
63
60
  actionNodeDef =
64
61
  pluginManager.getActionNodeDefinition(actionNodeType);
65
62
  if (actionNodeDef) {
66
- foundActionNodeType = actionNodeType;
67
- console.log(`Found ActionNode definition with type: ${actionNodeType}`);
63
+ _foundActionNodeType = actionNodeType;
68
64
  break;
69
65
  }
70
66
  }
@@ -73,8 +69,7 @@ export const usePluginEvents = ({ pluginManager, currentBlock, displayedCharacte
73
69
  if (!actionNodeDef && block.actionNode.name) {
74
70
  actionNodeDef = pluginManager.getActionNodeDefinition(block.actionNode.name);
75
71
  if (actionNodeDef) {
76
- foundActionNodeType = block.actionNode.name;
77
- console.log(`Found ActionNode definition with displayName: ${block.actionNode.name}`);
72
+ _foundActionNodeType = block.actionNode.name;
78
73
  }
79
74
  }
80
75
  if (actionNodeDef === null || actionNodeDef === void 0 ? void 0 : actionNodeDef.execute) {
@@ -88,7 +83,6 @@ export const usePluginEvents = ({ pluginManager, currentBlock, displayedCharacte
88
83
  displayedCharacters,
89
84
  api: pluginManager,
90
85
  });
91
- console.log(`Successfully executed ActionNode: ${foundActionNodeType}`);
92
86
  }
93
87
  catch (error) {
94
88
  console.error("Error executing ActionNode:", error);
@@ -117,21 +111,32 @@ export const usePluginEvents = ({ pluginManager, currentBlock, displayedCharacte
117
111
  isFirstRenderComplete,
118
112
  pluginsLoaded,
119
113
  ]);
120
- // ブロック変更イベント
114
+ // ブロック変更イベント + ビルトインクリック音
121
115
  useEffect(() => {
122
116
  if (!isFirstRenderComplete || !currentBlock || !pluginsLoaded) {
123
117
  return;
124
118
  }
119
+ // まずプラグインのオーバーライドフラグをリセット
120
+ pluginManager.resetClickSoundOverride();
121
+ // プラグインのonBlockChangeフックを呼び出す
122
+ // プラグインはここでoverrideClickSound()を呼び出してビルトインクリック音を抑制できる
125
123
  pluginManager.callHook("onBlockChange", {
126
124
  currentBlock,
127
125
  blockIndex,
126
+ transitionSource,
128
127
  });
128
+ // 時間待ちからの自動遷移でない場合のみ、ビルトインクリック音を再生
129
+ // プラグインがoverrideClickSound()を呼び出した場合は再生されない
130
+ if (transitionSource !== "time_wait" && transitionSource !== "auto") {
131
+ pluginManager.playClickSound();
132
+ }
129
133
  }, [
130
134
  pluginManager,
131
135
  currentBlock,
132
136
  blockIndex,
133
137
  isFirstRenderComplete,
134
138
  pluginsLoaded,
139
+ transitionSource,
135
140
  ]);
136
141
  // キャラクター発話イベント
137
142
  useEffect(() => {
@@ -1,6 +1,8 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
2
  export const usePreloadImages = (scenario) => {
3
3
  const [isLoaded, setIsLoaded] = useState(false);
4
+ // プリロード済みの画像URLセットを追跡(シナリオ変更で再プリロードを避ける)
5
+ const preloadedUrlsRef = useRef(new Set());
4
6
  useEffect(() => {
5
7
  const imageUrls = new Set();
6
8
  // シナリオ全体から画像URLを収集
@@ -24,12 +26,23 @@ export const usePreloadImages = (scenario) => {
24
26
  setIsLoaded(true);
25
27
  return;
26
28
  }
29
+ // 未プリロードの画像のみをフィルタリング
30
+ const newImageUrls = Array.from(imageUrls).filter((url) => !preloadedUrlsRef.current.has(url));
31
+ // すべてプリロード済みの場合は即座に完了
32
+ if (newImageUrls.length === 0) {
33
+ setIsLoaded(true);
34
+ return;
35
+ }
36
+ // 新しい画像がある場合はローディング状態にリセット
37
+ setIsLoaded(false);
27
38
  let loadedCount = 0;
28
- const totalCount = imageUrls.size;
39
+ const totalCount = newImageUrls.length;
40
+ const cleanupFunctions = [];
29
41
  // 各画像を事前読み込み
30
- imageUrls.forEach((url) => {
42
+ for (const url of newImageUrls) {
31
43
  const img = new Image();
32
44
  const handleLoad = () => {
45
+ preloadedUrlsRef.current.add(url);
33
46
  loadedCount++;
34
47
  if (loadedCount === totalCount) {
35
48
  setIsLoaded(true);
@@ -37,6 +50,7 @@ export const usePreloadImages = (scenario) => {
37
50
  };
38
51
  const handleError = () => {
39
52
  console.error(`Failed to preload image: ${url}`);
53
+ preloadedUrlsRef.current.add(url); // エラーでもプリロード済みとしてマーク
40
54
  loadedCount++;
41
55
  if (loadedCount === totalCount) {
42
56
  setIsLoaded(true);
@@ -45,12 +59,18 @@ export const usePreloadImages = (scenario) => {
45
59
  img.addEventListener("load", handleLoad);
46
60
  img.addEventListener("error", handleError);
47
61
  img.src = url;
48
- // クリーンアップ関数で画像を破棄
49
- return () => {
62
+ // クリーンアップ関数を配列に追加
63
+ cleanupFunctions.push(() => {
50
64
  img.removeEventListener("load", handleLoad);
51
65
  img.removeEventListener("error", handleError);
52
- };
53
- });
66
+ });
67
+ }
68
+ // クリーンアップ時にすべてのイベントリスナーを解除
69
+ return () => {
70
+ for (const cleanup of cleanupFunctions) {
71
+ cleanup();
72
+ }
73
+ };
54
74
  }, [scenario]);
55
75
  return isLoaded;
56
76
  };
@@ -0,0 +1,15 @@
1
+ import type { ScenarioBlock } from "../types";
2
+ interface UseSoundPlayerProps {
3
+ soundBlocks: ScenarioBlock[];
4
+ isFirstRenderComplete: boolean;
5
+ /** 音声をミュートするかどうか */
6
+ muteAudio?: boolean;
7
+ }
8
+ export declare const useSoundPlayer: ({ soundBlocks, isFirstRenderComplete, muteAudio, }: UseSoundPlayerProps) => {
9
+ playSound: (block: ScenarioBlock) => void;
10
+ stopBGM: () => void;
11
+ stopSE: (soundId: string) => void;
12
+ stopAllSE: () => void;
13
+ stopAll: () => void;
14
+ };
15
+ export {};
@@ -0,0 +1,209 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import { useAudioSettings } from "../contexts/AudioContext";
3
+ export const useSoundPlayer = ({ soundBlocks, isFirstRenderComplete, muteAudio = false, }) => {
4
+ const audioSettings = useAudioSettings();
5
+ // 現在再生中のBGM
6
+ const bgmRef = useRef(null);
7
+ // 現在再生中のSE(複数同時再生可能)
8
+ const seInstancesRef = useRef(new Map());
9
+ // 前回処理したブロックIDセット(重複処理防止)
10
+ const processedBlockIdsRef = useRef(new Set());
11
+ // BGMかどうかを判定(SoundTypeのcategoryで判断)
12
+ const isBGM = useCallback((block) => {
13
+ var _a;
14
+ if (!((_a = block.sound) === null || _a === void 0 ? void 0 : _a.soundType))
15
+ return false;
16
+ return block.sound.soundType.category === "bgm";
17
+ }, []);
18
+ // Voiceかどうかを判定
19
+ const isVoice = useCallback((block) => {
20
+ var _a;
21
+ if (!((_a = block.sound) === null || _a === void 0 ? void 0 : _a.soundType))
22
+ return false;
23
+ return block.sound.soundType.category === "voice";
24
+ }, []);
25
+ // 音量を適用
26
+ const applyVolume = useCallback((audio, block) => {
27
+ if (isBGM(block)) {
28
+ audio.volume = audioSettings.bgmVolume;
29
+ }
30
+ else if (isVoice(block)) {
31
+ audio.volume = audioSettings.voiceVolume;
32
+ }
33
+ else {
34
+ audio.volume = audioSettings.seVolume;
35
+ }
36
+ }, [
37
+ audioSettings.bgmVolume,
38
+ audioSettings.seVolume,
39
+ audioSettings.voiceVolume,
40
+ isBGM,
41
+ isVoice,
42
+ ]);
43
+ // BGMを停止
44
+ const stopBGM = useCallback(() => {
45
+ if (bgmRef.current) {
46
+ bgmRef.current.audio.pause();
47
+ bgmRef.current.audio.currentTime = 0;
48
+ bgmRef.current = null;
49
+ }
50
+ }, []);
51
+ // 特定のSEを停止
52
+ const stopSE = useCallback((soundId) => {
53
+ const instance = seInstancesRef.current.get(soundId);
54
+ if (instance) {
55
+ instance.audio.pause();
56
+ instance.audio.currentTime = 0;
57
+ seInstancesRef.current.delete(soundId);
58
+ }
59
+ }, []);
60
+ // 全てのSEを停止
61
+ const stopAllSE = useCallback(() => {
62
+ for (const instance of seInstancesRef.current.values()) {
63
+ instance.audio.pause();
64
+ instance.audio.currentTime = 0;
65
+ }
66
+ seInstancesRef.current.clear();
67
+ }, []);
68
+ // 全ての音を停止
69
+ const stopAll = useCallback(() => {
70
+ stopBGM();
71
+ stopAllSE();
72
+ }, [stopBGM, stopAllSE]);
73
+ // サウンドを再生
74
+ const playSound = useCallback((block) => {
75
+ if (!block.sound)
76
+ return;
77
+ // ミュート中は再生しない(propsまたはaudioSettings)
78
+ if (muteAudio || audioSettings.muteAudio)
79
+ return;
80
+ const { sound } = block;
81
+ const isBGMSound = isBGM(block);
82
+ // BGMの場合、既存のBGMを停止
83
+ if (isBGMSound) {
84
+ stopBGM();
85
+ }
86
+ const audio = new Audio(sound.url);
87
+ audio.loop = sound.loop;
88
+ applyVolume(audio, block);
89
+ const category = block.sound.soundType.category;
90
+ // エラーハンドリング(イベントリスナーを保持してクリーンアップ可能にする)
91
+ const handleError = () => {
92
+ var _a;
93
+ console.warn(`Sound load failed: ${sound.name} (${sound.url})`);
94
+ // 失敗したインスタンスをクリーンアップ
95
+ if (isBGMSound && ((_a = bgmRef.current) === null || _a === void 0 ? void 0 : _a.soundId) === sound.id) {
96
+ bgmRef.current = null;
97
+ }
98
+ else {
99
+ seInstancesRef.current.delete(sound.id);
100
+ }
101
+ // イベントリスナーを削除
102
+ audio.removeEventListener("error", handleError);
103
+ };
104
+ audio.addEventListener("error", handleError);
105
+ const instance = {
106
+ audio,
107
+ soundId: sound.id,
108
+ category,
109
+ };
110
+ if (isBGMSound) {
111
+ bgmRef.current = instance;
112
+ }
113
+ else {
114
+ // SE/Voiceは既に同じIDのものがあれば停止して再生
115
+ // 注意: 同一サウンドの重複再生は意図的に防止している
116
+ // 連打などによる音の重複を避けるため
117
+ stopSE(sound.id);
118
+ seInstancesRef.current.set(sound.id, instance);
119
+ // SE終了時にMapから削除(イベントリスナーも削除)
120
+ const handleEnded = () => {
121
+ seInstancesRef.current.delete(sound.id);
122
+ audio.removeEventListener("ended", handleEnded);
123
+ };
124
+ audio.addEventListener("ended", handleEnded);
125
+ }
126
+ audio.play().catch((error) => {
127
+ // ネット作品エラーやソース不対応時のエラーをより詳細にログ
128
+ if (error.name === "NotSupportedError") {
129
+ console.warn(`Sound source not available: ${sound.name}`);
130
+ }
131
+ else {
132
+ console.error("Sound playback error:", error);
133
+ }
134
+ });
135
+ }, [isBGM, stopBGM, stopSE, applyVolume, muteAudio, audioSettings.muteAudio]);
136
+ // soundBlocksが変更された時にprocessedBlockIdsをクリア(シナリオリセット対応)
137
+ useEffect(() => {
138
+ // soundBlocksが空になったらprocessedBlockIdsをクリア
139
+ if (soundBlocks.length === 0) {
140
+ processedBlockIdsRef.current.clear();
141
+ }
142
+ }, [soundBlocks]);
143
+ // 複数のサウンドブロックを順番に処理
144
+ useEffect(() => {
145
+ if (!isFirstRenderComplete)
146
+ return;
147
+ for (const block of soundBlocks) {
148
+ // 既に処理済みのブロックはスキップ
149
+ if (processedBlockIdsRef.current.has(block.id))
150
+ continue;
151
+ // 処理済みとしてマーク
152
+ processedBlockIdsRef.current.add(block.id);
153
+ switch (block.blockType) {
154
+ case "bgm_play":
155
+ case "se_play":
156
+ if (block.sound) {
157
+ playSound(block);
158
+ }
159
+ break;
160
+ case "bgm_stop":
161
+ // bgm_stopブロックはBGMを停止する
162
+ stopBGM();
163
+ break;
164
+ }
165
+ }
166
+ }, [soundBlocks, isFirstRenderComplete, playSound, stopBGM]);
167
+ // 音量設定の変更を反映
168
+ useEffect(() => {
169
+ if (bgmRef.current) {
170
+ bgmRef.current.audio.volume = audioSettings.bgmVolume;
171
+ }
172
+ for (const instance of seInstancesRef.current.values()) {
173
+ if (instance.category === "voice") {
174
+ instance.audio.volume = audioSettings.voiceVolume;
175
+ }
176
+ else {
177
+ instance.audio.volume = audioSettings.seVolume;
178
+ }
179
+ }
180
+ }, [
181
+ audioSettings.bgmVolume,
182
+ audioSettings.seVolume,
183
+ audioSettings.voiceVolume,
184
+ ]);
185
+ // クリーンアップ(直接refにアクセスして依存配列の問題を回避)
186
+ useEffect(() => {
187
+ return () => {
188
+ // BGMを停止
189
+ if (bgmRef.current) {
190
+ bgmRef.current.audio.pause();
191
+ bgmRef.current.audio.currentTime = 0;
192
+ bgmRef.current = null;
193
+ }
194
+ // 全てのSEを停止
195
+ for (const instance of seInstancesRef.current.values()) {
196
+ instance.audio.pause();
197
+ instance.audio.currentTime = 0;
198
+ }
199
+ seInstancesRef.current.clear();
200
+ };
201
+ }, []);
202
+ return {
203
+ playSound,
204
+ stopBGM,
205
+ stopSE,
206
+ stopAllSE,
207
+ stopAll,
208
+ };
209
+ };
@@ -1,3 +1,5 @@
1
+ /** 連続モード: false = 連続しない, "continue" = 連続(改行なし), "continueWithNewline" = 連続(改行あり) */
2
+ export type ContinueMode = false | "continue" | "continueWithNewline";
1
3
  export interface UseTypewriterOptions {
2
4
  speed?: number;
3
5
  onComplete?: () => void;
@@ -5,7 +7,9 @@ export interface UseTypewriterOptions {
5
7
  export declare const useTypewriter: (options?: UseTypewriterOptions) => {
6
8
  displayText: string;
7
9
  isTyping: boolean;
8
- startTyping: (text: string) => void;
9
- skipTyping: (text: string) => void;
10
+ startTyping: (text: string, continueMode?: ContinueMode) => void;
11
+ skipTyping: () => void;
10
12
  reset: () => void;
13
+ resetAccumulated: () => void;
14
+ accumulatedText: string;
11
15
  };