@luna-editor/engine 0.5.13 → 0.5.14

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,32 @@ 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();
468
+ // currentBlockが変更されたらパートボイスを再生
469
+ useEffect(() => {
470
+ var _a;
471
+ if (!isFirstRenderComplete)
472
+ return;
473
+ if (!currentBlock)
474
+ return;
475
+ if (state.isEnded)
476
+ return;
477
+ if ((currentBlock.blockType === "dialogue" ||
478
+ currentBlock.blockType === "narration") &&
479
+ ((_a = currentBlock.partVoice) === null || _a === void 0 ? void 0 : _a.url)) {
480
+ playVoice(currentBlock.partVoice.url);
481
+ }
482
+ else {
483
+ stopVoice();
484
+ }
485
+ }, [
486
+ currentBlock,
487
+ isFirstRenderComplete,
488
+ state.isEnded,
489
+ playVoice,
490
+ stopVoice,
491
+ ]);
465
492
  // 会話分岐機能
466
493
  const branchNavigatorRef = useRef(new BranchNavigator());
467
494
  const conversationBranch = useConversationBranch({
@@ -515,6 +542,10 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
515
542
  }
516
543
  };
517
544
  }, [pluginManager, branchState]);
545
+ // キャラクター登場フェード待ち管理(refはここで宣言、検出ロジックはdisplayedCharacters定義後)
546
+ const isWaitingForEntranceRef = useRef(false);
547
+ const pendingTypingRef = useRef(null);
548
+ const prevCharIdSetRef = useRef(new Set());
518
549
  // ダイアログ表示とアクションノード実行のuseEffectを分離
519
550
  useEffect(() => {
520
551
  if (currentBlock && isFirstRenderComplete && pluginsLoaded) {
@@ -540,15 +571,33 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
540
571
  }
541
572
  const isContinuableBlock = currentBlock.blockType === "dialogue" ||
542
573
  currentBlock.blockType === "narration";
543
- if (continueMode && isContinuableBlock) {
544
- startTyping(content, continueMode);
574
+ const doTyping = () => {
575
+ if (continueMode && isContinuableBlock) {
576
+ startTyping(content, continueMode);
577
+ }
578
+ else {
579
+ resetAccumulated();
580
+ startTyping(content, false);
581
+ }
582
+ };
583
+ // キャラクター登場フェード中はタイピング開始を遅延し、既存テキストを即クリア
584
+ if (isWaitingForEntranceRef.current) {
585
+ resetTypewriter();
586
+ pendingTypingRef.current = doTyping;
545
587
  }
546
588
  else {
547
- resetAccumulated();
548
- startTyping(content, false);
589
+ doTyping();
549
590
  }
550
591
  }
551
- }, [currentBlock, previousBlock, startTyping, resetAccumulated, isFirstRenderComplete, pluginsLoaded]);
592
+ }, [
593
+ currentBlock,
594
+ previousBlock,
595
+ startTyping,
596
+ resetAccumulated,
597
+ resetTypewriter,
598
+ isFirstRenderComplete,
599
+ pluginsLoaded,
600
+ ]);
552
601
  // 分岐ブロック自動ロード処理
553
602
  useEffect(() => {
554
603
  if (currentBlock && currentBlock.blockType === "conversation_branch") {
@@ -627,6 +676,7 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
627
676
  }
628
677
  // リスタート時にすべての音声を停止
629
678
  stopAllSounds();
679
+ stopVoice();
630
680
  setIsFirstRenderComplete(false);
631
681
  setHasStarted(false); // これにより次の useEffect で onScenarioStart が呼ばれる
632
682
  setCurrentBranchBlock(null);
@@ -647,6 +697,7 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
647
697
  scenario.id,
648
698
  scenario.name,
649
699
  stopAllSounds,
700
+ stopVoice,
650
701
  ]);
651
702
  // プラグインからシナリオを中断するためのコールバック
652
703
  const cancelScenario = useCallback(() => {
@@ -657,7 +708,14 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
657
708
  scenarioName: scenario.name || "scenario",
658
709
  });
659
710
  }
660
- }, [hasStarted, state.isEnded, onScenarioCancelled, pluginManager, scenario.id, scenario.name]);
711
+ }, [
712
+ hasStarted,
713
+ state.isEnded,
714
+ onScenarioCancelled,
715
+ pluginManager,
716
+ scenario.id,
717
+ scenario.name,
718
+ ]);
661
719
  const { handleNext: handleNextInternal, handlePrevious: handlePreviousInternal, togglePlay: _togglePlay, restart: _restartInternal, displayedCharacters, } = usePlayerLogic({
662
720
  state: Object.assign(Object.assign({}, state), { currentBlockIndex: actualBlockIndex }),
663
721
  setState: (newState) => {
@@ -689,7 +747,28 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
689
747
  customRestart: restart,
690
748
  variableManager: variableManagerRef.current,
691
749
  disableKeyboardNavigation,
750
+ onStopAllSounds: useCallback(() => {
751
+ stopAllSounds();
752
+ stopVoice();
753
+ }, [stopAllSounds, stopVoice]),
692
754
  });
755
+ // レンダーフェーズでキャラクター構成の変化を検出(useEffectより前に実行される)
756
+ const currentCharIdSet = useMemo(() => new Set(displayedCharacters.map((c) => c.objectId)), [displayedCharacters]);
757
+ const prevCharIds = prevCharIdSetRef.current;
758
+ const hasCharacterCompositionChange = currentCharIdSet.size !== prevCharIds.size ||
759
+ [...currentCharIdSet].some((id) => !prevCharIds.has(id)) ||
760
+ [...prevCharIds].some((id) => !currentCharIdSet.has(id));
761
+ if (hasCharacterCompositionChange) {
762
+ isWaitingForEntranceRef.current = true;
763
+ prevCharIdSetRef.current = currentCharIdSet;
764
+ }
765
+ const handleEntranceComplete = useCallback(() => {
766
+ isWaitingForEntranceRef.current = false;
767
+ if (pendingTypingRef.current) {
768
+ pendingTypingRef.current();
769
+ pendingTypingRef.current = null;
770
+ }
771
+ }, []);
693
772
  // プラグインイベント処理
694
773
  const realBlockIndex = currentBlock
695
774
  ? scenario.blocks.indexOf(currentBlock)
@@ -766,6 +845,7 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
766
845
  setState((prev) => (Object.assign(Object.assign({}, prev), { isEnded: true, isPlaying: false })));
767
846
  // シナリオ終了時にすべての音声を停止
768
847
  stopAllSounds();
848
+ stopVoice();
769
849
  // プラグインのonScenarioEndフックを呼び出し
770
850
  pluginManager.callHook("onScenarioEnd", {
771
851
  scenarioId: scenario.id,
@@ -782,6 +862,7 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
782
862
  handleNextInternal,
783
863
  onEnd,
784
864
  onScenarioEnd, // プラグインのonScenarioEndフックを呼び出し
865
+ stopVoice,
785
866
  pluginManager,
786
867
  scenario.id,
787
868
  scenario.name,
@@ -805,13 +886,26 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
805
886
  if (!hasStarted || state.isEnded)
806
887
  return;
807
888
  setState((prev) => (Object.assign(Object.assign({}, prev), { isEnded: true, isPlaying: false })));
889
+ // スキップ時にすべての音声を停止
890
+ stopAllSounds();
891
+ stopVoice();
808
892
  pluginManager.callHook("onScenarioEnd", {
809
893
  scenarioId: scenario.id,
810
894
  scenarioName: scenario.name || "scenario",
811
895
  });
812
896
  onScenarioEnd === null || onScenarioEnd === void 0 ? void 0 : onScenarioEnd();
813
897
  onEnd === null || onEnd === void 0 ? void 0 : onEnd();
814
- }, [hasStarted, state.isEnded, pluginManager, scenario.id, scenario.name, onScenarioEnd, onEnd]);
898
+ }, [
899
+ hasStarted,
900
+ state.isEnded,
901
+ stopAllSounds,
902
+ stopVoice,
903
+ pluginManager,
904
+ scenario.id,
905
+ scenario.name,
906
+ onScenarioEnd,
907
+ onEnd,
908
+ ]);
815
909
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
816
910
  const _handlePrevious = useCallback(() => {
817
911
  if (state.currentBlockIndex > 0) {
@@ -1007,7 +1101,7 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
1007
1101
  effectVolume: mergedSettings.effectVolume,
1008
1102
  textSoundVolume: mergedSettings.textSoundVolume,
1009
1103
  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
1104
+ }, 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
1105
  .getRegisteredComponents()
1012
1106
  .filter((type) => type !== ComponentType.DialogueBox &&
1013
1107
  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,
@@ -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.14",
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
+ }