@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 +103 -9
- package/dist/components/DialogueBox.js +8 -6
- package/dist/components/GameScreen.d.ts +2 -0
- package/dist/components/GameScreen.js +177 -49
- package/dist/contexts/DataContext.js +1 -1
- package/dist/hooks/useBacklog.js +1 -1
- package/dist/hooks/usePlayerLogic.d.ts +2 -1
- package/dist/hooks/usePlayerLogic.js +136 -98
- package/dist/hooks/useSoundPlayer.js +2 -1
- package/dist/types.d.ts +15 -1
- package/dist/utils/branchBlockConverter.js +1 -0
- package/package.json +2 -2
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
|
-
|
|
544
|
-
|
|
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
|
-
|
|
548
|
-
startTyping(content, false);
|
|
589
|
+
doTyping();
|
|
549
590
|
}
|
|
550
591
|
}
|
|
551
|
-
}, [
|
|
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
|
-
}, [
|
|
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
|
-
}, [
|
|
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
|
-
|
|
28
|
-
const
|
|
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 (
|
|
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
|
};
|
|
@@ -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
|
-
|
|
29
|
-
|
|
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
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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: (
|
|
59
|
-
y: (
|
|
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
|
-
|
|
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 (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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
|
-
}
|
|
189
|
+
}
|
|
97
190
|
}
|
|
98
191
|
prevDisplayedCharIdsRef.current = currentIds;
|
|
99
192
|
prevDisplayedCharsRef.current = displayedCharacters;
|
|
100
|
-
}, [displayedCharacters]);
|
|
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]);
|
|
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
|
-
|
|
470
|
+
var _a, _b;
|
|
373
471
|
const positions = new Map();
|
|
374
|
-
if (
|
|
472
|
+
if (characters.length === 0)
|
|
375
473
|
return positions;
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
383
|
-
//
|
|
384
|
-
const totalWidth = (
|
|
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
|
-
|
|
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として提供)
|
package/dist/hooks/useBacklog.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|