@luna-editor/engine 0.5.13 → 0.5.15

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
@@ -34,6 +34,7 @@ import { usePluginEvents } from "./hooks/usePluginEvents";
34
34
  import { usePreloadImages } from "./hooks/usePreloadImages";
35
35
  import { useSoundPlayer } from "./hooks/useSoundPlayer";
36
36
  import { useTypewriter } from "./hooks/useTypewriter";
37
+ import { useVoice } from "./hooks/useVoice";
37
38
  import { PluginManager } from "./plugin/PluginManager";
38
39
  import { ComponentType } from "./sdk";
39
40
  import { convertBranchBlockToScenarioBlock } from "./utils/branchBlockConverter";
@@ -410,7 +411,7 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
410
411
  }, 100);
411
412
  return () => clearTimeout(timeoutId);
412
413
  }, [pluginManager, isFirstRenderComplete]);
413
- const { displayText, isTyping, skipTyping, startTyping, resetAccumulated } = useTypewriter({
414
+ const { displayText, isTyping, skipTyping, startTyping, resetAccumulated, reset: resetTypewriter, } = useTypewriter({
414
415
  speed: mergedSettings.textSpeed,
415
416
  });
416
417
  // 現在の表示可能なブロックを取得(分岐ブロックが優先)
@@ -462,6 +463,8 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
462
463
  isFirstRenderComplete,
463
464
  muteAudio: mergedSettings.muteAudio,
464
465
  });
466
+ // パートボイス再生
467
+ const { playVoice, stopVoice } = useVoice();
465
468
  // 会話分岐機能
466
469
  const branchNavigatorRef = useRef(new BranchNavigator());
467
470
  const conversationBranch = useConversationBranch({
@@ -515,6 +518,10 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
515
518
  }
516
519
  };
517
520
  }, [pluginManager, branchState]);
521
+ // キャラクター登場フェード待ち管理(refはここで宣言、検出ロジックはdisplayedCharacters定義後)
522
+ const isWaitingForEntranceRef = useRef(false);
523
+ const pendingTypingRef = useRef(null);
524
+ const prevCharIdSetRef = useRef(new Set());
518
525
  // ダイアログ表示とアクションノード実行のuseEffectを分離
519
526
  useEffect(() => {
520
527
  if (currentBlock && isFirstRenderComplete && pluginsLoaded) {
@@ -540,15 +547,43 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
540
547
  }
541
548
  const isContinuableBlock = currentBlock.blockType === "dialogue" ||
542
549
  currentBlock.blockType === "narration";
543
- if (continueMode && isContinuableBlock) {
544
- startTyping(content, continueMode);
550
+ const doTyping = () => {
551
+ var _a;
552
+ // パートボイス再生(テキスト表示と同時)
553
+ if (isContinuableBlock && ((_a = currentBlock.partVoice) === null || _a === void 0 ? void 0 : _a.url)) {
554
+ playVoice(currentBlock.partVoice.url);
555
+ }
556
+ else if (isContinuableBlock) {
557
+ stopVoice();
558
+ }
559
+ if (continueMode && isContinuableBlock) {
560
+ startTyping(content, continueMode);
561
+ }
562
+ else {
563
+ resetAccumulated();
564
+ startTyping(content, false);
565
+ }
566
+ };
567
+ // キャラクター登場フェード中はタイピング開始を遅延し、既存テキストを即クリア
568
+ if (isWaitingForEntranceRef.current) {
569
+ resetTypewriter();
570
+ pendingTypingRef.current = doTyping;
545
571
  }
546
572
  else {
547
- resetAccumulated();
548
- startTyping(content, false);
573
+ doTyping();
549
574
  }
550
575
  }
551
- }, [currentBlock, previousBlock, startTyping, resetAccumulated, isFirstRenderComplete, pluginsLoaded]);
576
+ }, [
577
+ currentBlock,
578
+ previousBlock,
579
+ startTyping,
580
+ resetAccumulated,
581
+ resetTypewriter,
582
+ isFirstRenderComplete,
583
+ pluginsLoaded,
584
+ playVoice,
585
+ stopVoice,
586
+ ]);
552
587
  // 分岐ブロック自動ロード処理
553
588
  useEffect(() => {
554
589
  if (currentBlock && currentBlock.blockType === "conversation_branch") {
@@ -627,6 +662,7 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
627
662
  }
628
663
  // リスタート時にすべての音声を停止
629
664
  stopAllSounds();
665
+ stopVoice();
630
666
  setIsFirstRenderComplete(false);
631
667
  setHasStarted(false); // これにより次の useEffect で onScenarioStart が呼ばれる
632
668
  setCurrentBranchBlock(null);
@@ -647,6 +683,7 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
647
683
  scenario.id,
648
684
  scenario.name,
649
685
  stopAllSounds,
686
+ stopVoice,
650
687
  ]);
651
688
  // プラグインからシナリオを中断するためのコールバック
652
689
  const cancelScenario = useCallback(() => {
@@ -657,7 +694,14 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
657
694
  scenarioName: scenario.name || "scenario",
658
695
  });
659
696
  }
660
- }, [hasStarted, state.isEnded, onScenarioCancelled, pluginManager, scenario.id, scenario.name]);
697
+ }, [
698
+ hasStarted,
699
+ state.isEnded,
700
+ onScenarioCancelled,
701
+ pluginManager,
702
+ scenario.id,
703
+ scenario.name,
704
+ ]);
661
705
  const { handleNext: handleNextInternal, handlePrevious: handlePreviousInternal, togglePlay: _togglePlay, restart: _restartInternal, displayedCharacters, } = usePlayerLogic({
662
706
  state: Object.assign(Object.assign({}, state), { currentBlockIndex: actualBlockIndex }),
663
707
  setState: (newState) => {
@@ -689,7 +733,28 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
689
733
  customRestart: restart,
690
734
  variableManager: variableManagerRef.current,
691
735
  disableKeyboardNavigation,
736
+ onStopAllSounds: useCallback(() => {
737
+ stopAllSounds();
738
+ stopVoice();
739
+ }, [stopAllSounds, stopVoice]),
692
740
  });
741
+ // レンダーフェーズでキャラクター構成の変化を検出(useEffectより前に実行される)
742
+ const currentCharIdSet = useMemo(() => new Set(displayedCharacters.map((c) => c.objectId)), [displayedCharacters]);
743
+ const prevCharIds = prevCharIdSetRef.current;
744
+ const hasCharacterCompositionChange = currentCharIdSet.size !== prevCharIds.size ||
745
+ [...currentCharIdSet].some((id) => !prevCharIds.has(id)) ||
746
+ [...prevCharIds].some((id) => !currentCharIdSet.has(id));
747
+ if (hasCharacterCompositionChange) {
748
+ isWaitingForEntranceRef.current = true;
749
+ prevCharIdSetRef.current = currentCharIdSet;
750
+ }
751
+ const handleEntranceComplete = useCallback(() => {
752
+ isWaitingForEntranceRef.current = false;
753
+ if (pendingTypingRef.current) {
754
+ pendingTypingRef.current();
755
+ pendingTypingRef.current = null;
756
+ }
757
+ }, []);
693
758
  // プラグインイベント処理
694
759
  const realBlockIndex = currentBlock
695
760
  ? scenario.blocks.indexOf(currentBlock)
@@ -766,6 +831,7 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
766
831
  setState((prev) => (Object.assign(Object.assign({}, prev), { isEnded: true, isPlaying: false })));
767
832
  // シナリオ終了時にすべての音声を停止
768
833
  stopAllSounds();
834
+ stopVoice();
769
835
  // プラグインのonScenarioEndフックを呼び出し
770
836
  pluginManager.callHook("onScenarioEnd", {
771
837
  scenarioId: scenario.id,
@@ -782,6 +848,7 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
782
848
  handleNextInternal,
783
849
  onEnd,
784
850
  onScenarioEnd, // プラグインのonScenarioEndフックを呼び出し
851
+ stopVoice,
785
852
  pluginManager,
786
853
  scenario.id,
787
854
  scenario.name,
@@ -805,13 +872,26 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
805
872
  if (!hasStarted || state.isEnded)
806
873
  return;
807
874
  setState((prev) => (Object.assign(Object.assign({}, prev), { isEnded: true, isPlaying: false })));
875
+ // スキップ時にすべての音声を停止
876
+ stopAllSounds();
877
+ stopVoice();
808
878
  pluginManager.callHook("onScenarioEnd", {
809
879
  scenarioId: scenario.id,
810
880
  scenarioName: scenario.name || "scenario",
811
881
  });
812
882
  onScenarioEnd === null || onScenarioEnd === void 0 ? void 0 : onScenarioEnd();
813
883
  onEnd === null || onEnd === void 0 ? void 0 : onEnd();
814
- }, [hasStarted, state.isEnded, pluginManager, scenario.id, scenario.name, onScenarioEnd, onEnd]);
884
+ }, [
885
+ hasStarted,
886
+ state.isEnded,
887
+ stopAllSounds,
888
+ stopVoice,
889
+ pluginManager,
890
+ scenario.id,
891
+ scenario.name,
892
+ onScenarioEnd,
893
+ onEnd,
894
+ ]);
815
895
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
816
896
  const _handlePrevious = useCallback(() => {
817
897
  if (state.currentBlockIndex > 0) {
@@ -1007,7 +1087,7 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
1007
1087
  effectVolume: mergedSettings.effectVolume,
1008
1088
  textSoundVolume: mergedSettings.textSoundVolume,
1009
1089
  muteAudio: (_b = mergedSettings.muteAudio) !== null && _b !== void 0 ? _b : false,
1010
- }, 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, inactiveCharacterBrightness: mergedSettings.inactiveCharacterBrightness, characterSpacing: mergedSettings.characterSpacing }, "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
1090
+ }, 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, inactiveCharacterBrightness: mergedSettings.inactiveCharacterBrightness, characterSpacing: mergedSettings.characterSpacing, onEntranceComplete: handleEntranceComplete }, "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
1011
1091
  .getRegisteredComponents()
1012
1092
  .filter((type) => type !== ComponentType.DialogueBox &&
1013
1093
  type !== ComponentType.ConversationBranch)
@@ -3,7 +3,7 @@ import { useMemo } from "react";
3
3
  import { useDataAPI } from "../contexts/DataContext";
4
4
  import { getFontFamilyStyle } from "../hooks/useFontLoader";
5
5
  export const DialogueBox = () => {
6
- var _a, _b;
6
+ var _a, _b, _c;
7
7
  const dataAPI = useDataAPI();
8
8
  const currentBlock = dataAPI.get("playback", "currentBlock");
9
9
  const content = dataAPI.get("playback", "displayText") || "";
@@ -24,13 +24,15 @@ export const DialogueBox = () => {
24
24
  if (!content) {
25
25
  return null;
26
26
  }
27
- const speakerName = (_a = currentBlock.speaker) === null || _a === void 0 ? void 0 : _a.name;
28
- const currentSpeakerId = (_b = currentBlock.speaker) === null || _b === void 0 ? void 0 : _b.id;
27
+ // 話者不明(speakerId=null)の台詞ブロックでは「???」を表示
28
+ const isUnknownSpeaker = !currentBlock.speaker && currentBlock.blockType === "dialogue";
29
+ const speakerName = (_b = (_a = currentBlock.speaker) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : (isUnknownSpeaker ? "???" : undefined);
30
+ const currentSpeakerId = (_c = currentBlock.speaker) === null || _c === void 0 ? void 0 : _c.id;
29
31
  // 話者の位置を特定
30
32
  const speakerCharacter = displayedCharacters.find((char) => char.objectId === currentSpeakerId);
31
- // 位置に基づいて名前表示のクラスを決定
33
+ // 位置に基づいて名前表示のクラスを決定(話者不明は中央固定)
32
34
  const getNamePositionClass = () => {
33
- if (!(speakerCharacter === null || speakerCharacter === void 0 ? void 0 : speakerCharacter.positionX))
35
+ if (isUnknownSpeaker || !(speakerCharacter === null || speakerCharacter === void 0 ? void 0 : speakerCharacter.positionX))
34
36
  return "justify-center";
35
37
  const positionX = speakerCharacter.positionX;
36
38
  if (positionX < 0.33)
@@ -39,5 +41,5 @@ export const DialogueBox = () => {
39
41
  return "justify-end -mr-[5%]"; // 右側
40
42
  return "justify-center"; // 中央
41
43
  };
42
- return (_jsx("div", { className: "absolute bottom-[5%] max-w-[60%] mx-auto h-[25%] left-0 right-0 bg-amber-50 border-4 rounded-xl border-amber-800 z-10 pointer-events-auto", style: { zIndex: 1000 }, "data-dialogue-element": true, children: _jsxs("div", { className: "relative space-y-3", children: [speakerName && (_jsx("div", { className: `flex items-center space-x-2 ${getNamePositionClass()}`, children: _jsx("div", { className: "bg-amber-950 text-white rounded-xl relative -top-5 w-full max-w-30 text-center border-4 border-amber-50 shadow-lg", "data-speaker-name-element": true, children: _jsx("span", { className: "font-bold text-lg", children: speakerName }) }) })), _jsx("div", { className: "absolute top-0 p-4 leading-relaxed text-2xl text-black", style: { fontFamily: fontFamilyStyle }, children: content }), _jsx("div", { className: "flex justify-end", children: _jsx("div", { className: "text-white/60 text-sm animate-pulse", children: "\u25BC" }) })] }) }));
44
+ return (_jsxs("div", { className: "absolute bottom-[5%] max-w-[60%] mx-auto left-0 right-0 pointer-events-auto", style: { zIndex: 1000 }, "data-dialogue-element": true, children: [speakerName && (_jsx("div", { className: `flex ${getNamePositionClass()} px-4`, children: _jsx("div", { className: "bg-amber-950 text-white rounded-xl text-center px-4 py-0.5 border-4 border-amber-50 shadow-lg translate-y-3", "data-speaker-name-element": true, children: _jsx("span", { className: "font-bold text-lg", children: speakerName }) }) })), _jsxs("div", { className: `bg-amber-50 border-4 rounded-xl border-amber-800 p-4 ${speakerName ? "pt-5" : ""}`, children: [_jsx("div", { className: "leading-relaxed text-2xl text-black", style: { fontFamily: fontFamilyStyle }, children: content }), _jsx("div", { className: "flex justify-end mt-1", children: _jsx("div", { className: "text-amber-800/60 text-sm animate-pulse", children: "\u25BC" }) })] })] }));
43
45
  };
@@ -6,6 +6,8 @@ interface GameScreenProps {
6
6
  displayedCharacters: DisplayedCharacter[];
7
7
  inactiveCharacterBrightness?: number;
8
8
  characterSpacing?: number;
9
+ /** キャラクター登場フェード完了時のコールバック */
10
+ onEntranceComplete?: () => void;
9
11
  }
10
12
  /**
11
13
  * ゲームスクリーンコンポーネント
@@ -5,7 +5,7 @@ import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "re
5
5
  * React.memo でラップして、props が変わらない限り再レンダリングを防ぐ
6
6
  * これにより displayText の更新による不要な再描画を防止
7
7
  */
8
- export const GameScreen = memo(function GameScreen({ scenario, currentBlock, previousBlock, displayedCharacters, inactiveCharacterBrightness = 0.8, characterSpacing = 0.2, }) {
8
+ export const GameScreen = memo(function GameScreen({ scenario, currentBlock, previousBlock, displayedCharacters, inactiveCharacterBrightness = 0.8, characterSpacing = 0.2, onEntranceComplete, }) {
9
9
  var _a;
10
10
  // キャラクターごとのフェード状態を管理
11
11
  const [fadeStates, setFadeStates] = useState(new Map());
@@ -24,9 +24,13 @@ export const GameScreen = memo(function GameScreen({ scenario, currentBlock, pre
24
24
  const pendingEntranceRef = useRef([]);
25
25
  // 退場キャラの最終位置を保存(auto-layout再計算に依存しないため)
26
26
  const exitPositionsRef = useRef(new Map());
27
- // 登場・退場の検出
28
- useEffect(() => {
29
- var _a, _b;
27
+ // スライド方向を保存(登場・退場アニメーション用)
28
+ const slideDirectionsRef = useRef(new Map());
29
+ // 登場・退場の検出(差分ベース: 追加/削除されたキャラだけをアニメーション)
30
+ // useLayoutEffect: ブラウザ描画前に実行し、キャラがopacity=1で一瞬表示されるフラッシュを防ぐ
31
+ // biome-ignore lint/correctness/useExhaustiveDependencies: アニメーション検出はdisplayedCharactersの変更のみをトリガーとし、他の依存はrefで管理
32
+ useLayoutEffect(() => {
33
+ var _a, _b, _c, _d, _e, _f, _g;
30
34
  const currentIds = new Set(displayedCharacters.map((c) => c.objectId));
31
35
  const prevIds = prevDisplayedCharIdsRef.current;
32
36
  const prevChars = prevDisplayedCharsRef.current;
@@ -34,29 +38,62 @@ export const GameScreen = memo(function GameScreen({ scenario, currentBlock, pre
34
38
  if (prevIds.size === 0 && prevChars.length === 0) {
35
39
  prevDisplayedCharIdsRef.current = currentIds;
36
40
  prevDisplayedCharsRef.current = displayedCharacters;
41
+ onEntranceComplete === null || onEntranceComplete === void 0 ? void 0 : onEntranceComplete();
37
42
  return;
38
43
  }
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
+ // 前のブロックと現在のブロックの間にある character_exit / character_entrance ブロックを検索
45
+ let exitBlockOptions = null;
46
+ let entranceBlockOptions = null;
47
+ let hasNonAdditiveEntrance = false;
48
+ if (previousBlock && currentBlock) {
49
+ const blocks = scenario.blocks;
50
+ const prevIdx = blocks.findIndex((b) => b.id === previousBlock.id);
51
+ const curIdx = blocks.findIndex((b) => b.id === currentBlock.id);
52
+ if (prevIdx >= 0 && curIdx >= 0) {
53
+ for (let i = prevIdx + 1; i <= curIdx; i++) {
54
+ const b = blocks[i];
55
+ if (b.blockType === "character_exit" && b.options) {
56
+ exitBlockOptions = b.options;
57
+ }
58
+ if (b.blockType === "character_entrance") {
59
+ if (b.options) {
60
+ entranceBlockOptions = b.options;
61
+ }
62
+ if (((_a = b.options) === null || _a === void 0 ? void 0 : _a.additive) !== true) {
63
+ hasNonAdditiveEntrance = true;
64
+ }
65
+ }
66
+ }
67
+ }
68
+ }
69
+ // 非追加モードのentranceがある場合: 全員退場→全員登場(従来動作)
70
+ // 追加モード/exitのみの場合: 差分のみアニメーション
71
+ let enteringIds;
72
+ let exitingIds;
73
+ if (hasNonAdditiveEntrance) {
74
+ // 全差し替え: 前回の全キャラが退場、現在の全キャラが登場
75
+ exitingIds = [...prevIds];
76
+ enteringIds = [...currentIds];
77
+ }
78
+ else {
79
+ // 差分検出: 追加されたキャラ / 削除されたキャラのみ
80
+ enteringIds = [...currentIds].filter((id) => !prevIds.has(id));
81
+ exitingIds = [...prevIds].filter((id) => !currentIds.has(id));
82
+ }
83
+ if (enteringIds.length === 0 && exitingIds.length === 0) {
44
84
  prevDisplayedCharsRef.current = displayedCharacters;
45
85
  return;
46
86
  }
47
- // キャラ構成が変わった場合: 前回の全キャラを退場、現在の全キャラを登場
48
- // これにより同じキャラが含まれていても必ずフェードイン/アウトが発生
49
- const allExitChars = [...prevChars];
50
- const allEnterIds = [...currentIds];
51
- // 退場フェードアウト開始
52
- if (allExitChars.length > 0) {
87
+ // 退場キャラの処理: フェードアウトを適用
88
+ const exitChars = prevChars.filter((c) => exitingIds.includes(c.objectId));
89
+ if (exitChars.length > 0) {
53
90
  const now = performance.now();
54
- // 退場キャラの現在の位置を保存
55
- for (const char of allExitChars) {
91
+ for (const char of exitChars) {
92
+ // 位置を保存
56
93
  if (char.positionX !== null && char.positionY !== null) {
57
94
  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,
95
+ x: (_b = char.positionX) !== null && _b !== void 0 ? _b : 0,
96
+ y: (_c = char.positionY) !== null && _c !== void 0 ? _c : 0,
60
97
  });
61
98
  }
62
99
  else {
@@ -66,39 +103,96 @@ export const GameScreen = memo(function GameScreen({ scenario, currentBlock, pre
66
103
  exitPositionsRef.current.set(char.objectId, pos);
67
104
  }
68
105
  }
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
106
  entranceFadeStartRef.current.set(char.objectId, now);
107
+ // スライド: character_exit ブロックの anim: フラグがある場合のみ
108
+ const animKey = `anim:${char.objectId}`;
109
+ if ((exitBlockOptions === null || exitBlockOptions === void 0 ? void 0 : exitBlockOptions[animKey]) === true) {
110
+ const dirKey = `dir:${char.objectId}`;
111
+ const dirVal = exitBlockOptions[dirKey];
112
+ const validDirs = ["left", "right", "up", "down"];
113
+ const dir = typeof dirVal === "string" &&
114
+ validDirs.includes(dirVal)
115
+ ? dirVal
116
+ : ((_e = (_d = exitPositionsRef.current.get(char.objectId)) === null || _d === void 0 ? void 0 : _d.x) !== null && _e !== void 0 ? _e : 0) < 0
117
+ ? "left"
118
+ : "right";
119
+ slideDirectionsRef.current.set(char.objectId, dir);
120
+ }
75
121
  }
76
- setEntranceFades(newFades);
122
+ setExitingCharacters((prev) => [...prev, ...exitChars]);
123
+ setEntranceFades((prev) => {
124
+ const newFades = new Map(prev);
125
+ for (const char of exitChars) {
126
+ newFades.set(char.objectId, 1);
127
+ }
128
+ return newFades;
129
+ });
77
130
  }
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 = [];
131
+ // 登場キャラの処理: 全登場キャラにフェードインを適用
132
+ if (enteringIds.length > 0) {
133
+ // スライド: character_entrance ブロックの anim: フラグがある場合のみ
134
+ const enterAutoPositions = calculateAutoLayout(displayedCharacters, characterSpacing !== null && characterSpacing !== void 0 ? characterSpacing : 0.2);
135
+ for (const id of enteringIds) {
136
+ const animKey = `anim:${id}`;
137
+ if ((entranceBlockOptions === null || entranceBlockOptions === void 0 ? void 0 : entranceBlockOptions[animKey]) === true) {
138
+ const dirKey = `dir:${id}`;
139
+ const dirVal = entranceBlockOptions[dirKey];
140
+ const validDirs = ["left", "right", "up", "down"];
141
+ if (typeof dirVal === "string" &&
142
+ validDirs.includes(dirVal)) {
143
+ slideDirectionsRef.current.set(id, dirVal);
144
+ }
145
+ else {
146
+ const char = displayedCharacters.find((c) => c.objectId === id);
147
+ let x = 0;
148
+ if ((char === null || char === void 0 ? void 0 : char.positionX) !== null && (char === null || char === void 0 ? void 0 : char.positionX) !== undefined) {
149
+ x = char.positionX;
150
+ }
151
+ else {
152
+ x = (_g = (_f = enterAutoPositions.get(id)) === null || _f === void 0 ? void 0 : _f.x) !== null && _g !== void 0 ? _g : 0;
153
+ }
154
+ slideDirectionsRef.current.set(id, x < 0 ? "left" : "right");
155
+ }
156
+ }
157
+ }
158
+ if (exitChars.length > 0) {
159
+ // 退場アニメーション完了を待ってから登場フェードイン開始
160
+ const delay = ENTRANCE_FADE_DURATION + 300;
161
+ pendingEntranceRef.current = enteringIds;
162
+ setTimeout(() => {
163
+ const ids = pendingEntranceRef.current;
164
+ if (ids.length === 0)
165
+ return;
166
+ pendingEntranceRef.current = [];
167
+ const now = performance.now();
168
+ setEntranceFades((prev) => {
169
+ const newFades = new Map(prev);
170
+ for (const id of ids) {
171
+ newFades.set(id, 0);
172
+ entranceFadeStartRef.current.set(id, now);
173
+ }
174
+ return newFades;
175
+ });
176
+ }, delay);
177
+ }
178
+ else {
179
+ // 退場なし: 即座にフェードイン開始(setTimeoutを使わず1フレーム目から opacity=0)
87
180
  const now = performance.now();
88
181
  setEntranceFades((prev) => {
89
182
  const newFades = new Map(prev);
90
- for (const id of ids) {
183
+ for (const id of enteringIds) {
91
184
  newFades.set(id, 0);
92
185
  entranceFadeStartRef.current.set(id, now);
93
186
  }
94
187
  return newFades;
95
188
  });
96
- }, delay);
189
+ }
97
190
  }
98
191
  prevDisplayedCharIdsRef.current = currentIds;
99
192
  prevDisplayedCharsRef.current = displayedCharacters;
100
- }, [displayedCharacters]); // eslint-disable-line react-hooks/exhaustive-deps
193
+ }, [displayedCharacters]);
101
194
  // 登場・退場フェードのアニメーションループ
195
+ // biome-ignore lint/correctness/useExhaustiveDependencies: アニメーションループはフェード状態と退場キャラの変更のみで制御
102
196
  useEffect(() => {
103
197
  if (entranceFades.size === 0)
104
198
  return;
@@ -140,6 +234,10 @@ export const GameScreen = memo(function GameScreen({ scenario, currentBlock, pre
140
234
  else {
141
235
  setEntranceFades(new Map());
142
236
  entranceFadeStartRef.current.clear();
237
+ // 登場フェード完了を通知(保留中の登場がない場合のみ)
238
+ if (pendingEntranceRef.current.length === 0) {
239
+ onEntranceComplete === null || onEntranceComplete === void 0 ? void 0 : onEntranceComplete();
240
+ }
143
241
  }
144
242
  };
145
243
  entranceAnimFrameRef.current = requestAnimationFrame(animate);
@@ -148,7 +246,7 @@ export const GameScreen = memo(function GameScreen({ scenario, currentBlock, pre
148
246
  cancelAnimationFrame(entranceAnimFrameRef.current);
149
247
  }
150
248
  };
151
- }, [entranceFades.size > 0, exitingCharacters]); // eslint-disable-line react-hooks/exhaustive-deps
249
+ }, [entranceFades.size > 0, exitingCharacters]);
152
250
  // コンテナサイズを取得(ResizeObserverで確実に取得)
153
251
  const containerRef = useRef(null);
154
252
  const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
@@ -367,24 +465,28 @@ export const GameScreen = memo(function GameScreen({ scenario, currentBlock, pre
367
465
  const currentSpeaker = (currentBlock === null || currentBlock === void 0 ? void 0 : currentBlock.speakerId)
368
466
  ? displayedCharacters.find((char) => char.objectId === currentBlock.speakerId)
369
467
  : null;
370
- // 簡易モード用の自動配置計算
468
+ // 簡易モード用の自動配置計算(スロットベース対応)
371
469
  const calculateAutoLayout = (characters, spacing) => {
372
- const total = characters.length;
470
+ var _a, _b;
373
471
  const positions = new Map();
374
- if (total === 0)
472
+ if (characters.length === 0)
375
473
  return positions;
376
- if (total === 1) {
377
- // 1人の場合は中央
474
+ // スロットベース配置: layoutSlotCountがあればそれをスロット数として使用
475
+ const slotCount = (_b = (_a = characters[0]) === null || _a === void 0 ? void 0 : _a.layoutSlotCount) !== null && _b !== void 0 ? _b : characters.length;
476
+ if (slotCount === 1) {
378
477
  positions.set(characters[0].objectId, { x: 0, y: 0 });
379
478
  return positions;
380
479
  }
381
- // 複数の場合は均等配置
382
- // spacing: 画面幅に対する割合(例: 0.1 = 10%)
383
- // 座標系: -1(左端)〜 1(右端)
384
- const totalWidth = (total - 1) * spacing * 2;
480
+ // 2人の場合は間隔を少し広めに(最低0.3)
481
+ const effectiveSpacing = slotCount === 2 ? Math.max(spacing, 0.3) : spacing;
482
+ // スロット数に基づいた均等配置
483
+ const totalWidth = (slotCount - 1) * effectiveSpacing * 2;
385
484
  const startX = -totalWidth / 2;
386
485
  characters.forEach((char, index) => {
387
- const x = startX + index * spacing * 2;
486
+ var _a;
487
+ // layoutSlotIndexがあればそのスロット位置を使用、なければ配列順
488
+ const slotIndex = (_a = char.layoutSlotIndex) !== null && _a !== void 0 ? _a : index;
489
+ const x = startX + slotIndex * effectiveSpacing * 2;
388
490
  positions.set(char.objectId, { x, y: 0 });
389
491
  });
390
492
  return positions;
@@ -443,6 +545,32 @@ export const GameScreen = memo(function GameScreen({ scenario, currentBlock, pre
443
545
  !isExitingChar) {
444
546
  opacity = 0;
445
547
  }
548
+ // スライドアニメーション: opacity に連動してスライドオフセットを計算
549
+ let slideOffsetX = 0;
550
+ let slideOffsetY = 0;
551
+ const slideDir = slideDirectionsRef.current.get(image.objectId);
552
+ if (slideDir &&
553
+ (entranceFadeOpacity !== undefined ||
554
+ pendingEntranceRef.current.includes(image.objectId))) {
555
+ const remaining = 1 - (entranceFadeOpacity !== null && entranceFadeOpacity !== void 0 ? entranceFadeOpacity : 0);
556
+ const easedOffset = remaining * remaining;
557
+ const maxHorizontal = containerSize.width * 0.3;
558
+ const maxVertical = containerSize.height * 0.3;
559
+ switch (slideDir) {
560
+ case "left":
561
+ slideOffsetX = -maxHorizontal * easedOffset;
562
+ break;
563
+ case "right":
564
+ slideOffsetX = maxHorizontal * easedOffset;
565
+ break;
566
+ case "up":
567
+ slideOffsetY = -maxVertical * easedOffset;
568
+ break;
569
+ case "down":
570
+ slideOffsetY = maxVertical * easedOffset;
571
+ break;
572
+ }
573
+ }
446
574
  // 明るさを決定
447
575
  let brightness = 1;
448
576
  if (displayedCharacters.length > 0 && displayedChar) {
@@ -540,7 +668,7 @@ export const GameScreen = memo(function GameScreen({ scenario, currentBlock, pre
540
668
  return (_jsx("div", { style: Object.assign({ position: "absolute", visibility: shouldDisplay ? "visible" : "hidden", opacity: opacity, filter: `brightness(${brightness})`, zIndex: zIndex,
541
669
  // CharacterEditDialogと完全に同じ: left/topを0にしてtransformで位置制御
542
670
  // translate(%)は画像サイズに対する割合
543
- 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: "", decoding: "sync", style: {
671
+ left: 0, top: 0, transform: `translate(${leftPx + slideOffsetX}px, ${topPx + slideOffsetY}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: "", decoding: "sync", style: {
544
672
  height: `${baseHeight}px`,
545
673
  width: "auto",
546
674
  objectFit: "contain",
@@ -24,7 +24,7 @@ const SkipScenarioContext = createContext(null);
24
24
  * </DataProvider>
25
25
  * ```
26
26
  */
27
- export const DataProvider = ({ data, onSettingsUpdate, onEmotionEffectUpdate, onCancelScenario, onToggleAutoPlay, onSetAutoPlay, onSkipToNext, onSkipScenario, children }) => {
27
+ export const DataProvider = ({ data, onSettingsUpdate, onEmotionEffectUpdate, onCancelScenario, onToggleAutoPlay, onSetAutoPlay, onSkipToNext, onSkipScenario, children, }) => {
28
28
  const subscribers = useMemo(() => new Map(), []);
29
29
  const previousDataRef = useRef(data);
30
30
  // 安定したデータ参照ホルダー(Context valueとして提供)
@@ -117,7 +117,7 @@ export function useBacklog({ scenario, currentBlockIndex, currentBlock, }) {
117
117
  processedBlocks.current.add(currentBlockIndex);
118
118
  }
119
119
  }
120
- }, [currentBlock, currentBlockIndex, addLogEntry]);
120
+ }, [currentBlock, currentBlockIndex, addLogEntry, scenario.blocks]);
121
121
  // シナリオ変更時にリセット
122
122
  // biome-ignore lint/correctness/useExhaustiveDependencies: scenario.idの変更を検知してログをクリアする意図的な依存
123
123
  useEffect(() => {
@@ -16,8 +16,9 @@ interface UsePlayerLogicProps {
16
16
  customRestart?: () => void;
17
17
  variableManager?: VariableManager | null;
18
18
  disableKeyboardNavigation?: boolean;
19
+ onStopAllSounds?: () => void;
19
20
  }
20
- export declare const usePlayerLogic: ({ state, setState, scenario, isTyping, currentBlock, skipTyping, onEnd, onScenarioEnd, autoplay, branchState, branchNavigator: _branchNavigator, customRestart, variableManager, disableKeyboardNavigation, }: UsePlayerLogicProps) => {
21
+ export declare const usePlayerLogic: ({ state, setState, scenario, isTyping, currentBlock, skipTyping, onEnd, onScenarioEnd, autoplay, branchState, branchNavigator: _branchNavigator, customRestart, variableManager, disableKeyboardNavigation, onStopAllSounds, }: UsePlayerLogicProps) => {
21
22
  handleNext: () => void;
22
23
  handlePrevious: () => void;
23
24
  togglePlay: () => void;
@@ -1,114 +1,150 @@
1
1
  import { useCallback, useEffect, useMemo } from "react";
2
+ // character_entranceブロックからキャラクターデータを生成するヘルパー
3
+ const mapEntranceCharacters = (block) => {
4
+ if (!block.characters)
5
+ return [];
6
+ const opts = block.options;
7
+ const layoutSlotCount = opts && typeof opts.layoutSlotCount === "number"
8
+ ? opts.layoutSlotCount
9
+ : undefined;
10
+ return block.characters.map((char) => {
11
+ const slotKey = `slot:${char.objectId}`;
12
+ const slotVal = opts === null || opts === void 0 ? void 0 : opts[slotKey];
13
+ const layoutSlotIndex = typeof slotVal === "number" ? slotVal : undefined;
14
+ return {
15
+ objectId: char.objectId,
16
+ entityStateId: char.entityStateId,
17
+ positionX: char.positionX,
18
+ positionY: char.positionY,
19
+ zIndex: char.zIndex,
20
+ scale: char.scale,
21
+ cropLeft: char.cropLeft,
22
+ cropRight: char.cropRight,
23
+ cropFade: char.cropFade,
24
+ object: char.object,
25
+ entityState: char.entityState,
26
+ baseBodyState: char.baseBodyState,
27
+ layoutSlotCount,
28
+ layoutSlotIndex,
29
+ };
30
+ });
31
+ };
2
32
  // ブロックインデックスから表示すべきキャラクター状態を計算する純粋関数
3
33
  const calculateDisplayedCharacters = (blocks, currentIndex) => {
34
+ var _a, _b, _c, _d;
4
35
  let characters = [];
5
- // 現在位置から前方向に探索して、最後のcharacter_entranceまたはcharacter_exitを見つける
36
+ // 後方探索: 最初の非追加モードのcharacter_entranceを見つける(リセットポイント)
37
+ let startIndex = 0;
6
38
  for (let i = currentIndex; i >= 0; i--) {
7
39
  const block = blocks[i];
8
40
  if (block.blockType === "character_entrance") {
9
- // character_entranceを見つけたら、そこから現在位置までのキャラクター状態を構築
10
- // character_entranceから現在位置までのブロックを処理
11
- for (let j = i; j <= currentIndex; j++) {
12
- const processBlock = blocks[j];
13
- switch (processBlock.blockType) {
14
- case "character_entrance":
15
- if (processBlock.characters) {
16
- const newCharacters = processBlock.characters.map((char) => ({
17
- objectId: char.objectId,
18
- entityStateId: char.entityStateId,
19
- positionX: char.positionX,
20
- positionY: char.positionY,
21
- zIndex: char.zIndex,
22
- scale: char.scale,
23
- cropLeft: char.cropLeft,
24
- cropRight: char.cropRight,
25
- cropFade: char.cropFade,
26
- object: char.object,
27
- entityState: char.entityState,
28
- // レイヤー機能用
29
- baseBodyState: char.baseBodyState,
30
- }));
31
- characters = [
32
- ...characters.filter((existing) => !newCharacters.some((newChar) => newChar.objectId === existing.objectId)),
33
- ...newCharacters,
34
- ];
35
- }
36
- break;
37
- case "dialogue":
38
- if (processBlock.speakerId && processBlock.speakerStateId) {
39
- characters = characters.map((char) => {
40
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
41
- return char.objectId === processBlock.speakerId
42
- ? Object.assign(Object.assign({}, char), { entityStateId: processBlock.speakerStateId || "", entityState: {
43
- id: (_a = processBlock.speakerStateId) !== null && _a !== void 0 ? _a : "",
44
- name: ((_b = processBlock.speakerState) === null || _b === void 0 ? void 0 : _b.name) || "",
45
- imageUrl: ((_c = processBlock.speakerState) === null || _c === void 0 ? void 0 : _c.imageUrl) || null,
46
- cropArea: ((_d = processBlock.speakerState) === null || _d === void 0 ? void 0 : _d.cropArea) || null,
47
- scale: (_e = processBlock.speakerState) === null || _e === void 0 ? void 0 : _e.scale,
48
- translateX: (_f = processBlock.speakerState) === null || _f === void 0 ? void 0 : _f.translateX,
49
- translateY: (_g = processBlock.speakerState) === null || _g === void 0 ? void 0 : _g.translateY,
50
- isDefault: (_h = processBlock.speakerState) === null || _h === void 0 ? void 0 : _h.isDefault,
51
- // speakerStateにレイヤー情報があればそれを使用、なければ既存を保持
52
- layers: (_k = (_j = processBlock.speakerState) === null || _j === void 0 ? void 0 : _j.layers) !== null && _k !== void 0 ? _k : char.entityState.layers,
53
- },
54
- // baseBodyStateを保持
55
- baseBodyState: char.baseBodyState }) : char;
56
- });
57
- }
58
- break;
59
- case "character_state_change":
60
- // 状態変更ブロック: 登場済みキャラクターの状態のみを変更(位置は維持)
61
- // characters配列がある場合はそれを使用(複数キャラクター対応)
62
- if (processBlock.characters && processBlock.characters.length > 0) {
63
- for (const stateChange of processBlock.characters) {
64
- characters = characters.map((char) => {
65
- var _a, _b, _c, _d, _e, _f, _g, _h, _j;
66
- return char.objectId === stateChange.objectId
67
- ? Object.assign(Object.assign({}, char), { entityStateId: stateChange.entityStateId, entityState: {
68
- id: stateChange.entityStateId,
69
- name: ((_a = stateChange.entityState) === null || _a === void 0 ? void 0 : _a.name) || "",
70
- imageUrl: ((_b = stateChange.entityState) === null || _b === void 0 ? void 0 : _b.imageUrl) || null,
71
- cropArea: ((_c = stateChange.entityState) === null || _c === void 0 ? void 0 : _c.cropArea) || null,
72
- scale: (_d = stateChange.entityState) === null || _d === void 0 ? void 0 : _d.scale,
73
- translateX: (_e = stateChange.entityState) === null || _e === void 0 ? void 0 : _e.translateX,
74
- translateY: (_f = stateChange.entityState) === null || _f === void 0 ? void 0 : _f.translateY,
75
- isDefault: (_g = stateChange.entityState) === null || _g === void 0 ? void 0 : _g.isDefault,
76
- layers: (_j = (_h = stateChange.entityState) === null || _h === void 0 ? void 0 : _h.layers) !== null && _j !== void 0 ? _j : char.entityState.layers,
77
- },
78
- // baseBodyStateと位置情報を保持
79
- baseBodyState: char.baseBodyState }) : char;
80
- });
81
- }
82
- }
83
- else if (processBlock.speakerId && processBlock.speakerStateId) {
84
- // speakerId/speakerStateIdを使用した単一キャラクター状態変更(インラインエディタ形式)
85
- characters = characters.map((char) => {
86
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
87
- return char.objectId === processBlock.speakerId
88
- ? Object.assign(Object.assign({}, char), { entityStateId: processBlock.speakerStateId || "", entityState: {
89
- id: (_a = processBlock.speakerStateId) !== null && _a !== void 0 ? _a : "",
90
- name: ((_b = processBlock.speakerState) === null || _b === void 0 ? void 0 : _b.name) || "",
91
- imageUrl: ((_c = processBlock.speakerState) === null || _c === void 0 ? void 0 : _c.imageUrl) || null,
92
- cropArea: ((_d = processBlock.speakerState) === null || _d === void 0 ? void 0 : _d.cropArea) || null,
93
- scale: (_e = processBlock.speakerState) === null || _e === void 0 ? void 0 : _e.scale,
94
- translateX: (_f = processBlock.speakerState) === null || _f === void 0 ? void 0 : _f.translateX,
95
- translateY: (_g = processBlock.speakerState) === null || _g === void 0 ? void 0 : _g.translateY,
96
- isDefault: (_h = processBlock.speakerState) === null || _h === void 0 ? void 0 : _h.isDefault,
97
- layers: (_k = (_j = processBlock.speakerState) === null || _j === void 0 ? void 0 : _j.layers) !== null && _k !== void 0 ? _k : char.entityState.layers,
98
- },
99
- // baseBodyStateと位置情報を保持
100
- baseBodyState: char.baseBodyState }) : char;
101
- });
102
- }
103
- break;
41
+ const isAdditive = ((_a = block.options) === null || _a === void 0 ? void 0 : _a.additive) === true;
42
+ if (!isAdditive) {
43
+ startIndex = i;
44
+ break;
45
+ }
46
+ }
47
+ }
48
+ // startIndexから現在位置まで前方処理
49
+ for (let j = startIndex; j <= currentIndex; j++) {
50
+ const processBlock = blocks[j];
51
+ switch (processBlock.blockType) {
52
+ case "character_entrance": {
53
+ const isAdditive = ((_b = processBlock.options) === null || _b === void 0 ? void 0 : _b.additive) === true;
54
+ const newCharacters = mapEntranceCharacters(processBlock);
55
+ if (newCharacters.length === 0)
56
+ break;
57
+ if (isAdditive) {
58
+ // 追加モード: 既存キャラに追加(同一objectIdは上書き)
59
+ characters = [
60
+ ...characters.filter((existing) => !newCharacters.some((nc) => nc.objectId === existing.objectId)),
61
+ ...newCharacters,
62
+ ];
63
+ }
64
+ else {
65
+ // 全差し替え(従来動作)
66
+ characters = newCharacters;
67
+ }
68
+ break;
69
+ }
70
+ case "character_exit": {
71
+ // 指定されたキャラクターを表示リストから削除
72
+ if (processBlock.characters && processBlock.characters.length > 0) {
73
+ const exitIds = new Set(processBlock.characters.map((c) => c.objectId));
74
+ // 退場前のスロット総数を保存(残りキャラの位置を維持するため)
75
+ const prevSlotCount = (_d = (_c = characters[0]) === null || _c === void 0 ? void 0 : _c.layoutSlotCount) !== null && _d !== void 0 ? _d : characters.length;
76
+ // 各キャラにスロット情報を付与してからフィルタ
77
+ characters = characters
78
+ .map((c, idx) => {
79
+ var _a;
80
+ return (Object.assign(Object.assign({}, c), { layoutSlotCount: prevSlotCount, layoutSlotIndex: (_a = c.layoutSlotIndex) !== null && _a !== void 0 ? _a : idx }));
81
+ })
82
+ .filter((c) => !exitIds.has(c.objectId));
104
83
  }
84
+ break;
105
85
  }
106
- break; // 最初のcharacter_entranceを見つけたら終了
86
+ case "dialogue":
87
+ if (processBlock.speakerId && processBlock.speakerStateId) {
88
+ characters = characters.map((char) => {
89
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
90
+ return char.objectId === processBlock.speakerId
91
+ ? Object.assign(Object.assign({}, char), { entityStateId: processBlock.speakerStateId || "", entityState: {
92
+ id: (_a = processBlock.speakerStateId) !== null && _a !== void 0 ? _a : "",
93
+ name: ((_b = processBlock.speakerState) === null || _b === void 0 ? void 0 : _b.name) || "",
94
+ imageUrl: ((_c = processBlock.speakerState) === null || _c === void 0 ? void 0 : _c.imageUrl) || null,
95
+ cropArea: ((_d = processBlock.speakerState) === null || _d === void 0 ? void 0 : _d.cropArea) || null,
96
+ scale: (_e = processBlock.speakerState) === null || _e === void 0 ? void 0 : _e.scale,
97
+ translateX: (_f = processBlock.speakerState) === null || _f === void 0 ? void 0 : _f.translateX,
98
+ translateY: (_g = processBlock.speakerState) === null || _g === void 0 ? void 0 : _g.translateY,
99
+ isDefault: (_h = processBlock.speakerState) === null || _h === void 0 ? void 0 : _h.isDefault,
100
+ layers: (_k = (_j = processBlock.speakerState) === null || _j === void 0 ? void 0 : _j.layers) !== null && _k !== void 0 ? _k : char.entityState.layers,
101
+ }, baseBodyState: char.baseBodyState }) : char;
102
+ });
103
+ }
104
+ break;
105
+ case "character_state_change":
106
+ if (processBlock.characters && processBlock.characters.length > 0) {
107
+ for (const stateChange of processBlock.characters) {
108
+ characters = characters.map((char) => {
109
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
110
+ return char.objectId === stateChange.objectId
111
+ ? Object.assign(Object.assign({}, char), { entityStateId: stateChange.entityStateId, entityState: {
112
+ id: stateChange.entityStateId,
113
+ name: ((_a = stateChange.entityState) === null || _a === void 0 ? void 0 : _a.name) || "",
114
+ imageUrl: ((_b = stateChange.entityState) === null || _b === void 0 ? void 0 : _b.imageUrl) || null,
115
+ cropArea: ((_c = stateChange.entityState) === null || _c === void 0 ? void 0 : _c.cropArea) || null,
116
+ scale: (_d = stateChange.entityState) === null || _d === void 0 ? void 0 : _d.scale,
117
+ translateX: (_e = stateChange.entityState) === null || _e === void 0 ? void 0 : _e.translateX,
118
+ translateY: (_f = stateChange.entityState) === null || _f === void 0 ? void 0 : _f.translateY,
119
+ isDefault: (_g = stateChange.entityState) === null || _g === void 0 ? void 0 : _g.isDefault,
120
+ layers: (_j = (_h = stateChange.entityState) === null || _h === void 0 ? void 0 : _h.layers) !== null && _j !== void 0 ? _j : char.entityState.layers,
121
+ }, baseBodyState: char.baseBodyState }) : char;
122
+ });
123
+ }
124
+ }
125
+ else if (processBlock.speakerId && processBlock.speakerStateId) {
126
+ characters = characters.map((char) => {
127
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
128
+ return char.objectId === processBlock.speakerId
129
+ ? Object.assign(Object.assign({}, char), { entityStateId: processBlock.speakerStateId || "", entityState: {
130
+ id: (_a = processBlock.speakerStateId) !== null && _a !== void 0 ? _a : "",
131
+ name: ((_b = processBlock.speakerState) === null || _b === void 0 ? void 0 : _b.name) || "",
132
+ imageUrl: ((_c = processBlock.speakerState) === null || _c === void 0 ? void 0 : _c.imageUrl) || null,
133
+ cropArea: ((_d = processBlock.speakerState) === null || _d === void 0 ? void 0 : _d.cropArea) || null,
134
+ scale: (_e = processBlock.speakerState) === null || _e === void 0 ? void 0 : _e.scale,
135
+ translateX: (_f = processBlock.speakerState) === null || _f === void 0 ? void 0 : _f.translateX,
136
+ translateY: (_g = processBlock.speakerState) === null || _g === void 0 ? void 0 : _g.translateY,
137
+ isDefault: (_h = processBlock.speakerState) === null || _h === void 0 ? void 0 : _h.isDefault,
138
+ layers: (_k = (_j = processBlock.speakerState) === null || _j === void 0 ? void 0 : _j.layers) !== null && _k !== void 0 ? _k : char.entityState.layers,
139
+ }, baseBodyState: char.baseBodyState }) : char;
140
+ });
141
+ }
142
+ break;
107
143
  }
108
144
  }
109
145
  return characters;
110
146
  };
111
- export const usePlayerLogic = ({ state, setState, scenario, isTyping, currentBlock, skipTyping, onEnd, onScenarioEnd, autoplay, branchState, branchNavigator: _branchNavigator, customRestart, variableManager, disableKeyboardNavigation = false, }) => {
147
+ export const usePlayerLogic = ({ state, setState, scenario, isTyping, currentBlock, skipTyping, onEnd, onScenarioEnd, autoplay, branchState, branchNavigator: _branchNavigator, customRestart, variableManager, disableKeyboardNavigation = false, onStopAllSounds, }) => {
112
148
  const isLastBlock = state.currentBlockIndex === scenario.blocks.length - 1;
113
149
  // 現在のインデックスに基づいて表示すべきキャラクターを計算
114
150
  const displayedCharacters = useMemo(() => calculateDisplayedCharacters(scenario.blocks, state.currentBlockIndex), [scenario.blocks, state.currentBlockIndex]);
@@ -132,6 +168,7 @@ export const usePlayerLogic = ({ state, setState, scenario, isTyping, currentBlo
132
168
  }
133
169
  if (isLastBlock) {
134
170
  setState((prev) => (Object.assign(Object.assign({}, prev), { isEnded: true, isPlaying: false })));
171
+ onStopAllSounds === null || onStopAllSounds === void 0 ? void 0 : onStopAllSounds();
135
172
  onEnd === null || onEnd === void 0 ? void 0 : onEnd();
136
173
  onScenarioEnd === null || onScenarioEnd === void 0 ? void 0 : onScenarioEnd();
137
174
  if (typeof window !== "undefined") {
@@ -161,6 +198,7 @@ export const usePlayerLogic = ({ state, setState, scenario, isTyping, currentBlo
161
198
  isLastBlock,
162
199
  onEnd,
163
200
  onScenarioEnd,
201
+ onStopAllSounds,
164
202
  isTyping,
165
203
  currentBlock,
166
204
  skipTyping,
@@ -3,6 +3,25 @@ export const usePreloadImages = (scenario) => {
3
3
  const [isLoaded, setIsLoaded] = useState(false);
4
4
  // プリロード済みの画像URLセットを追跡(シナリオ変更で再プリロードを避ける)
5
5
  const preloadedUrlsRef = useRef(new Set());
6
+ // プリロード済みの音声URLセットを追跡
7
+ const preloadedAudioUrlsRef = useRef(new Set());
8
+ // パートボイスをプリロード
9
+ useEffect(() => {
10
+ const voiceUrls = new Set();
11
+ scenario.blocks.forEach((block) => {
12
+ var _a;
13
+ if ((_a = block.partVoice) === null || _a === void 0 ? void 0 : _a.url) {
14
+ voiceUrls.add(block.partVoice.url);
15
+ }
16
+ });
17
+ const newVoiceUrls = Array.from(voiceUrls).filter((url) => !preloadedAudioUrlsRef.current.has(url));
18
+ for (const url of newVoiceUrls) {
19
+ const audio = new Audio();
20
+ audio.preload = "auto";
21
+ audio.src = url;
22
+ preloadedAudioUrlsRef.current.add(url);
23
+ }
24
+ }, [scenario]);
6
25
  useEffect(() => {
7
26
  const imageUrls = new Set();
8
27
  // シナリオ全体から画像URLを収集
@@ -107,7 +107,8 @@ export const useSoundPlayer = ({ soundBlocks, isFirstRenderComplete, muteAudio =
107
107
  stopBGM();
108
108
  }
109
109
  const audio = new Audio(sound.url);
110
- audio.loop = sound.loop;
110
+ // BGMは常にループ再生
111
+ audio.loop = isBGMSound ? true : sound.loop;
111
112
  applyVolume(audio, block);
112
113
  const category = block.sound.soundType.category;
113
114
  // エラーハンドリング(イベントリスナーを保持してクリーンアップ可能にする)
package/dist/types.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export type SoundCategory = "bgm" | "se" | "voice";
2
2
  /** Block options stored as key-value pairs */
3
3
  export type BlockOptions = Record<string, string | number | boolean>;
4
- export type ScenarioBlockType = "dialogue" | "narration" | "action_node" | "character_entrance" | "character_state_change" | "bgm_play" | "se_play" | "bgm_stop" | "conversation_branch" | "background_change" | "background_group" | "variable_operation" | "fullscreen_text" | "click_wait" | "time_wait";
4
+ export type ScenarioBlockType = "dialogue" | "narration" | "action_node" | "character_entrance" | "character_exit" | "character_state_change" | "bgm_play" | "se_play" | "bgm_stop" | "conversation_branch" | "background_change" | "background_group" | "variable_operation" | "fullscreen_text" | "click_wait" | "time_wait";
5
5
  export interface ScenarioBlock {
6
6
  id: string;
7
7
  scenarioId: string;
@@ -11,6 +11,11 @@ export interface ScenarioBlock {
11
11
  speakerId: string | null;
12
12
  speakerStateId: string | null;
13
13
  partVoiceId: string | null;
14
+ partVoice?: {
15
+ id: string;
16
+ name: string;
17
+ url: string;
18
+ } | null;
14
19
  actionNodeId: string | null;
15
20
  createdAt: Date;
16
21
  updatedAt: Date;
@@ -205,6 +210,10 @@ export interface DisplayedCharacter {
205
210
  entityStateId: string;
206
211
  positionX?: number | null;
207
212
  positionY?: number | null;
213
+ /** スロットベース配置: スロット総数 */
214
+ layoutSlotCount?: number;
215
+ /** スロットベース配置: このキャラのスロット位置(0始まり) */
216
+ layoutSlotIndex?: number;
208
217
  zIndex?: number | null;
209
218
  scale?: number | null;
210
219
  cropLeft?: number | null;
@@ -428,6 +437,11 @@ export interface ConversationBranchBlock {
428
437
  speakerStateId: string | null;
429
438
  actionNodeId: string | null;
430
439
  partVoiceId: string | null;
440
+ partVoice?: {
441
+ id: string;
442
+ name: string;
443
+ url: string;
444
+ } | null;
431
445
  order: number;
432
446
  createdAt: Date;
433
447
  updatedAt: Date;
@@ -8,6 +8,7 @@ export function convertBranchBlockToScenarioBlock(branchBlock) {
8
8
  speakerId: branchBlock.speakerId,
9
9
  speakerStateId: branchBlock.speakerStateId,
10
10
  partVoiceId: branchBlock.partVoiceId,
11
+ partVoice: branchBlock.partVoice,
11
12
  actionNodeId: branchBlock.actionNodeId,
12
13
  createdAt: branchBlock.createdAt,
13
14
  updatedAt: branchBlock.updatedAt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luna-editor/engine",
3
- "version": "0.5.13",
3
+ "version": "0.5.15",
4
4
  "description": "Luna Editor scenario playback engine",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -52,4 +52,4 @@
52
52
  "game-engine"
53
53
  ],
54
54
  "license": "MIT"
55
- }
55
+ }