@luna-editor/engine 0.4.5 → 0.5.1

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
@@ -49,7 +49,7 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
49
49
  return (Object.assign(Object.assign({}, scenarioProp), { blocks: (_a = scenarioProp.blocks) !== null && _a !== void 0 ? _a : [] }));
50
50
  }, [scenarioProp]);
51
51
  // デフォルト値とマージ
52
- const mergedSettings = Object.assign({ textSpeed: 80, autoPlaySpeed: 3, bgmVolume: 0.5, seVolume: 1.0, voiceVolume: 1.0, effectVolume: 1.0, textSoundVolume: 1.0, defaultBackgroundFadeDuration: 0, inactiveCharacterBrightness: 0.8 }, settings);
52
+ const mergedSettings = Object.assign({ textSpeed: 80, autoPlaySpeed: 3, bgmVolume: 0.5, seVolume: 1.0, voiceVolume: 1.0, effectVolume: 1.0, textSoundVolume: 1.0, defaultBackgroundFadeDuration: 0, inactiveCharacterBrightness: 0.8, characterSpacing: 0.2 }, settings);
53
53
  // プラグインからの設定更新ハンドラ
54
54
  const handleSettingsUpdate = useCallback((updatedSettings) => {
55
55
  var _a, _b;
@@ -645,6 +645,16 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
645
645
  scenario.id,
646
646
  scenario.name,
647
647
  ]);
648
+ // プラグインからシナリオを中断するためのコールバック
649
+ const cancelScenario = useCallback(() => {
650
+ if (hasStarted && !state.isEnded) {
651
+ onScenarioCancelled === null || onScenarioCancelled === void 0 ? void 0 : onScenarioCancelled();
652
+ pluginManager.callHook("onScenarioEnd", {
653
+ scenarioId: scenario.id,
654
+ scenarioName: scenario.name || "scenario",
655
+ });
656
+ }
657
+ }, [hasStarted, state.isEnded, onScenarioCancelled, pluginManager, scenario.id, scenario.name]);
648
658
  const { handleNext: handleNextInternal, handlePrevious: handlePreviousInternal, togglePlay: _togglePlay, restart: _restartInternal, displayedCharacters, } = usePlayerLogic({
649
659
  state: Object.assign(Object.assign({}, state), { currentBlockIndex: actualBlockIndex }),
650
660
  setState: (newState) => {
@@ -918,18 +928,25 @@ export const Player = ({ scenario: scenarioProp, settings, plugins = EMPTY_PLUGI
918
928
  touchAction: "none",
919
929
  userSelect: "none",
920
930
  WebkitUserSelect: "none",
921
- }, children: _jsx("div", { ref: aspectRatioContainerRef, className: "relative bg-white flex flex-col overflow-hidden", style: {
922
- aspectRatio: getAspectRatio(),
923
- width: `min(100vw, calc(100vh * ${getAspectRatioValue()}))`,
924
- height: `min(100vh, calc(100vw / ${getAspectRatioValue()}))`,
925
- }, children: _jsx(DataProvider, { data: dataContext, onSettingsUpdate: handleSettingsUpdate, onEmotionEffectUpdate: handleEmotionEffectUpdate, children: _jsx(AudioProvider, { settings: {
931
+ }, children: _jsx("div", { ref: aspectRatioContainerRef, className: "relative bg-white flex flex-col overflow-hidden", style: screenSizeProp
932
+ ? {
933
+ // プレビューモード: 親コンテナ(固定論理サイズ)を100%で埋める
934
+ width: "100%",
935
+ height: "100%",
936
+ }
937
+ : {
938
+ // 通常再生: ビューポートに合わせてアスペクト比を維持
939
+ aspectRatio: getAspectRatio(),
940
+ width: `min(100vw, calc(100vh * ${getAspectRatioValue()}))`,
941
+ height: `min(100vh, calc(100vw / ${getAspectRatioValue()}))`,
942
+ }, children: _jsx(DataProvider, { data: dataContext, onSettingsUpdate: handleSettingsUpdate, onEmotionEffectUpdate: handleEmotionEffectUpdate, onCancelScenario: cancelScenario, children: _jsx(AudioProvider, { settings: {
926
943
  bgmVolume: mergedSettings.bgmVolume,
927
944
  seVolume: mergedSettings.seVolume,
928
945
  voiceVolume: mergedSettings.voiceVolume,
929
946
  effectVolume: mergedSettings.effectVolume,
930
947
  textSoundVolume: mergedSettings.textSoundVolume,
931
948
  muteAudio: (_b = mergedSettings.muteAudio) !== null && _b !== void 0 ? _b : false,
932
- }, 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 }, "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
949
+ }, 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
933
950
  .getRegisteredComponents()
934
951
  .filter((type) => type !== ComponentType.DialogueBox &&
935
952
  type !== ComponentType.ConversationBranch)
@@ -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.1, }) {
8
+ export const GameScreen = memo(function GameScreen({ scenario, currentBlock, previousBlock, displayedCharacters, inactiveCharacterBrightness = 0.8, characterSpacing = 0.2, }) {
9
9
  var _a;
10
10
  // キャラクターごとのフェード状態を管理
11
11
  const [fadeStates, setFadeStates] = useState(new Map());
@@ -13,6 +13,142 @@ export const GameScreen = memo(function GameScreen({ scenario, currentBlock, pre
13
13
  const fadeStartTimeRef = useRef(new Map());
14
14
  // 完了したフェードを追跡(同じpendingFadeで再度フェードしないため)
15
15
  const completedFadesRef = useRef(new Set());
16
+ // 登場・退場フェード管理
17
+ const ENTRANCE_FADE_DURATION = 300; // ms
18
+ const [entranceFades, setEntranceFades] = useState(new Map()); // objectId -> opacity (0-1)
19
+ const [exitingCharacters, setExitingCharacters] = useState([]);
20
+ const prevDisplayedCharIdsRef = useRef(new Set());
21
+ const entranceFadeStartRef = useRef(new Map());
22
+ const entranceAnimFrameRef = useRef(null);
23
+ const prevDisplayedCharsRef = useRef([]);
24
+ const pendingEntranceRef = useRef([]);
25
+ // 退場キャラの最終位置を保存(auto-layout再計算に依存しないため)
26
+ const exitPositionsRef = useRef(new Map());
27
+ // 登場・退場の検出
28
+ useEffect(() => {
29
+ var _a, _b;
30
+ const currentIds = new Set(displayedCharacters.map((c) => c.objectId));
31
+ const prevIds = prevDisplayedCharIdsRef.current;
32
+ const prevChars = prevDisplayedCharsRef.current;
33
+ // 初回レンダリング(前回データなし)はスキップ
34
+ if (prevIds.size === 0 && prevChars.length === 0) {
35
+ prevDisplayedCharIdsRef.current = currentIds;
36
+ prevDisplayedCharsRef.current = displayedCharacters;
37
+ return;
38
+ }
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
+ prevDisplayedCharsRef.current = displayedCharacters;
45
+ return;
46
+ }
47
+ // キャラ構成が変わった場合: 前回の全キャラを退場、現在の全キャラを登場
48
+ // これにより同じキャラが含まれていても必ずフェードイン/アウトが発生
49
+ const allExitChars = [...prevChars];
50
+ const allEnterIds = [...currentIds];
51
+ // 退場フェードアウト開始
52
+ if (allExitChars.length > 0) {
53
+ const now = performance.now();
54
+ // 退場キャラの現在の位置を保存
55
+ for (const char of allExitChars) {
56
+ if (char.positionX !== null && char.positionY !== null) {
57
+ 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,
60
+ });
61
+ }
62
+ else {
63
+ const autoPositions = calculateAutoLayout(prevChars, characterSpacing !== null && characterSpacing !== void 0 ? characterSpacing : 0.2);
64
+ const pos = autoPositions.get(char.objectId);
65
+ if (pos) {
66
+ exitPositionsRef.current.set(char.objectId, pos);
67
+ }
68
+ }
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
+ entranceFadeStartRef.current.set(char.objectId, now);
75
+ }
76
+ setEntranceFades(newFades);
77
+ }
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 = [];
87
+ const now = performance.now();
88
+ setEntranceFades((prev) => {
89
+ const newFades = new Map(prev);
90
+ for (const id of ids) {
91
+ newFades.set(id, 0);
92
+ entranceFadeStartRef.current.set(id, now);
93
+ }
94
+ return newFades;
95
+ });
96
+ }, delay);
97
+ }
98
+ prevDisplayedCharIdsRef.current = currentIds;
99
+ prevDisplayedCharsRef.current = displayedCharacters;
100
+ }, [displayedCharacters]); // eslint-disable-line react-hooks/exhaustive-deps
101
+ // 登場・退場フェードのアニメーションループ
102
+ useEffect(() => {
103
+ if (entranceFades.size === 0)
104
+ return;
105
+ const exitingIds = new Set(exitingCharacters.map((c) => c.objectId));
106
+ const animate = () => {
107
+ const now = performance.now();
108
+ let hasActive = false;
109
+ const newFades = new Map();
110
+ const completedExitIds = [];
111
+ for (const [objectId] of entranceFades) {
112
+ const startTime = entranceFadeStartRef.current.get(objectId);
113
+ if (!startTime)
114
+ continue;
115
+ const elapsed = now - startTime;
116
+ const progress = Math.min(elapsed / ENTRANCE_FADE_DURATION, 1);
117
+ const isExiting = exitingIds.has(objectId);
118
+ // 退場: 1→0, 登場: 0→1
119
+ const opacity = isExiting ? 1 - progress : progress;
120
+ newFades.set(objectId, opacity);
121
+ if (progress < 1) {
122
+ hasActive = true;
123
+ }
124
+ else if (isExiting) {
125
+ completedExitIds.push(objectId);
126
+ }
127
+ }
128
+ setEntranceFades(newFades);
129
+ // 退場フェード完了したキャラを削除
130
+ if (completedExitIds.length > 0) {
131
+ setExitingCharacters((prev) => prev.filter((c) => !completedExitIds.includes(c.objectId)));
132
+ // 保存位置もクリーンアップ
133
+ for (const id of completedExitIds) {
134
+ exitPositionsRef.current.delete(id);
135
+ }
136
+ }
137
+ if (hasActive) {
138
+ entranceAnimFrameRef.current = requestAnimationFrame(animate);
139
+ }
140
+ else {
141
+ setEntranceFades(new Map());
142
+ entranceFadeStartRef.current.clear();
143
+ }
144
+ };
145
+ entranceAnimFrameRef.current = requestAnimationFrame(animate);
146
+ return () => {
147
+ if (entranceAnimFrameRef.current) {
148
+ cancelAnimationFrame(entranceAnimFrameRef.current);
149
+ }
150
+ };
151
+ }, [entranceFades.size > 0, exitingCharacters]); // eslint-disable-line react-hooks/exhaustive-deps
16
152
  // コンテナサイズを取得(ResizeObserverで確実に取得)
17
153
  const containerRef = useRef(null);
18
154
  const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
@@ -59,7 +195,7 @@ export const GameScreen = memo(function GameScreen({ scenario, currentBlock, pre
59
195
  // character_entranceブロックのキャラクター画像(レイヤー情報含む)
60
196
  if (block.characters) {
61
197
  block.characters.forEach((char) => {
62
- var _a, _b, _c, _d, _e, _f;
198
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
63
199
  if (char.entityState.imageUrl || ((_a = char.baseBodyState) === null || _a === void 0 ? void 0 : _a.imageUrl)) {
64
200
  const key = `${char.objectId}-${char.entityStateId}`;
65
201
  images.set(key, {
@@ -75,6 +211,10 @@ export const GameScreen = memo(function GameScreen({ scenario, currentBlock, pre
75
211
  baseBodyTranslateX: (_e = char.baseBodyState) === null || _e === void 0 ? void 0 : _e.translateX,
76
212
  baseBodyTranslateY: (_f = char.baseBodyState) === null || _f === void 0 ? void 0 : _f.translateY,
77
213
  layers: char.entityState.layers,
214
+ // EntityStateのcrop値を優先、なければScenarioBlockCharacterのcrop値
215
+ cropLeft: (_g = char.entityState.cropLeft) !== null && _g !== void 0 ? _g : char.cropLeft,
216
+ cropRight: (_h = char.entityState.cropRight) !== null && _h !== void 0 ? _h : char.cropRight,
217
+ cropFade: (_j = char.entityState.cropFade) !== null && _j !== void 0 ? _j : char.cropFade,
78
218
  });
79
219
  }
80
220
  });
@@ -251,7 +391,7 @@ export const GameScreen = memo(function GameScreen({ scenario, currentBlock, pre
251
391
  };
252
392
  // キャラクター描画用のヘルパー関数
253
393
  const renderCharacter = (image, displayedChar, isCurrentSpeaker, keyPrefix) => {
254
- var _a, _b, _c, _d, _e, _f, _g, _h;
394
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
255
395
  // フェード状態を確認
256
396
  const fadeState = fadeStates.get(image.objectId);
257
397
  const isFadingIn = fadeState && fadeState.previousEntityStateId !== image.entityStateId;
@@ -266,8 +406,14 @@ export const GameScreen = memo(function GameScreen({ scenario, currentBlock, pre
266
406
  (pendingFade === null || pendingFade === void 0 ? void 0 : pendingFade.objectId) === image.objectId &&
267
407
  pendingFade.previousEntityStateId === image.entityStateId &&
268
408
  !completedFadesRef.current.has(pendingFade.fadeKey);
409
+ // 退場中のキャラクターか判定
410
+ const isExitingChar = exitingCharacters.some((c) => c.objectId === image.objectId);
269
411
  // 表示すべきかどうか
270
- const shouldDisplay = displayedChar || isCurrentSpeaker || isFadingOut || isPendingFadeOut;
412
+ const shouldDisplay = displayedChar ||
413
+ isCurrentSpeaker ||
414
+ isFadingOut ||
415
+ isPendingFadeOut ||
416
+ isExitingChar;
271
417
  // opacityを決定
272
418
  let opacity = 1;
273
419
  if (isFadingIn || isPendingFadeIn) {
@@ -278,6 +424,15 @@ export const GameScreen = memo(function GameScreen({ scenario, currentBlock, pre
278
424
  // フェードアウト中
279
425
  opacity = fadeState ? 1 - fadeState.fadeProgress : 1;
280
426
  }
427
+ // 登場・退場フェード(character_entrance/exit時)
428
+ const entranceFadeOpacity = entranceFades.get(image.objectId);
429
+ if (entranceFadeOpacity !== undefined) {
430
+ opacity = entranceFadeOpacity;
431
+ }
432
+ // 登場待ち(退場フェード完了待ち)のキャラは非表示
433
+ if (pendingEntranceRef.current.includes(image.objectId)) {
434
+ opacity = 0;
435
+ }
281
436
  // 明るさを決定
282
437
  let brightness = 1;
283
438
  if (displayedCharacters.length > 0 && displayedChar) {
@@ -298,19 +453,31 @@ export const GameScreen = memo(function GameScreen({ scenario, currentBlock, pre
298
453
  // 位置を決定(カスタム位置またはデフォルト位置)
299
454
  // 新座標系: x: -1=左見切れ, 0=中央, 1=右見切れ / y: -1=上見切れ, 0=中央, 1=下見切れ
300
455
  let finalPosition = { x: 0, y: 0 };
301
- if (displayedChar) {
456
+ // 退場中のキャラは保存された位置情報を使用(移動しない)
457
+ const exitingChar = isExitingChar
458
+ ? exitingCharacters.find((c) => c.objectId === image.objectId)
459
+ : null;
460
+ const positionSource = displayedChar !== null && displayedChar !== void 0 ? displayedChar : exitingChar;
461
+ if (isExitingChar) {
462
+ // 退場中のキャラ: 保存された位置を使用(絶対に移動しない)
463
+ const savedPos = exitPositionsRef.current.get(image.objectId);
464
+ if (savedPos) {
465
+ finalPosition = savedPos;
466
+ }
467
+ }
468
+ else if (positionSource) {
302
469
  // カスタム位置が設定されている場合(詳細モード)
303
- if (displayedChar.positionX !== null &&
304
- displayedChar.positionY !== null) {
470
+ if (positionSource.positionX !== null &&
471
+ positionSource.positionY !== null) {
305
472
  finalPosition = {
306
- x: (_e = displayedChar.positionX) !== null && _e !== void 0 ? _e : 0,
307
- y: (_f = displayedChar.positionY) !== null && _f !== void 0 ? _f : 0,
473
+ x: (_e = positionSource.positionX) !== null && _e !== void 0 ? _e : 0,
474
+ y: (_f = positionSource.positionY) !== null && _f !== void 0 ? _f : 0,
308
475
  };
309
476
  }
310
477
  else {
311
478
  // 簡易モード: 自動配置
312
- const autoPositions = calculateAutoLayout(displayedCharacters, characterSpacing !== null && characterSpacing !== void 0 ? characterSpacing : 0.25);
313
- const autoPos = autoPositions.get(displayedChar.objectId);
479
+ const autoPositions = calculateAutoLayout(displayedCharacters, characterSpacing !== null && characterSpacing !== void 0 ? characterSpacing : 0.2);
480
+ const autoPos = autoPositions.get(positionSource.objectId);
314
481
  if (autoPos) {
315
482
  finalPosition = autoPos;
316
483
  }
@@ -346,19 +513,24 @@ export const GameScreen = memo(function GameScreen({ scenario, currentBlock, pre
346
513
  // 位置をピクセルで計算(CharacterEditDialogと同じ)
347
514
  const leftPx = (leftPercent / 100) * containerSize.width;
348
515
  const topPx = (topPercent / 100) * containerSize.height;
349
- return (_jsx("div", { style: {
350
- position: "absolute",
351
- visibility: shouldDisplay ? "visible" : "hidden",
352
- opacity: opacity,
353
- filter: `brightness(${brightness})`,
354
- zIndex: zIndex,
516
+ // トリミング用のCSS mask計算
517
+ // displayedCharacterのcrop値を優先(キャラクター登場ブロックで設定)
518
+ const cLeft = (_k = (_j = displayedChar === null || displayedChar === void 0 ? void 0 : displayedChar.cropLeft) !== null && _j !== void 0 ? _j : image.cropLeft) !== null && _k !== void 0 ? _k : 0;
519
+ const cRight = (_m = (_l = displayedChar === null || displayedChar === void 0 ? void 0 : displayedChar.cropRight) !== null && _l !== void 0 ? _l : image.cropRight) !== null && _m !== void 0 ? _m : 0;
520
+ const cFade = (_p = (_o = displayedChar === null || displayedChar === void 0 ? void 0 : displayedChar.cropFade) !== null && _o !== void 0 ? _o : image.cropFade) !== null && _p !== void 0 ? _p : 0;
521
+ const hasCrop = cLeft > 0 || cRight > 0;
522
+ // フェード量をpx基準値からコンテナ高さ比でスケーリング
523
+ const fadePx = hasCrop && cFade > 0 ? (cFade / 1080) * containerSize.height : 0;
524
+ const cropMaskStyle = hasCrop
525
+ ? {
526
+ WebkitMaskImage: `linear-gradient(to right, transparent ${cLeft * 100}%, black ${cLeft * 100 + (fadePx > 0 ? (fadePx / baseHeight) * 100 : 0)}%, black ${(1 - cRight) * 100 - (fadePx > 0 ? (fadePx / baseHeight) * 100 : 0)}%, transparent ${(1 - cRight) * 100}%)`,
527
+ maskImage: `linear-gradient(to right, transparent ${cLeft * 100}%, black ${cLeft * 100 + (fadePx > 0 ? (fadePx / baseHeight) * 100 : 0)}%, black ${(1 - cRight) * 100 - (fadePx > 0 ? (fadePx / baseHeight) * 100 : 0)}%, transparent ${(1 - cRight) * 100}%)`,
528
+ }
529
+ : {};
530
+ return (_jsx("div", { style: Object.assign({ position: "absolute", visibility: shouldDisplay ? "visible" : "hidden", opacity: opacity, filter: `brightness(${brightness})`, zIndex: zIndex,
355
531
  // CharacterEditDialogと完全に同じ: left/topを0にしてtransformで位置制御
356
532
  // translate(%)は画像サイズに対する割合
357
- left: 0,
358
- top: 0,
359
- transform: `translate(${leftPx}px, ${topPx}px) translate(${translateXPercent}%, ${translateYPercent}%)`,
360
- transition: fadeState ? "none" : undefined,
361
- }, "data-character-id": image.objectId, "data-character-sprite": true, children: hasLayerFeature && image.baseBodyUrl ? (_jsxs(_Fragment, { children: [_jsx("img", { src: image.baseBodyUrl, alt: "", style: {
533
+ 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: "", style: {
362
534
  height: `${baseHeight}px`,
363
535
  width: "auto",
364
536
  objectFit: "contain",
@@ -407,6 +579,27 @@ export const GameScreen = memo(function GameScreen({ scenario, currentBlock, pre
407
579
  return renderCharacter(fallbackImage, char, false, "char");
408
580
  }
409
581
  return renderCharacter(image, char, false, "char");
582
+ }), exitingCharacters.map((char) => {
583
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
584
+ const imageKey = `${char.objectId}-${char.entityStateId}`;
585
+ const image = imageDataMap.get(imageKey);
586
+ if (!image) {
587
+ const fallbackImage = {
588
+ url: (_b = (_a = char.entityState) === null || _a === void 0 ? void 0 : _a.imageUrl) !== null && _b !== void 0 ? _b : "",
589
+ objectId: char.objectId,
590
+ entityStateId: char.entityStateId,
591
+ scale: (_c = char.entityState) === null || _c === void 0 ? void 0 : _c.scale,
592
+ translateX: (_d = char.entityState) === null || _d === void 0 ? void 0 : _d.translateX,
593
+ translateY: (_e = char.entityState) === null || _e === void 0 ? void 0 : _e.translateY,
594
+ baseBodyUrl: (_f = char.baseBodyState) === null || _f === void 0 ? void 0 : _f.imageUrl,
595
+ baseBodyScale: (_g = char.baseBodyState) === null || _g === void 0 ? void 0 : _g.scale,
596
+ baseBodyTranslateX: (_h = char.baseBodyState) === null || _h === void 0 ? void 0 : _h.translateX,
597
+ baseBodyTranslateY: (_j = char.baseBodyState) === null || _j === void 0 ? void 0 : _j.translateY,
598
+ layers: (_k = char.entityState) === null || _k === void 0 ? void 0 : _k.layers,
599
+ };
600
+ return renderCharacter(fallbackImage, char, false, "exit");
601
+ }
602
+ return renderCharacter(image, char, false, "exit");
410
603
  }), displayedCharacters.length === 0 &&
411
604
  currentBlock.speakerId &&
412
605
  currentBlock.speakerStateId && (_jsxs(_Fragment, { children: [(() => {
@@ -2,6 +2,7 @@ import { type ReactNode } from "react";
2
2
  import type { DataAPI, DataContext, EmotionEffectState, PlayerSettingsData } from "../sdk";
3
3
  type SettingsUpdater = (settings: Partial<PlayerSettingsData>) => void;
4
4
  type EmotionEffectUpdater = (state: EmotionEffectState | null) => void;
5
+ type ScenarioCanceller = () => void;
5
6
  /**
6
7
  * データプロバイダー
7
8
  * プラグインがシナリオ情報にリアクティブにアクセスするためのProvider
@@ -19,6 +20,7 @@ export declare const DataProvider: React.FC<{
19
20
  data: DataContext;
20
21
  onSettingsUpdate?: SettingsUpdater;
21
22
  onEmotionEffectUpdate?: EmotionEffectUpdater;
23
+ onCancelScenario?: ScenarioCanceller;
22
24
  children: ReactNode;
23
25
  }>;
24
26
  /**
@@ -6,6 +6,7 @@ const DataRefContext = createContext(null);
6
6
  const SubscribersContext = createContext(null);
7
7
  const SettingsUpdaterContext = createContext(null);
8
8
  const EmotionEffectUpdaterContext = createContext(null);
9
+ const ScenarioCancellerContext = createContext(null);
9
10
  /**
10
11
  * データプロバイダー
11
12
  * プラグインがシナリオ情報にリアクティブにアクセスするためのProvider
@@ -19,7 +20,7 @@ const EmotionEffectUpdaterContext = createContext(null);
19
20
  * </DataProvider>
20
21
  * ```
21
22
  */
22
- export const DataProvider = ({ data, onSettingsUpdate, onEmotionEffectUpdate, children }) => {
23
+ export const DataProvider = ({ data, onSettingsUpdate, onEmotionEffectUpdate, onCancelScenario, children }) => {
23
24
  const subscribers = useMemo(() => new Map(), []);
24
25
  const previousDataRef = useRef(data);
25
26
  // 安定したデータ参照ホルダー(Context valueとして提供)
@@ -61,7 +62,7 @@ export const DataProvider = ({ data, onSettingsUpdate, onEmotionEffectUpdate, ch
61
62
  }
62
63
  previousDataRef.current = data;
63
64
  }, [data, subscribers]);
64
- return (_jsx(SubscribersContext.Provider, { value: subscribers, children: _jsx(SettingsUpdaterContext.Provider, { value: onSettingsUpdate !== null && onSettingsUpdate !== void 0 ? onSettingsUpdate : null, children: _jsx(EmotionEffectUpdaterContext.Provider, { value: onEmotionEffectUpdate !== null && onEmotionEffectUpdate !== void 0 ? onEmotionEffectUpdate : null, children: _jsx(DataRefContext.Provider, { value: dataRefHolder, children: _jsx(DataContextInstance.Provider, { value: data, children: children }) }) }) }) }));
65
+ return (_jsx(SubscribersContext.Provider, { value: subscribers, children: _jsx(SettingsUpdaterContext.Provider, { value: onSettingsUpdate !== null && onSettingsUpdate !== void 0 ? onSettingsUpdate : null, children: _jsx(EmotionEffectUpdaterContext.Provider, { value: onEmotionEffectUpdate !== null && onEmotionEffectUpdate !== void 0 ? onEmotionEffectUpdate : null, children: _jsx(ScenarioCancellerContext.Provider, { value: onCancelScenario !== null && onCancelScenario !== void 0 ? onCancelScenario : null, children: _jsx(DataRefContext.Provider, { value: dataRefHolder, children: _jsx(DataContextInstance.Provider, { value: data, children: children }) }) }) }) }) }));
65
66
  };
66
67
  /**
67
68
  * DataAPI実装を提供するフック
@@ -75,6 +76,7 @@ export function useDataAPI() {
75
76
  const subscribers = useContext(SubscribersContext);
76
77
  const settingsUpdater = useContext(SettingsUpdaterContext);
77
78
  const emotionEffectUpdater = useContext(EmotionEffectUpdaterContext);
79
+ const scenarioCanceller = useContext(ScenarioCancellerContext);
78
80
  // 注意: usePlaybackTextOptional()をここで呼ぶと、PlaybackTextContextの更新で
79
81
  // useDataAPIを使う全コンポーネントが再レンダリングされてしまう
80
82
  // 代わりに、displayTextが必要なコンポーネントはusePlaybackText()を直接使う
@@ -173,6 +175,14 @@ export function useDataAPI() {
173
175
  console.warn("updateEmotionEffect called but no emotion effect updater provided");
174
176
  }
175
177
  }, [emotionEffectUpdater]);
178
+ const cancelScenario = useCallback(() => {
179
+ if (scenarioCanceller) {
180
+ scenarioCanceller();
181
+ }
182
+ else {
183
+ console.warn("cancelScenario called but no scenario canceller provided");
184
+ }
185
+ }, [scenarioCanceller]);
176
186
  return useMemo(() => ({
177
187
  get,
178
188
  subscribe,
@@ -184,6 +194,7 @@ export function useDataAPI() {
184
194
  setVolumes,
185
195
  getBlockOption,
186
196
  updateEmotionEffect,
197
+ cancelScenario,
187
198
  }), [
188
199
  get,
189
200
  subscribe,
@@ -195,5 +206,6 @@ export function useDataAPI() {
195
206
  setVolumes,
196
207
  getBlockOption,
197
208
  updateEmotionEffect,
209
+ cancelScenario,
198
210
  ]);
199
211
  }
@@ -248,4 +248,10 @@ export interface DataAPI {
248
248
  * @param state - 更新する感情エフェクトの状態
249
249
  */
250
250
  updateEmotionEffect(state: EmotionEffectState | null): void;
251
+ /**
252
+ * シナリオを中断する
253
+ * プラグインのGameMenuなどから呼び出し、シナリオ再生を中断する。
254
+ * Player の onScenarioCancelled コールバックが発火される。
255
+ */
256
+ cancelScenario(): void;
251
257
  }
@@ -20,6 +20,9 @@ const calculateDisplayedCharacters = (blocks, currentIndex) => {
20
20
  positionY: char.positionY,
21
21
  zIndex: char.zIndex,
22
22
  scale: char.scale,
23
+ cropLeft: char.cropLeft,
24
+ cropRight: char.cropRight,
25
+ cropFade: char.cropFade,
23
26
  object: char.object,
24
27
  entityState: char.entityState,
25
28
  // レイヤー機能用
@@ -40,13 +40,36 @@ export const useSoundPlayer = ({ soundBlocks, isFirstRenderComplete, muteAudio =
40
40
  isBGM,
41
41
  isVoice,
42
42
  ]);
43
- // BGMを停止
43
+ const bgmFadeIntervalRef = useRef(null);
44
+ // BGMをフェードアウトして停止
44
45
  const stopBGM = useCallback(() => {
45
- if (bgmRef.current) {
46
- bgmRef.current.audio.pause();
47
- bgmRef.current.audio.currentTime = 0;
48
- bgmRef.current = null;
46
+ if (!bgmRef.current)
47
+ return;
48
+ // 既にフェード中なら前回のをクリア
49
+ if (bgmFadeIntervalRef.current) {
50
+ clearInterval(bgmFadeIntervalRef.current);
49
51
  }
52
+ const audio = bgmRef.current.audio;
53
+ const fadeDuration = 1000; // 1秒フェードアウト
54
+ const fadeSteps = 20;
55
+ const stepTime = fadeDuration / fadeSteps;
56
+ const volumeStep = audio.volume / fadeSteps;
57
+ let stepsRemaining = fadeSteps;
58
+ bgmFadeIntervalRef.current = setInterval(() => {
59
+ stepsRemaining--;
60
+ if (stepsRemaining <= 0) {
61
+ if (bgmFadeIntervalRef.current) {
62
+ clearInterval(bgmFadeIntervalRef.current);
63
+ bgmFadeIntervalRef.current = null;
64
+ }
65
+ audio.pause();
66
+ audio.currentTime = 0;
67
+ bgmRef.current = null;
68
+ }
69
+ else {
70
+ audio.volume = Math.max(0, volumeStep * stepsRemaining);
71
+ }
72
+ }, stepTime);
50
73
  }, []);
51
74
  // 特定のSEを停止
52
75
  const stopSE = useCallback((soundId) => {
@@ -185,6 +208,11 @@ export const useSoundPlayer = ({ soundBlocks, isFirstRenderComplete, muteAudio =
185
208
  // クリーンアップ(直接refにアクセスして依存配列の問題を回避)
186
209
  useEffect(() => {
187
210
  return () => {
211
+ // フェードintervalをクリア
212
+ if (bgmFadeIntervalRef.current) {
213
+ clearInterval(bgmFadeIntervalRef.current);
214
+ bgmFadeIntervalRef.current = null;
215
+ }
188
216
  // BGMを停止
189
217
  if (bgmRef.current) {
190
218
  bgmRef.current.audio.pause();
@@ -870,6 +870,9 @@ export class PluginManager {
870
870
  console.warn("updateEmotionEffect callback is not registered. Make sure to call setEmotionEffectUpdaterCallback.");
871
871
  }
872
872
  },
873
+ cancelScenario: () => {
874
+ throw new Error("DataAPI.cancelScenario() can only be called from within DataProvider context");
875
+ },
873
876
  },
874
877
  };
875
878
  }
package/dist/types.d.ts CHANGED
@@ -101,6 +101,9 @@ export interface ScenarioBlockCharacter {
101
101
  positionY?: number | null;
102
102
  zIndex?: number | null;
103
103
  scale?: number | null;
104
+ cropLeft?: number | null;
105
+ cropRight?: number | null;
106
+ cropFade?: number | null;
104
107
  object: {
105
108
  id: string;
106
109
  name: string;
@@ -114,6 +117,9 @@ export interface ScenarioBlockCharacter {
114
117
  scale?: number;
115
118
  translateY?: number;
116
119
  translateX?: number;
120
+ cropLeft?: number | null;
121
+ cropRight?: number | null;
122
+ cropFade?: number | null;
117
123
  isDefault?: boolean;
118
124
  layers?: EntityStateLayer[];
119
125
  };
@@ -201,6 +207,9 @@ export interface DisplayedCharacter {
201
207
  positionY?: number | null;
202
208
  zIndex?: number | null;
203
209
  scale?: number | null;
210
+ cropLeft?: number | null;
211
+ cropRight?: number | null;
212
+ cropFade?: number | null;
204
213
  object: {
205
214
  id: string;
206
215
  name: string;
@@ -214,6 +223,9 @@ export interface DisplayedCharacter {
214
223
  scale?: number;
215
224
  translateY?: number;
216
225
  translateX?: number;
226
+ cropLeft?: number | null;
227
+ cropRight?: number | null;
228
+ cropFade?: number | null;
217
229
  isDefault?: boolean;
218
230
  layers?: EntityStateLayer[];
219
231
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luna-editor/engine",
3
- "version": "0.4.5",
3
+ "version": "0.5.1",
4
4
  "description": "Luna Editor scenario playback engine",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1,5 +0,0 @@
1
- import type { DisplayedCharacter } from "../types";
2
- export declare const useImagePreloader: (
3
- displayedCharacters: DisplayedCharacter[],
4
- singleImageUrl?: string
5
- ) => boolean;
@@ -1,53 +0,0 @@
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
- };
@@ -1,41 +0,0 @@
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;
@@ -1,99 +0,0 @@
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
- };