@luna-editor/engine 0.5.12 → 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
@@ -16,6 +16,7 @@ import { BackgroundLayer } from "./components/BackgroundLayer";
16
16
  import { ClickWaitIndicator } from "./components/ClickWaitIndicator";
17
17
  import { ConversationBranchBox } from "./components/ConversationBranchBox";
18
18
  import { DialogueBox } from "./components/DialogueBox";
19
+ import { EndScreen } from "./components/EndScreen";
19
20
  import { FullscreenTextBox } from "./components/FullscreenTextBox";
20
21
  import { GameScreen } from "./components/GameScreen";
21
22
  import { OverlayUI } from "./components/OverlayUI";
@@ -33,6 +34,7 @@ import { usePluginEvents } from "./hooks/usePluginEvents";
33
34
  import { usePreloadImages } from "./hooks/usePreloadImages";
34
35
  import { useSoundPlayer } from "./hooks/useSoundPlayer";
35
36
  import { useTypewriter } from "./hooks/useTypewriter";
37
+ import { useVoice } from "./hooks/useVoice";
36
38
  import { PluginManager } from "./plugin/PluginManager";
37
39
  import { ComponentType } from "./sdk";
38
40
  import { convertBranchBlockToScenarioBlock } from "./utils/branchBlockConverter";
@@ -40,7 +42,7 @@ import { BranchNavigator } from "./utils/branchNavigator";
40
42
  import { VariableManager } from "./utils/variableManager";
41
43
  const EMPTY_PLUGINS = [];
42
44
  const EMPTY_SOUNDS = [];
43
- export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGINS, sounds = EMPTY_SOUNDS, onEnd, onScenarioEnd, onScenarioStart, onScenarioCancelled, onSettingsChange, className, autoplay = false, preventDefaultScroll = true, screenSize: screenSizeProp, disableKeyboardNavigation = false, }) => {
45
+ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGINS, sounds = EMPTY_SOUNDS, onEnd, onScenarioEnd, onScenarioStart, onScenarioCancelled, onSettingsChange, className, autoplay = false, preventDefaultScroll = true, screenSize: screenSizeProp, disableKeyboardNavigation = false, hideEndScreen = false, }) => {
44
46
  var _a, _b, _c, _d;
45
47
  // scenario.blocks が存在しない場合は空の配列を使用
46
48
  const scenario = useMemo(() => {
@@ -409,7 +411,7 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
409
411
  }, 100);
410
412
  return () => clearTimeout(timeoutId);
411
413
  }, [pluginManager, isFirstRenderComplete]);
412
- const { displayText, isTyping, skipTyping, startTyping, resetAccumulated } = useTypewriter({
414
+ const { displayText, isTyping, skipTyping, startTyping, resetAccumulated, reset: resetTypewriter, } = useTypewriter({
413
415
  speed: mergedSettings.textSpeed,
414
416
  });
415
417
  // 現在の表示可能なブロックを取得(分岐ブロックが優先)
@@ -456,11 +458,37 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
456
458
  return blocks;
457
459
  }, [scenario.blocks, displayableBlockIndices, state.currentBlockIndex]);
458
460
  // サウンド再生フック
459
- useSoundPlayer({
461
+ const { stopAll: stopAllSounds } = useSoundPlayer({
460
462
  soundBlocks: soundBlocksToProcess,
461
463
  isFirstRenderComplete,
462
464
  muteAudio: mergedSettings.muteAudio,
463
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
+ ]);
464
492
  // 会話分岐機能
465
493
  const branchNavigatorRef = useRef(new BranchNavigator());
466
494
  const conversationBranch = useConversationBranch({
@@ -514,6 +542,10 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
514
542
  }
515
543
  };
516
544
  }, [pluginManager, branchState]);
545
+ // キャラクター登場フェード待ち管理(refはここで宣言、検出ロジックはdisplayedCharacters定義後)
546
+ const isWaitingForEntranceRef = useRef(false);
547
+ const pendingTypingRef = useRef(null);
548
+ const prevCharIdSetRef = useRef(new Set());
517
549
  // ダイアログ表示とアクションノード実行のuseEffectを分離
518
550
  useEffect(() => {
519
551
  if (currentBlock && isFirstRenderComplete && pluginsLoaded) {
@@ -539,15 +571,33 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
539
571
  }
540
572
  const isContinuableBlock = currentBlock.blockType === "dialogue" ||
541
573
  currentBlock.blockType === "narration";
542
- if (continueMode && isContinuableBlock) {
543
- 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;
544
587
  }
545
588
  else {
546
- resetAccumulated();
547
- startTyping(content, false);
589
+ doTyping();
548
590
  }
549
591
  }
550
- }, [currentBlock, previousBlock, startTyping, resetAccumulated, isFirstRenderComplete, pluginsLoaded]);
592
+ }, [
593
+ currentBlock,
594
+ previousBlock,
595
+ startTyping,
596
+ resetAccumulated,
597
+ resetTypewriter,
598
+ isFirstRenderComplete,
599
+ pluginsLoaded,
600
+ ]);
551
601
  // 分岐ブロック自動ロード処理
552
602
  useEffect(() => {
553
603
  if (currentBlock && currentBlock.blockType === "conversation_branch") {
@@ -624,6 +674,9 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
624
674
  scenarioName: scenario.name || "scenario",
625
675
  });
626
676
  }
677
+ // リスタート時にすべての音声を停止
678
+ stopAllSounds();
679
+ stopVoice();
627
680
  setIsFirstRenderComplete(false);
628
681
  setHasStarted(false); // これにより次の useEffect で onScenarioStart が呼ばれる
629
682
  setCurrentBranchBlock(null);
@@ -643,6 +696,8 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
643
696
  pluginManager,
644
697
  scenario.id,
645
698
  scenario.name,
699
+ stopAllSounds,
700
+ stopVoice,
646
701
  ]);
647
702
  // プラグインからシナリオを中断するためのコールバック
648
703
  const cancelScenario = useCallback(() => {
@@ -653,7 +708,14 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
653
708
  scenarioName: scenario.name || "scenario",
654
709
  });
655
710
  }
656
- }, [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
+ ]);
657
719
  const { handleNext: handleNextInternal, handlePrevious: handlePreviousInternal, togglePlay: _togglePlay, restart: _restartInternal, displayedCharacters, } = usePlayerLogic({
658
720
  state: Object.assign(Object.assign({}, state), { currentBlockIndex: actualBlockIndex }),
659
721
  setState: (newState) => {
@@ -685,7 +747,28 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
685
747
  customRestart: restart,
686
748
  variableManager: variableManagerRef.current,
687
749
  disableKeyboardNavigation,
750
+ onStopAllSounds: useCallback(() => {
751
+ stopAllSounds();
752
+ stopVoice();
753
+ }, [stopAllSounds, stopVoice]),
688
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
+ }, []);
689
772
  // プラグインイベント処理
690
773
  const realBlockIndex = currentBlock
691
774
  ? scenario.blocks.indexOf(currentBlock)
@@ -760,6 +843,9 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
760
843
  }
761
844
  else {
762
845
  setState((prev) => (Object.assign(Object.assign({}, prev), { isEnded: true, isPlaying: false })));
846
+ // シナリオ終了時にすべての音声を停止
847
+ stopAllSounds();
848
+ stopVoice();
763
849
  // プラグインのonScenarioEndフックを呼び出し
764
850
  pluginManager.callHook("onScenarioEnd", {
765
851
  scenarioId: scenario.id,
@@ -776,9 +862,11 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
776
862
  handleNextInternal,
777
863
  onEnd,
778
864
  onScenarioEnd, // プラグインのonScenarioEndフックを呼び出し
865
+ stopVoice,
779
866
  pluginManager,
780
867
  scenario.id,
781
868
  scenario.name,
869
+ stopAllSounds,
782
870
  ]);
783
871
  // プラグインからAUTO再生を制御するためのコールバック
784
872
  const toggleAutoPlay = useCallback(() => {
@@ -798,13 +886,26 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
798
886
  if (!hasStarted || state.isEnded)
799
887
  return;
800
888
  setState((prev) => (Object.assign(Object.assign({}, prev), { isEnded: true, isPlaying: false })));
889
+ // スキップ時にすべての音声を停止
890
+ stopAllSounds();
891
+ stopVoice();
801
892
  pluginManager.callHook("onScenarioEnd", {
802
893
  scenarioId: scenario.id,
803
894
  scenarioName: scenario.name || "scenario",
804
895
  });
805
896
  onScenarioEnd === null || onScenarioEnd === void 0 ? void 0 : onScenarioEnd();
806
897
  onEnd === null || onEnd === void 0 ? void 0 : onEnd();
807
- }, [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
+ ]);
808
909
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
809
910
  const _handlePrevious = useCallback(() => {
810
911
  if (state.currentBlockIndex > 0) {
@@ -967,7 +1068,7 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
967
1068
  return width / height;
968
1069
  };
969
1070
  // 条件付きレンダリングを JSX で処理(フックの後、early return なし)
970
- return (_jsxs(_Fragment, { children: [!currentBlock && !state.isEnded && (_jsx("div", { className: clsx("flex items-center justify-center p-8", className), children: _jsx("p", { className: "text-gray-500", children: "\u30B7\u30CA\u30EA\u30AA\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093" }) })), currentBlock && !state.isEnded && (_jsxs(_Fragment, { children: [(!imagesLoaded ||
1071
+ return (_jsxs(_Fragment, { children: [!currentBlock && !state.isEnded && (_jsx("div", { className: clsx("flex items-center justify-center p-8", className), children: _jsx("p", { className: "text-gray-500", children: "\u30B7\u30CA\u30EA\u30AA\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093" }) })), state.isEnded && !hideEndScreen && (_jsx(EndScreen, { scenarioName: scenario.name, onRestart: restart, className: className })), currentBlock && !state.isEnded && (_jsxs(_Fragment, { children: [(!imagesLoaded ||
971
1072
  !fontsLoaded ||
972
1073
  !isFirstRenderComplete ||
973
1074
  !pluginsLoaded) && (_jsx("div", { className: clsx("luna-player fixed inset-0 bg-black overflow-hidden flex items-center justify-center z-50", className), style: {
@@ -1000,7 +1101,7 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
1000
1101
  effectVolume: mergedSettings.effectVolume,
1001
1102
  textSoundVolume: mergedSettings.textSoundVolume,
1002
1103
  muteAudio: (_b = mergedSettings.muteAudio) !== null && _b !== void 0 ? _b : false,
1003
- }, 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
1004
1105
  .getRegisteredComponents()
1005
1106
  .filter((type) => type !== ComponentType.DialogueBox &&
1006
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(() => {
@@ -0,0 +1,5 @@
1
+ import type { DisplayedCharacter } from "../types";
2
+ export declare const useImagePreloader: (
3
+ displayedCharacters: DisplayedCharacter[],
4
+ singleImageUrl?: string
5
+ ) => boolean;
@@ -0,0 +1,53 @@
1
+ import { useEffect, useState } from "react";
2
+ export var useImagePreloader = function (displayedCharacters, singleImageUrl) {
3
+ var _a = useState(true),
4
+ isLoading = _a[0],
5
+ setIsLoading = _a[1];
6
+ useEffect(
7
+ function () {
8
+ var imageUrls = [];
9
+ // 複数キャラクター表示の場合
10
+ if (displayedCharacters.length > 0) {
11
+ displayedCharacters.forEach(function (char) {
12
+ if (char.entityState.imageUrl) {
13
+ imageUrls.push(char.entityState.imageUrl);
14
+ }
15
+ });
16
+ } else if (singleImageUrl) {
17
+ // 単一キャラクター表示の場合
18
+ imageUrls.push(singleImageUrl);
19
+ }
20
+ // 画像がない場合は即座に完了
21
+ if (imageUrls.length === 0) {
22
+ setIsLoading(false);
23
+ return;
24
+ }
25
+ setIsLoading(true);
26
+ // すべての画像を並列でプリロード
27
+ var loadPromises = imageUrls.map(function (url) {
28
+ return new Promise(function (resolve, reject) {
29
+ var img = new Image();
30
+ img.onload = function () {
31
+ return resolve();
32
+ };
33
+ img.onerror = function () {
34
+ return reject(new Error("Failed to load image: ".concat(url)));
35
+ };
36
+ img.src = url;
37
+ });
38
+ });
39
+ // すべての画像の読み込みを待つ
40
+ Promise.all(loadPromises)
41
+ .then(function () {
42
+ setIsLoading(false);
43
+ })
44
+ .catch(function (error) {
45
+ console.error("Image preload error:", error);
46
+ // エラーが発生しても続行
47
+ setIsLoading(false);
48
+ });
49
+ },
50
+ [displayedCharacters, singleImageUrl]
51
+ );
52
+ return isLoading;
53
+ };
@@ -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
  // エラーハンドリング(イベントリスナーを保持してクリーンアップ可能にする)
@@ -0,0 +1,41 @@
1
+ /**
2
+ * プラグインAPIからReactインスタンスを設定
3
+ */
4
+ export declare function setReactRuntime(react: any): void;
5
+ /**
6
+ * JSX Transform用のjsx関数
7
+ */
8
+ export declare function jsx(type: any, props: any, key?: any): any;
9
+ /**
10
+ * JSX Transform用のjsxs関数(複数子要素用)
11
+ */
12
+ export declare function jsxs(type: any, props: any, key?: any): any;
13
+ /**
14
+ * Fragment用
15
+ */
16
+ export declare function Fragment(props: {
17
+ children?: any;
18
+ }): any;
19
+ /**
20
+ * Reactフックと関数のプロキシ
21
+ */
22
+ export declare const useState: (...args: any[]) => any;
23
+ export declare const useEffect: (...args: any[]) => any;
24
+ export declare const useCallback: (...args: any[]) => any;
25
+ export declare const useMemo: (...args: any[]) => any;
26
+ export declare const useRef: (...args: any[]) => any;
27
+ export declare const useContext: (...args: any[]) => any;
28
+ export declare const useReducer: (...args: any[]) => any;
29
+ export declare const createElement: (...args: any[]) => any;
30
+ declare const _default: {
31
+ createElement: (...args: any[]) => any;
32
+ Fragment: typeof Fragment;
33
+ useState: (...args: any[]) => any;
34
+ useEffect: (...args: any[]) => any;
35
+ useCallback: (...args: any[]) => any;
36
+ useMemo: (...args: any[]) => any;
37
+ useRef: (...args: any[]) => any;
38
+ useContext: (...args: any[]) => any;
39
+ useReducer: (...args: any[]) => any;
40
+ };
41
+ export default _default;
@@ -0,0 +1,99 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ /* eslint-disable import/no-anonymous-default-export */
3
+ let runtimeReact = null;
4
+ /**
5
+ * プラグインAPIからReactインスタンスを設定
6
+ */
7
+ export function setReactRuntime(react) {
8
+ runtimeReact = react;
9
+ }
10
+ /**
11
+ * JSX Transform用のjsx関数
12
+ */
13
+ export function jsx(type, props, key) {
14
+ if (!runtimeReact) {
15
+ throw new Error("React runtime not initialized. Make sure plugin is loaded properly.");
16
+ }
17
+ return runtimeReact.createElement(type, key ? Object.assign(Object.assign({}, props), { key }) : props);
18
+ }
19
+ /**
20
+ * JSX Transform用のjsxs関数(複数子要素用)
21
+ */
22
+ export function jsxs(type, props, key) {
23
+ if (!runtimeReact) {
24
+ throw new Error("React runtime not initialized. Make sure plugin is loaded properly.");
25
+ }
26
+ return runtimeReact.createElement(type, key ? Object.assign(Object.assign({}, props), { key }) : props);
27
+ }
28
+ /**
29
+ * Fragment用
30
+ */
31
+ export function Fragment(props) {
32
+ if (!runtimeReact) {
33
+ throw new Error("React runtime not initialized. Make sure plugin is loaded properly.");
34
+ }
35
+ return runtimeReact.createElement(runtimeReact.Fragment, null, props.children);
36
+ }
37
+ /**
38
+ * Reactフックと関数のプロキシ
39
+ */
40
+ export const useState = (...args) => {
41
+ if (!runtimeReact) {
42
+ throw new Error("React runtime not initialized. Make sure plugin is loaded properly.");
43
+ }
44
+ return runtimeReact.useState(...args);
45
+ };
46
+ export const useEffect = (...args) => {
47
+ if (!runtimeReact) {
48
+ throw new Error("React runtime not initialized. Make sure plugin is loaded properly.");
49
+ }
50
+ return runtimeReact.useEffect(...args);
51
+ };
52
+ export const useCallback = (...args) => {
53
+ if (!runtimeReact) {
54
+ throw new Error("React runtime not initialized. Make sure plugin is loaded properly.");
55
+ }
56
+ return runtimeReact.useCallback(...args);
57
+ };
58
+ export const useMemo = (...args) => {
59
+ if (!runtimeReact) {
60
+ throw new Error("React runtime not initialized. Make sure plugin is loaded properly.");
61
+ }
62
+ return runtimeReact.useMemo(...args);
63
+ };
64
+ export const useRef = (...args) => {
65
+ if (!runtimeReact) {
66
+ throw new Error("React runtime not initialized. Make sure plugin is loaded properly.");
67
+ }
68
+ return runtimeReact.useRef(...args);
69
+ };
70
+ export const useContext = (...args) => {
71
+ if (!runtimeReact) {
72
+ throw new Error("React runtime not initialized. Make sure plugin is loaded properly.");
73
+ }
74
+ return runtimeReact.useContext(...args);
75
+ };
76
+ export const useReducer = (...args) => {
77
+ if (!runtimeReact) {
78
+ throw new Error("React runtime not initialized. Make sure plugin is loaded properly.");
79
+ }
80
+ return runtimeReact.useReducer(...args);
81
+ };
82
+ export const createElement = (...args) => {
83
+ if (!runtimeReact) {
84
+ throw new Error("React runtime not initialized. Make sure plugin is loaded properly.");
85
+ }
86
+ return runtimeReact.createElement(...args);
87
+ };
88
+ // デフォルトエクスポート(互換性用)
89
+ export default {
90
+ createElement,
91
+ Fragment,
92
+ useState,
93
+ useEffect,
94
+ useCallback,
95
+ useMemo,
96
+ useRef,
97
+ useContext,
98
+ useReducer,
99
+ };
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;
@@ -374,6 +383,8 @@ export interface PlayerProps {
374
383
  };
375
384
  /** キーボードナビゲーションを無効化するかどうか(デフォルト: false) */
376
385
  disableKeyboardNavigation?: boolean;
386
+ /** 終了画面(リスタートボタン等)を非表示にするかどうか(デフォルト: false) */
387
+ hideEndScreen?: boolean;
377
388
  }
378
389
  export type VariableValue = string | number | boolean;
379
390
  export interface PlayerState {
@@ -426,6 +437,11 @@ export interface ConversationBranchBlock {
426
437
  speakerStateId: string | null;
427
438
  actionNodeId: string | null;
428
439
  partVoiceId: string | null;
440
+ partVoice?: {
441
+ id: string;
442
+ name: string;
443
+ url: string;
444
+ } | null;
429
445
  order: number;
430
446
  createdAt: Date;
431
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.12",
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
+ }