@luna-editor/engine 0.5.0 → 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 +120 -25
- package/dist/components/BackgroundLayer.js +7 -5
- package/dist/components/GameScreen.d.ts +8 -2
- package/dist/components/GameScreen.js +253 -26
- package/dist/components/OverlayUI.d.ts +2 -3
- package/dist/components/OverlayUI.js +3 -14
- package/dist/components/PluginComponentProvider.js +1 -1
- package/dist/contexts/DataContext.js +25 -8
- package/dist/contexts/PlaybackTextContext.d.ts +32 -0
- package/dist/contexts/PlaybackTextContext.js +29 -0
- package/dist/data-api-types.d.ts +6 -2
- package/dist/hooks/useFontLoader.d.ts +9 -2
- package/dist/hooks/useFontLoader.js +126 -87
- package/dist/hooks/usePlayerLogic.js +3 -0
- package/dist/hooks/usePluginAPI.js +1 -1
- package/dist/hooks/useSoundPlayer.js +33 -5
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/plugin/PluginManager.d.ts +4 -1
- package/dist/plugin/PluginManager.js +51 -19
- package/dist/sdk.d.ts +1 -1
- package/dist/types.d.ts +29 -2
- package/package.json +1 -1
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
3
|
-
|
|
2
|
+
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* ゲームスクリーンコンポーネント
|
|
5
|
+
* React.memo でラップして、props が変わらない限り再レンダリングを防ぐ
|
|
6
|
+
* これにより displayText の更新による不要な再描画を防止
|
|
7
|
+
*/
|
|
8
|
+
export const GameScreen = memo(function GameScreen({ scenario, currentBlock, previousBlock, displayedCharacters, inactiveCharacterBrightness = 0.8, characterSpacing = 0.2, }) {
|
|
4
9
|
var _a;
|
|
5
10
|
// キャラクターごとのフェード状態を管理
|
|
6
11
|
const [fadeStates, setFadeStates] = useState(new Map());
|
|
@@ -8,6 +13,142 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
|
|
|
8
13
|
const fadeStartTimeRef = useRef(new Map());
|
|
9
14
|
// 完了したフェードを追跡(同じpendingFadeで再度フェードしないため)
|
|
10
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
|
|
11
152
|
// コンテナサイズを取得(ResizeObserverで確実に取得)
|
|
12
153
|
const containerRef = useRef(null);
|
|
13
154
|
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
|
@@ -54,7 +195,7 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
|
|
|
54
195
|
// character_entranceブロックのキャラクター画像(レイヤー情報含む)
|
|
55
196
|
if (block.characters) {
|
|
56
197
|
block.characters.forEach((char) => {
|
|
57
|
-
var _a, _b, _c, _d, _e, _f;
|
|
198
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
|
|
58
199
|
if (char.entityState.imageUrl || ((_a = char.baseBodyState) === null || _a === void 0 ? void 0 : _a.imageUrl)) {
|
|
59
200
|
const key = `${char.objectId}-${char.entityStateId}`;
|
|
60
201
|
images.set(key, {
|
|
@@ -70,6 +211,10 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
|
|
|
70
211
|
baseBodyTranslateX: (_e = char.baseBodyState) === null || _e === void 0 ? void 0 : _e.translateX,
|
|
71
212
|
baseBodyTranslateY: (_f = char.baseBodyState) === null || _f === void 0 ? void 0 : _f.translateY,
|
|
72
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,
|
|
73
218
|
});
|
|
74
219
|
}
|
|
75
220
|
});
|
|
@@ -222,9 +367,31 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
|
|
|
222
367
|
const currentSpeaker = (currentBlock === null || currentBlock === void 0 ? void 0 : currentBlock.speakerId)
|
|
223
368
|
? displayedCharacters.find((char) => char.objectId === currentBlock.speakerId)
|
|
224
369
|
: null;
|
|
370
|
+
// 簡易モード用の自動配置計算
|
|
371
|
+
const calculateAutoLayout = (characters, spacing) => {
|
|
372
|
+
const total = characters.length;
|
|
373
|
+
const positions = new Map();
|
|
374
|
+
if (total === 0)
|
|
375
|
+
return positions;
|
|
376
|
+
if (total === 1) {
|
|
377
|
+
// 1人の場合は中央
|
|
378
|
+
positions.set(characters[0].objectId, { x: 0, y: 0 });
|
|
379
|
+
return positions;
|
|
380
|
+
}
|
|
381
|
+
// 複数の場合は均等配置
|
|
382
|
+
// spacing: 画面幅に対する割合(例: 0.1 = 10%)
|
|
383
|
+
// 座標系: -1(左端)〜 1(右端)
|
|
384
|
+
const totalWidth = (total - 1) * spacing * 2;
|
|
385
|
+
const startX = -totalWidth / 2;
|
|
386
|
+
characters.forEach((char, index) => {
|
|
387
|
+
const x = startX + index * spacing * 2;
|
|
388
|
+
positions.set(char.objectId, { x, y: 0 });
|
|
389
|
+
});
|
|
390
|
+
return positions;
|
|
391
|
+
};
|
|
225
392
|
// キャラクター描画用のヘルパー関数
|
|
226
393
|
const renderCharacter = (image, displayedChar, isCurrentSpeaker, keyPrefix) => {
|
|
227
|
-
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;
|
|
228
395
|
// フェード状態を確認
|
|
229
396
|
const fadeState = fadeStates.get(image.objectId);
|
|
230
397
|
const isFadingIn = fadeState && fadeState.previousEntityStateId !== image.entityStateId;
|
|
@@ -239,8 +406,14 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
|
|
|
239
406
|
(pendingFade === null || pendingFade === void 0 ? void 0 : pendingFade.objectId) === image.objectId &&
|
|
240
407
|
pendingFade.previousEntityStateId === image.entityStateId &&
|
|
241
408
|
!completedFadesRef.current.has(pendingFade.fadeKey);
|
|
409
|
+
// 退場中のキャラクターか判定
|
|
410
|
+
const isExitingChar = exitingCharacters.some((c) => c.objectId === image.objectId);
|
|
242
411
|
// 表示すべきかどうか
|
|
243
|
-
const shouldDisplay = displayedChar ||
|
|
412
|
+
const shouldDisplay = displayedChar ||
|
|
413
|
+
isCurrentSpeaker ||
|
|
414
|
+
isFadingOut ||
|
|
415
|
+
isPendingFadeOut ||
|
|
416
|
+
isExitingChar;
|
|
244
417
|
// opacityを決定
|
|
245
418
|
let opacity = 1;
|
|
246
419
|
if (isFadingIn || isPendingFadeIn) {
|
|
@@ -251,13 +424,22 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
|
|
|
251
424
|
// フェードアウト中
|
|
252
425
|
opacity = fadeState ? 1 - fadeState.fadeProgress : 1;
|
|
253
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
|
+
}
|
|
254
436
|
// 明るさを決定
|
|
255
437
|
let brightness = 1;
|
|
256
438
|
if (displayedCharacters.length > 0 && displayedChar) {
|
|
257
439
|
// 複数キャラクター表示で、現在の話者でない場合
|
|
258
440
|
if (currentSpeaker &&
|
|
259
441
|
currentSpeaker.objectId !== displayedChar.objectId) {
|
|
260
|
-
brightness =
|
|
442
|
+
brightness = inactiveCharacterBrightness;
|
|
261
443
|
}
|
|
262
444
|
}
|
|
263
445
|
// z-indexを決定
|
|
@@ -270,17 +452,36 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
|
|
|
270
452
|
: ((_d = image.scale) !== null && _d !== void 0 ? _d : 1);
|
|
271
453
|
// 位置を決定(カスタム位置またはデフォルト位置)
|
|
272
454
|
// 新座標系: x: -1=左見切れ, 0=中央, 1=右見切れ / y: -1=上見切れ, 0=中央, 1=下見切れ
|
|
273
|
-
let finalPosition = { x: 0, y:
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
455
|
+
let finalPosition = { x: 0, y: 0 };
|
|
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) {
|
|
469
|
+
// カスタム位置が設定されている場合(詳細モード)
|
|
470
|
+
if (positionSource.positionX !== null &&
|
|
471
|
+
positionSource.positionY !== null) {
|
|
278
472
|
finalPosition = {
|
|
279
|
-
x: (_e =
|
|
280
|
-
y: (_f =
|
|
473
|
+
x: (_e = positionSource.positionX) !== null && _e !== void 0 ? _e : 0,
|
|
474
|
+
y: (_f = positionSource.positionY) !== null && _f !== void 0 ? _f : 0,
|
|
281
475
|
};
|
|
282
476
|
}
|
|
283
|
-
|
|
477
|
+
else {
|
|
478
|
+
// 簡易モード: 自動配置
|
|
479
|
+
const autoPositions = calculateAutoLayout(displayedCharacters, characterSpacing !== null && characterSpacing !== void 0 ? characterSpacing : 0.2);
|
|
480
|
+
const autoPos = autoPositions.get(positionSource.objectId);
|
|
481
|
+
if (autoPos) {
|
|
482
|
+
finalPosition = autoPos;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
284
485
|
}
|
|
285
486
|
// コンテナ相対の位置計算(パーセンテージ使用)
|
|
286
487
|
// positionX: -1.0 = 完全に左に見切れ, 0.0 = 中央, 1.0 = 完全に右に見切れ
|
|
@@ -312,19 +513,24 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
|
|
|
312
513
|
// 位置をピクセルで計算(CharacterEditDialogと同じ)
|
|
313
514
|
const leftPx = (leftPercent / 100) * containerSize.width;
|
|
314
515
|
const topPx = (topPercent / 100) * containerSize.height;
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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,
|
|
321
531
|
// CharacterEditDialogと完全に同じ: left/topを0にしてtransformで位置制御
|
|
322
532
|
// translate(%)は画像サイズに対する割合
|
|
323
|
-
left: 0,
|
|
324
|
-
top: 0,
|
|
325
|
-
transform: `translate(${leftPx}px, ${topPx}px) translate(${translateXPercent}%, ${translateYPercent}%)`,
|
|
326
|
-
transition: fadeState ? "none" : undefined,
|
|
327
|
-
}, "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: {
|
|
328
534
|
height: `${baseHeight}px`,
|
|
329
535
|
width: "auto",
|
|
330
536
|
objectFit: "contain",
|
|
@@ -373,6 +579,27 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
|
|
|
373
579
|
return renderCharacter(fallbackImage, char, false, "char");
|
|
374
580
|
}
|
|
375
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");
|
|
376
603
|
}), displayedCharacters.length === 0 &&
|
|
377
604
|
currentBlock.speakerId &&
|
|
378
605
|
currentBlock.speakerStateId && (_jsxs(_Fragment, { children: [(() => {
|
|
@@ -390,4 +617,4 @@ export const GameScreen = ({ scenario, currentBlock, previousBlock, displayedCha
|
|
|
390
617
|
return null;
|
|
391
618
|
return renderCharacter(image, null, false, "fadeout");
|
|
392
619
|
})()] }))] }) }));
|
|
393
|
-
};
|
|
620
|
+
});
|
|
@@ -5,9 +5,8 @@ interface OverlayUIProps {
|
|
|
5
5
|
/**
|
|
6
6
|
* OverlayUI コンポーネント
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* - タブレット(幅 ≥ 1000px): 70%に縮小して中央配置
|
|
8
|
+
* アスペクト比コンテナ全体に配置されるUIオーバーレイ
|
|
9
|
+
* 親コンテナ(アスペクト比コンテナ)のサイズに合わせて自動調整される
|
|
11
10
|
*/
|
|
12
11
|
export declare const OverlayUI: React.FC<OverlayUIProps>;
|
|
13
12
|
export {};
|
|
@@ -1,21 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useScreenSizeAtom } from "../atoms/screen-size";
|
|
3
2
|
/**
|
|
4
3
|
* OverlayUI コンポーネント
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* - タブレット(幅 ≥ 1000px): 70%に縮小して中央配置
|
|
5
|
+
* アスペクト比コンテナ全体に配置されるUIオーバーレイ
|
|
6
|
+
* 親コンテナ(アスペクト比コンテナ)のサイズに合わせて自動調整される
|
|
9
7
|
*/
|
|
10
8
|
export const OverlayUI = ({ children }) => {
|
|
11
|
-
|
|
12
|
-
let [{ height: screenHeight }] = useScreenSizeAtom();
|
|
13
|
-
let screenWidth = (screenHeight * 16) / 9;
|
|
14
|
-
// タブレット(幅1000px以上)では70%に縮小
|
|
15
|
-
const [{ width: checkWidth }] = useScreenSizeAtom();
|
|
16
|
-
if (checkWidth >= 1000) {
|
|
17
|
-
screenHeight = (screenHeight * 7) / 10;
|
|
18
|
-
screenWidth = (screenHeight * 16) / 9;
|
|
19
|
-
}
|
|
20
|
-
return (_jsx("div", { className: "absolute inset-0 pointer-events-none z-[9999] flex justify-center items-center", children: _jsx("div", { "data-overlay-container": true, className: "relative", style: { width: screenWidth, height: screenHeight }, children: children }) }));
|
|
9
|
+
return (_jsx("div", { className: "absolute inset-0 pointer-events-none z-[9999]", children: _jsx("div", { className: "w-full h-full", children: children }) }));
|
|
21
10
|
};
|
|
@@ -20,7 +20,7 @@ export function PluginComponentProvider({ type, pluginManager, fallback: Fallbac
|
|
|
20
20
|
return () => {
|
|
21
21
|
unsubscribe();
|
|
22
22
|
};
|
|
23
|
-
}, [dataAPI
|
|
23
|
+
}, [dataAPI]);
|
|
24
24
|
const Component = pluginManager.getComponent(type);
|
|
25
25
|
if (Component) {
|
|
26
26
|
// Pass DataAPI as prop to avoid context issues
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, } from "react";
|
|
3
3
|
const DataContextInstance = createContext(null);
|
|
4
|
+
// データへの参照を共有するContext(Context value自体は変わらない)
|
|
5
|
+
const DataRefContext = createContext(null);
|
|
4
6
|
const SubscribersContext = createContext(null);
|
|
5
7
|
const SettingsUpdaterContext = createContext(null);
|
|
6
8
|
const EmotionEffectUpdaterContext = createContext(null);
|
|
@@ -21,6 +23,12 @@ const ScenarioCancellerContext = createContext(null);
|
|
|
21
23
|
export const DataProvider = ({ data, onSettingsUpdate, onEmotionEffectUpdate, onCancelScenario, children }) => {
|
|
22
24
|
const subscribers = useMemo(() => new Map(), []);
|
|
23
25
|
const previousDataRef = useRef(data);
|
|
26
|
+
// 安定したデータ参照ホルダー(Context valueとして提供)
|
|
27
|
+
// オブジェクト自体の参照は変わらず、currentプロパティを更新
|
|
28
|
+
// これにより、useContextを使うコンポーネントは再レンダリングされない
|
|
29
|
+
const dataRefHolder = useMemo(() => ({ current: data }), [data]);
|
|
30
|
+
// dataが変わるたびにcurrentを更新
|
|
31
|
+
dataRefHolder.current = data;
|
|
24
32
|
// データ変更時に購読者に通知
|
|
25
33
|
useEffect(() => {
|
|
26
34
|
const prevData = previousDataRef.current;
|
|
@@ -54,31 +62,39 @@ export const DataProvider = ({ data, onSettingsUpdate, onEmotionEffectUpdate, on
|
|
|
54
62
|
}
|
|
55
63
|
previousDataRef.current = data;
|
|
56
64
|
}, [data, subscribers]);
|
|
57
|
-
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(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 }) }) }) }) }) }));
|
|
58
66
|
};
|
|
59
67
|
/**
|
|
60
68
|
* DataAPI実装を提供するフック
|
|
61
69
|
* プラグインから呼び出される
|
|
62
70
|
*/
|
|
63
71
|
export function useDataAPI() {
|
|
64
|
-
|
|
72
|
+
var _a;
|
|
73
|
+
// DataRefContextから安定したデータ参照を取得
|
|
74
|
+
// これはContext valueが変わらないので、再レンダリングを引き起こさない
|
|
75
|
+
const dataRefHolder = useContext(DataRefContext);
|
|
65
76
|
const subscribers = useContext(SubscribersContext);
|
|
66
77
|
const settingsUpdater = useContext(SettingsUpdaterContext);
|
|
67
78
|
const emotionEffectUpdater = useContext(EmotionEffectUpdaterContext);
|
|
68
79
|
const scenarioCanceller = useContext(ScenarioCancellerContext);
|
|
69
|
-
|
|
80
|
+
// 注意: usePlaybackTextOptional()をここで呼ぶと、PlaybackTextContextの更新で
|
|
81
|
+
// useDataAPIを使う全コンポーネントが再レンダリングされてしまう
|
|
82
|
+
// 代わりに、displayTextが必要なコンポーネントはusePlaybackText()を直接使う
|
|
83
|
+
if (!dataRefHolder || !subscribers) {
|
|
70
84
|
throw new Error("useDataAPI must be used within DataProvider");
|
|
71
85
|
}
|
|
72
86
|
const get = useCallback((key, property) => {
|
|
87
|
+
const currentData = dataRefHolder.current;
|
|
73
88
|
if (property !== undefined) {
|
|
74
|
-
const value =
|
|
89
|
+
const value = currentData[key];
|
|
75
90
|
if (value && typeof value === "object" && property in value) {
|
|
76
91
|
return value[property];
|
|
77
92
|
}
|
|
78
93
|
return undefined;
|
|
79
94
|
}
|
|
80
|
-
return
|
|
81
|
-
}, [
|
|
95
|
+
return currentData[key];
|
|
96
|
+
}, [dataRefHolder.current] // 依存配列を空にして、get関数を安定化
|
|
97
|
+
);
|
|
82
98
|
const subscribe = useCallback((key, callback) => {
|
|
83
99
|
var _a;
|
|
84
100
|
const subscriberKey = key;
|
|
@@ -144,12 +160,13 @@ export function useDataAPI() {
|
|
|
144
160
|
}, [updateSettings]);
|
|
145
161
|
const getBlockOption = useCallback((key) => {
|
|
146
162
|
var _a;
|
|
147
|
-
const currentBlock = (_a =
|
|
163
|
+
const currentBlock = (_a = dataRefHolder.current.playback) === null || _a === void 0 ? void 0 : _a.currentBlock;
|
|
148
164
|
if (!(currentBlock === null || currentBlock === void 0 ? void 0 : currentBlock.options)) {
|
|
149
165
|
return undefined;
|
|
150
166
|
}
|
|
151
167
|
return currentBlock.options[key];
|
|
152
|
-
}, [
|
|
168
|
+
}, [(_a = dataRefHolder.current.playback) === null || _a === void 0 ? void 0 : _a.currentBlock] // 依存配列を空にして安定化
|
|
169
|
+
);
|
|
153
170
|
const updateEmotionEffect = useCallback((state) => {
|
|
154
171
|
if (emotionEffectUpdater) {
|
|
155
172
|
emotionEffectUpdater(state);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* 文字送り用の軽量コンテキスト
|
|
4
|
+
* displayText と isTyping は頻繁に更新されるため、
|
|
5
|
+
* DataContext から分離して不要な再レンダリングを防ぐ
|
|
6
|
+
*/
|
|
7
|
+
interface PlaybackTextContextValue {
|
|
8
|
+
/** 現在表示中のテキスト(文字送り中は部分テキスト) */
|
|
9
|
+
displayText: string | React.ReactNode;
|
|
10
|
+
/** 文字送り中かどうか */
|
|
11
|
+
isTyping: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* PlaybackTextProvider
|
|
15
|
+
* 文字送り状態を提供するProvider
|
|
16
|
+
* DataProviderとは別に、displayTextとisTypingのみを管理
|
|
17
|
+
*/
|
|
18
|
+
export declare const PlaybackTextProvider: React.FC<{
|
|
19
|
+
displayText: string | React.ReactNode;
|
|
20
|
+
isTyping: boolean;
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
}>;
|
|
23
|
+
/**
|
|
24
|
+
* 文字送り状態を取得するフック
|
|
25
|
+
*/
|
|
26
|
+
export declare function usePlaybackText(): PlaybackTextContextValue;
|
|
27
|
+
/**
|
|
28
|
+
* 文字送り状態を取得するフック(オプショナル版)
|
|
29
|
+
* PlaybackTextProviderの外でも使用可能(その場合はnullを返す)
|
|
30
|
+
*/
|
|
31
|
+
export declare function usePlaybackTextOptional(): PlaybackTextContextValue | null;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useMemo } from "react";
|
|
3
|
+
const PlaybackTextContextInstance = createContext(null);
|
|
4
|
+
/**
|
|
5
|
+
* PlaybackTextProvider
|
|
6
|
+
* 文字送り状態を提供するProvider
|
|
7
|
+
* DataProviderとは別に、displayTextとisTypingのみを管理
|
|
8
|
+
*/
|
|
9
|
+
export const PlaybackTextProvider = ({ displayText, isTyping, children }) => {
|
|
10
|
+
const value = useMemo(() => ({ displayText, isTyping }), [displayText, isTyping]);
|
|
11
|
+
return (_jsx(PlaybackTextContextInstance.Provider, { value: value, children: children }));
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* 文字送り状態を取得するフック
|
|
15
|
+
*/
|
|
16
|
+
export function usePlaybackText() {
|
|
17
|
+
const context = useContext(PlaybackTextContextInstance);
|
|
18
|
+
if (!context) {
|
|
19
|
+
throw new Error("usePlaybackText must be used within PlaybackTextProvider");
|
|
20
|
+
}
|
|
21
|
+
return context;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 文字送り状態を取得するフック(オプショナル版)
|
|
25
|
+
* PlaybackTextProviderの外でも使用可能(その場合はnullを返す)
|
|
26
|
+
*/
|
|
27
|
+
export function usePlaybackTextOptional() {
|
|
28
|
+
return useContext(PlaybackTextContextInstance);
|
|
29
|
+
}
|
package/dist/data-api-types.d.ts
CHANGED
|
@@ -51,8 +51,10 @@ export interface PlayerSettingsData {
|
|
|
51
51
|
voiceVolume: number;
|
|
52
52
|
/** スキップ設定 */
|
|
53
53
|
skipMode?: "all" | "unread" | "none";
|
|
54
|
-
/**
|
|
54
|
+
/** 選択されたフォントファミリー(テキスト用) */
|
|
55
55
|
selectedFontFamily?: string;
|
|
56
|
+
/** 選択されたUIフォントファミリー(メニュー・ラベル等) */
|
|
57
|
+
selectedUIFontFamily?: string;
|
|
56
58
|
}
|
|
57
59
|
/**
|
|
58
60
|
* プラグインアセットデータ
|
|
@@ -72,8 +74,10 @@ export interface PluginAssetsData {
|
|
|
72
74
|
export interface FontsData {
|
|
73
75
|
/** 利用可能なフォント一覧 */
|
|
74
76
|
fonts: WorkFont[];
|
|
75
|
-
/**
|
|
77
|
+
/** 選択されているフォントファミリー(テキスト用) */
|
|
76
78
|
selectedFontFamily: string | undefined;
|
|
79
|
+
/** 選択されているUIフォントファミリー(メニュー・ラベル等) */
|
|
80
|
+
selectedUIFontFamily: string | undefined;
|
|
77
81
|
/** フォントが読み込み完了しているかどうか */
|
|
78
82
|
isLoaded: boolean;
|
|
79
83
|
}
|
|
@@ -3,9 +3,11 @@ import type { WorkFont } from "../types";
|
|
|
3
3
|
* フォントを読み込むカスタムフック
|
|
4
4
|
* - Googleフォント: <link>タグで読み込み + document.fonts.load()でプリロード
|
|
5
5
|
* - カスタムフォント: FontFace APIで読み込み
|
|
6
|
-
* -
|
|
6
|
+
* - すべてのフォントが完全にダウンロード&キャッシュされてからisLoaded=trueになる
|
|
7
|
+
* - 再生開始前に全フォントを一括ダウンロードし、再生中のフォントスワップを防止する
|
|
8
|
+
* - preloadText: シナリオ内の全テキストを渡すと、使用される全ての文字でフォントをプリロード
|
|
7
9
|
*/
|
|
8
|
-
export declare function useFontLoader(fonts: WorkFont[] | undefined): {
|
|
10
|
+
export declare function useFontLoader(fonts: WorkFont[] | undefined, preloadText?: string): {
|
|
9
11
|
isLoaded: boolean;
|
|
10
12
|
loadedFonts: string[];
|
|
11
13
|
};
|
|
@@ -13,6 +15,11 @@ export declare function useFontLoader(fonts: WorkFont[] | undefined): {
|
|
|
13
15
|
* フォントファミリーに基づいてCSSのfont-family値を生成
|
|
14
16
|
*/
|
|
15
17
|
export declare function getFontFamilyStyle(selectedFontFamily: string | undefined, fonts: WorkFont[] | undefined): string;
|
|
18
|
+
/**
|
|
19
|
+
* UIフォントファミリーに基づいてCSSのfont-family値を生成
|
|
20
|
+
* メニュー、ボタン、ラベルなどのUI要素に使用
|
|
21
|
+
*/
|
|
22
|
+
export declare function getUIFontFamilyStyle(selectedUIFontFamily: string | undefined, fonts: WorkFont[] | undefined): string;
|
|
16
23
|
/**
|
|
17
24
|
* フォントがキャッシュに存在するかチェック
|
|
18
25
|
*/
|