danoniplus 49.1.0 → 49.2.0

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/js/danoni_main.js CHANGED
@@ -4,12 +4,12 @@
4
4
  *
5
5
  * Source by tickle
6
6
  * Created : 2018/10/08
7
- * Revised : 2026/07/02
7
+ * Revised : 2026/07/07
8
8
  *
9
9
  * https://github.com/cwtickle/danoniplus
10
10
  */
11
- const g_version = `Ver 49.1.0`;
12
- const g_revisedDate = `2026/07/02`;
11
+ const g_version = `Ver 49.2.0`;
12
+ const g_revisedDate = `2026/07/07`;
13
13
 
14
14
  // カスタム用バージョン (danoni_custom.js 等で指定可)
15
15
  let g_localVersion = ``;
@@ -177,6 +177,10 @@ let g_maxScore = 1000000;
177
177
  let g_gameOverFlg = false;
178
178
  let g_finishFlg = true;
179
179
 
180
+ // 音源のAudioContext管理、リトライ中フラグ
181
+ let g_sharedAudioContext = null;
182
+ let g_retryInProgress = false;
183
+
180
184
  /** 共通オブジェクト */
181
185
  const g_loadObj = {};
182
186
  const g_rootObj = {};
@@ -2060,10 +2064,9 @@ const makeBgCanvas = (_ctx, { w = g_sWidth, h = g_sHeight } = {}) => {
2060
2064
  * - divオブジェクト(ボタンなど)はdivRoot配下で管理しているため、子要素のみを全削除している。
2061
2065
  * - dicRoot自体を削除しないよう注意すること。
2062
2066
  * - 再描画時に共通で表示する箇所はここで指定している。
2063
- * @param {boolean} [_redrawFlg=false] 画面横幅を再定義し、Canvas背景を再描画するかどうか
2064
2067
  * @param {string} [_customDisplayName=''] 画面名(メイン画面: 'Main', それ以外: 空)
2065
2068
  */
2066
- const clearWindow = (_redrawFlg = false, _customDisplayName = ``) => {
2069
+ const clearWindow = (_customDisplayName = ``) => {
2067
2070
  closeDisplayPreview();
2068
2071
  resetKeyControl();
2069
2072
  resetTransform();
@@ -2110,37 +2113,33 @@ const clearWindow = (_redrawFlg = false, _customDisplayName = ``) => {
2110
2113
  getLayerWithClear(`layer2`);
2111
2114
  }
2112
2115
 
2113
- if (_redrawFlg) {
2114
- // 画面背景を指定 (background-color)
2115
- $id(`canvas-frame`).width = wUnit(g_sWidth + diffX);
2116
- layer0.width = g_sWidth + diffX;
2117
- if (!g_headerObj[`customBack${_customDisplayName}Use`]) {
2118
- makeBgCanvas(l0ctx, { w: g_sWidth + diffX });
2119
- }
2116
+ // 画面背景を指定 (background-color)
2117
+ $id(`canvas-frame`).width = wUnit(g_sWidth + diffX);
2118
+ layer0.width = g_sWidth + diffX;
2119
+ if (!g_headerObj[`customBack${_customDisplayName}Use`]) {
2120
+ makeBgCanvas(l0ctx, { w: g_sWidth + diffX });
2120
2121
  }
2121
2122
  }
2122
2123
 
2123
2124
  // 背景を再描画
2124
- if (_redrawFlg) {
2125
- g_btnAddFunc = {};
2126
- g_btnDeleteFlg = {};
2127
- g_cxtAddFunc = {};
2128
- g_cxtDeleteFlg = {};
2125
+ g_btnAddFunc = {};
2126
+ g_btnDeleteFlg = {};
2127
+ g_cxtAddFunc = {};
2128
+ g_cxtDeleteFlg = {};
2129
2129
 
2130
- if (document.getElementById(`layer0`) === null ||
2131
- (!g_headerObj[`customBack${_customDisplayName}Use`] && !g_headerObj.defaultSkinFlg)) {
2130
+ if (document.getElementById(`layer0`) === null ||
2131
+ (!g_headerObj[`customBack${_customDisplayName}Use`] && !g_headerObj.defaultSkinFlg)) {
2132
2132
 
2133
- $id(`canvas-frame`).width = wUnit(g_sWidth + diffX);
2134
- createEmptySprite(divRoot, `divBack`, { w: g_sWidth + diffX });
2135
- }
2133
+ $id(`canvas-frame`).width = wUnit(g_sWidth + diffX);
2134
+ createEmptySprite(divRoot, `divBack`, { w: g_sWidth + diffX });
2135
+ }
2136
2136
 
2137
- // CSSスタイルの初期化
2138
- Object.keys(g_cssBkProperties).forEach(prop =>
2139
- document.documentElement.style.setProperty(prop, g_cssBkProperties[prop]));
2137
+ // CSSスタイルの初期化
2138
+ Object.keys(g_cssBkProperties).forEach(prop =>
2139
+ document.documentElement.style.setProperty(prop, g_cssBkProperties[prop]));
2140
2140
 
2141
- Object.keys(g_headerObj).filter(val => val.startsWith(`--`) && hasVal(g_headerObj[val])).forEach(prop =>
2142
- document.documentElement.style.setProperty(prop, getCssCustomProperty(prop, g_headerObj[prop])));
2143
- }
2141
+ Object.keys(g_headerObj).filter(val => val.startsWith(`--`) && hasVal(g_headerObj[val])).forEach(prop =>
2142
+ document.documentElement.style.setProperty(prop, getCssCustomProperty(prop, g_headerObj[prop])));
2144
2143
  };
2145
2144
 
2146
2145
  /**
@@ -2653,7 +2652,6 @@ class AudioPlayer {
2653
2652
  }
2654
2653
 
2655
2654
  // グローバルで1つだけ保持(遅延生成)
2656
- let g_sharedAudioContext = null;
2657
2655
  const getSharedAudioContext = () => {
2658
2656
  if (!g_sharedAudioContext) {
2659
2657
  g_sharedAudioContext = new AudioContext();
@@ -3254,7 +3252,6 @@ const loadChartFile = async (_scoreId = g_stateObj.scoreId) => {
3254
3252
 
3255
3253
  await loadScript2(`${filename}?${Date.now()}`, false, charset);
3256
3254
  if (typeof externalDosInit === C_TYP_FUNCTION) {
3257
- deleteDiv(divRoot, `lblLoading`);
3258
3255
 
3259
3256
  // 外部データを読込(ファイルが見つからなかった場合は譜面追記をスキップ)
3260
3257
  externalDosInit();
@@ -5660,7 +5657,7 @@ const setKeyDfVal = _ptnName => {
5660
5657
  */
5661
5658
  const titleInit = (_initFlg = false) => {
5662
5659
 
5663
- clearWindow(true);
5660
+ clearWindow();
5664
5661
  g_currentPage = `title`;
5665
5662
  g_stateObj.settingSummaryVisible = false;
5666
5663
 
@@ -6391,9 +6388,10 @@ const playBGM = async (_num, _currentLoopNum = g_settings.musicLoopNum) => {
6391
6388
  if (encodeFlg) {
6392
6389
  try {
6393
6390
  closeExistingAudio();
6394
- if (g_audioBufferCache.has(url)) {
6391
+ const cachedBuffer = getAudioBufferFromCache(url);
6392
+ if (cachedBuffer !== undefined) {
6395
6393
  const tmpAudio = new AudioPlayer();
6396
- tmpAudio.setBuffer(g_audioBufferCache.get(url)); // 新設メソッド、decode不要
6394
+ tmpAudio.setBuffer(cachedBuffer);
6397
6395
  g_audioForMS = tmpAudio;
6398
6396
  } else {
6399
6397
  await loadScript2(url);
@@ -6668,7 +6666,7 @@ const setWindowStyle = (_text, _bkColor, _textColor, _align = C_ALIGN_LEFT, { _x
6668
6666
  /*-----------------------------------------------------------*/
6669
6667
 
6670
6668
  const dataMgtInit = () => {
6671
- clearWindow(true);
6669
+ clearWindow();
6672
6670
  pauseBGM();
6673
6671
  const prevPage = g_currentPage;
6674
6672
  g_currentPage = `dataMgt`;
@@ -6906,7 +6904,7 @@ const dataMgtInit = () => {
6906
6904
  /*-----------------------------------------------------------*/
6907
6905
 
6908
6906
  const preconditionInit = () => {
6909
- clearWindow(true);
6907
+ clearWindow();
6910
6908
  pauseBGM();
6911
6909
  const prevPage = g_currentPage;
6912
6910
  g_currentPage = `precondition`;
@@ -7134,7 +7132,7 @@ const makePlayButton = _func => createCss2Button(`btnPlay`, g_lblNameObj.b_play,
7134
7132
  */
7135
7133
  const optionInit = () => {
7136
7134
 
7137
- clearWindow(true);
7135
+ clearWindow();
7138
7136
  pauseBGM();
7139
7137
  const divRoot = document.getElementById(`divRoot`);
7140
7138
  g_currentPage = `option`;
@@ -9212,7 +9210,7 @@ const resetGroupList = (_type, _keyCtrlPtn) => {
9212
9210
 
9213
9211
  const settingsDisplayInit = () => {
9214
9212
 
9215
- clearWindow(true);
9213
+ clearWindow();
9216
9214
  const divRoot = document.getElementById(`divRoot`);
9217
9215
  g_currentPage = `settingsDisplay`;
9218
9216
 
@@ -10019,7 +10017,7 @@ const interlockingButton = (_headerObj, _name, _current, _next, _buttonFlg = fal
10019
10017
  /*-----------------------------------------------------------*/
10020
10018
 
10021
10019
  const exSettingInit = () => {
10022
- clearWindow(true);
10020
+ clearWindow();
10023
10021
  g_currentPage = `exSetting`;
10024
10022
 
10025
10023
  multiAppend(divRoot,
@@ -10180,7 +10178,7 @@ const createGeneralSettingEx = (_spriteList, _name, { defaultList = [C_FLG_OFF],
10180
10178
  */
10181
10179
  const keyConfigInit = (_kcType = g_kcType, _initFlg = false) => {
10182
10180
 
10183
- clearWindow(true);
10181
+ clearWindow();
10184
10182
  const divRoot = document.getElementById(`divRoot`);
10185
10183
  g_kcType = _kcType;
10186
10184
  g_currentPage = `keyConfig`;
@@ -11956,9 +11954,9 @@ const changeShuffleConfigColor = (_keyCtrlPtn, _vals, _j = -1) => {
11956
11954
  /* Scene : LOADING [strawberry] */
11957
11955
  /*-----------------------------------------------------------*/
11958
11956
 
11959
- const loadMusic = () => {
11957
+ const loadMusic = async () => {
11960
11958
 
11961
- clearWindow(true);
11959
+ clearWindow();
11962
11960
  pauseBGM();
11963
11961
  g_currentPage = `loading`;
11964
11962
 
@@ -11971,127 +11969,228 @@ const loadMusic = () => {
11971
11969
  const lblLoading = getLoadingLabel();
11972
11970
  divRoot.appendChild(lblLoading);
11973
11971
 
11974
- // ローカル動作時
11975
- if (g_isFile) {
11976
- setAudio(url);
11977
- return;
11972
+ // 音源準備Promise(ローカル/オンライン双方を吸収)
11973
+ const audioReadyPromise = loadAndSetupAudio(url, lblLoading);
11974
+
11975
+ // 譜面データ読込・変換処理Promise(g_audioに依存しない部分)
11976
+ const chartReadyPromise = loadChartFile().then(() => prepareScoreData());
11977
+
11978
+ // 両方の完了を待つ
11979
+ let loadSucceeded = true;
11980
+ try {
11981
+ await Promise.all([audioReadyPromise, chartReadyPromise]);
11982
+ } catch (e) {
11983
+ console.warn(`Loading error: ${e}`);
11984
+ loadSucceeded = false;
11985
+ } finally {
11986
+ deleteDiv(divRoot, `lblLoading`);
11987
+ }
11988
+
11989
+ if (loadSucceeded) {
11990
+ mainInit(); // 音源・譜面変換双方の完了後にまとめて呼ぶ
11978
11991
  }
11992
+ };
11993
+
11994
+ /**
11995
+ * 音源の取得とセットアップ
11996
+ * - エンコード形式(base64)か通常の音声ファイルかを判定し、それぞれの準備処理に振り分ける
11997
+ * - iOSの場合はユーザー操作(ジェスチャー)を待ってから再生準備を行う(readyToStart経由)
11998
+ * - キャッシュヒット時はダウンロード自体を行わないよう、取得処理はsetupWebAudioへ
11999
+ * コールバックとして渡し、キャッシュミス時にのみ評価されるようにしている
12000
+ * @param {string} _url 音源の取得元URL
12001
+ * @param {HTMLDivElement} _lblLoading ローディング表示用のDiv要素
12002
+ * @returns {Promise<void>} 再生準備(canplaythrough相当)が完了したら解決するPromise
12003
+ */
12004
+ const loadAndSetupAudio = async (_url, _lblLoading) => {
12005
+
12006
+ /**
12007
+ * iOSの場合はユーザー操作(ジェスチャー)を待ってから_funcを実行する
12008
+ * - AudioContextの制約上、iOSはジェスチャーを起点にしないと音声再生が許可されないため
12009
+ * @param {() => Promise<void>} _func 実行する音源準備処理
12010
+ * @returns {Promise<void>}
12011
+ */
12012
+ const readyToStart = _func => {
12013
+ if (!g_isIos) {
12014
+ return _func(); // 通常環境はそのまま実行するだけ
12015
+ }
12016
+ return new Promise((resolve, reject) => {
12017
+ g_currentPage = `loadingIos`;
12018
+ _lblLoading.textContent = `Click to Start!`;
12019
+ divRoot.appendChild(makePlayButton(evt => {
12020
+ getSharedAudioContext().resume();
12021
+ g_currentPage = `loading`;
12022
+ resetKeyControl();
12023
+ divRoot.removeChild(evt.target);
12024
+ _func().then(resolve).catch(reject);
12025
+ }));
12026
+ setShortcutEvent(g_currentPage);
12027
+ });
12028
+ };
12029
+
12030
+ // 音源準備処理そのものを、条件に応じて組み立てる
12031
+ const setupAudioFunc = (() => {
12032
+
12033
+ // エンコードなし & ローカル実行: Audio要素で直接再生
12034
+ if (!g_musicEncodedFlg && g_isFile) {
12035
+ return () => {
12036
+ g_audio = new Audio();
12037
+ g_audio.src = _url;
12038
+ return musicAfterLoaded();
12039
+ };
12040
+ }
12041
+
12042
+ // エンコードなし & オンライン: WebAudioAPI経由(URLからfetch)
12043
+ if (!g_musicEncodedFlg) {
12044
+ return () => setupWebAudio(async () => {
12045
+ const blobUrl = await fetchMusicBlobUrl(_url, _lblLoading);
12046
+ try {
12047
+ const response = await fetch(blobUrl);
12048
+ return await response.arrayBuffer();
12049
+ } finally {
12050
+ URL.revokeObjectURL(blobUrl);
12051
+ }
12052
+ }, _url);
12053
+ }
12054
+
12055
+ // エンコードあり: スクリプト読込・musicInit実行を経てWebAudioAPI準備
12056
+ return () => setupWebAudio(async () => {
12057
+ const scriptSrc = g_isFile ? _url : await fetchMusicBlobUrl(_url, _lblLoading);
12058
+ try {
12059
+ await loadScript2(scriptSrc);
12060
+ } finally {
12061
+ if (!g_isFile) {
12062
+ URL.revokeObjectURL(scriptSrc);
12063
+ }
12064
+ }
12065
+ if (typeof musicInit !== C_TYP_FUNCTION) {
12066
+ makeWarningWindow(g_msgInfoObj.E_0031, { backBtnUse: true });
12067
+ throw new Error(`musicInit is not defined`);
12068
+ }
12069
+ musicInit();
12070
+ return base64ToUint8Array(g_musicdata).buffer;
12071
+ }, _url);
12072
+ })();
12073
+
12074
+ return readyToStart(setupAudioFunc);
12075
+ };
11979
12076
 
11980
- // XHRで読み込み
12077
+ /**
12078
+ * XHRによる音源ファイルのダウンロード(Promise化)
12079
+ * - ダウンロードして Blob URL を返す
12080
+ * @param {string} _url 音源ファイルのURL
12081
+ * @param {HTMLDivElement} _lblLoading ローディング表示用のDiv要素
12082
+ * @returns {Promise<string>} Blob URL
12083
+ */
12084
+ const fetchMusicBlobUrl = (_url, _lblLoading) => new Promise((resolve, reject) => {
11981
12085
  const request = new XMLHttpRequest();
11982
- request.open(`GET`, url, true);
12086
+ request.open(`GET`, _url, true);
11983
12087
  request.responseType = `blob`;
11984
12088
 
12089
+ const STALL_TIMEOUT_MS = 30000; // 30秒間、進捗がなければ停滞とみなす
12090
+ let stallTimer = null;
12091
+
12092
+ // 停滞タイマーをリセット
12093
+ const resetStallTimer = () => {
12094
+ clearTimeout(stallTimer);
12095
+ stallTimer = setTimeout(() => {
12096
+ request.abort();
12097
+ makeWarningWindow(g_msgInfoObj.E_0033, { backBtnUse: true });
12098
+ reject(new Error(`stalled`));
12099
+ }, STALL_TIMEOUT_MS);
12100
+ };
12101
+
11985
12102
  // 読み込み完了時
11986
12103
  request.addEventListener(`load`, () => {
12104
+ clearTimeout(stallTimer);
11987
12105
  if (request.status >= 200 && request.status < 300) {
11988
12106
  const blobUrl = URL.createObjectURL(request.response);
11989
12107
  createEmptySprite(divRoot, `loader`, g_windowObj.loader);
11990
- lblLoading.textContent = g_lblNameObj.pleaseWait;
11991
- setAudio(blobUrl, url);
12108
+ _lblLoading.textContent = g_lblNameObj.pleaseWait;
12109
+ resolve(blobUrl);
11992
12110
  } else {
11993
- makeWarningWindow(`${g_msgInfoObj.E_0041.split('{0}').join(getFullPath(url))}<br>(${request.status} ${request.statusText})`, { backBtnUse: true });
12111
+ makeWarningWindow(`${g_msgInfoObj.E_0041.split('{0}').join(getFullPath(_url))}<br>(${request.status} ${request.statusText})`, { backBtnUse: true });
12112
+ reject(new Error(`HTTP ${request.status}`));
11994
12113
  }
11995
12114
  });
11996
12115
 
11997
12116
  // 進捗時
11998
12117
  request.addEventListener(`progress`, _event => {
11999
- const lblLoading = document.getElementById(`lblLoading`);
12118
+ resetStallTimer(); // 進捗があるたびにタイマーをリセット
12119
+ const lblLoadingElem = document.getElementById(`lblLoading`);
12120
+ if (lblLoadingElem === null) return; // 並列処理で先に削除されている場合の防御
12000
12121
 
12001
12122
  if (_event.lengthComputable) {
12002
12123
  const rate = _event.loaded / _event.total;
12003
12124
  createEmptySprite(divRoot, `loader`, { y: g_sHeight - 10, h: 10, w: g_sWidth * rate, backgroundColor: `#eeeeee` });
12004
- lblLoading.textContent = `${g_lblNameObj.nowLoading} ${Math.floor(rate * 100)}%`;
12125
+ lblLoadingElem.textContent = `${g_lblNameObj.nowLoading} ${Math.floor(rate * 100)}%`;
12005
12126
  } else {
12006
- lblLoading.textContent = `${g_lblNameObj.nowLoading} ${_event.loaded}Bytes`;
12127
+ lblLoadingElem.textContent = `${g_lblNameObj.nowLoading} ${_event.loaded}Bytes`;
12007
12128
  }
12008
12129
  // ユーザカスタムイベント
12009
12130
  safeExecuteCustomHooks(`g_customJsObj.progress`, g_customJsObj.progress, _event);
12010
12131
  });
12011
12132
 
12012
- // エラー処理
12013
- request.addEventListener(`timeout`, () => makeWarningWindow(g_msgInfoObj.E_0033, { backBtnUse: true }));
12014
- request.addEventListener(`error`, () => makeWarningWindow(g_msgInfoObj.E_0034, { backBtnUse: true }));
12133
+ request.addEventListener(`error`, () => {
12134
+ clearTimeout(stallTimer);
12135
+ makeWarningWindow(g_msgInfoObj.E_0034, { backBtnUse: true });
12136
+ reject(new Error(`network error`));
12137
+ });
12015
12138
 
12139
+ resetStallTimer(); // 初回(最初のprogressが来るまで)のタイマーも開始
12016
12140
  request.send();
12017
- };
12141
+ });
12018
12142
 
12019
12143
  /**
12020
- * 音楽データの設定
12021
- * iOSの場合はAudioタグによる再生
12022
- * @param {string} _url
12023
- * @param {string} _cacheKey
12144
+ * WebAudioAPIによる音源再生の準備(共通処理)
12145
+ * - AudioPlayerを生成し、canplaythrough/errorの発火待ちを開始した上で、
12146
+ * ArrayBufferを取得してデコードする(キャッシュヒット時はデコードをスキップ)
12147
+ * @param {() => Promise<ArrayBuffer>} _fetchArrayBuffer 未キャッシュの場合にArrayBufferを取得する関数
12148
+ * @param {string} [_cacheKey] AudioBufferキャッシュ照合用のキー(省略時はキャッシュを使わない)
12149
+ * @returns {Promise<void>} 再生準備(canplaythrough相当)が完了したら解決するPromise
12024
12150
  */
12025
- const setAudio = async (_url, _cacheKey = _url) => {
12026
-
12027
- const loadMp3 = () => {
12028
- if (g_isFile) {
12029
- g_audio = new Audio();
12030
- g_audio.src = _url;
12031
- musicAfterLoaded();
12032
- } else {
12033
- initWebAudioAPIfromURL(_url);
12034
- }
12035
- };
12036
-
12037
- const readyToStart = _func => {
12038
- if (g_isIos) {
12039
- g_currentPage = `loadingIos`;
12040
- lblLoading.textContent = `Click to Start!`;
12041
- divRoot.appendChild(makePlayButton(evt => {
12042
- g_currentPage = `loading`;
12043
- resetKeyControl();
12044
- divRoot.removeChild(evt.target);
12045
- _func();
12046
- }));
12047
- setShortcutEvent(g_currentPage);
12048
- } else {
12049
- _func();
12050
- }
12051
- };
12151
+ const setupWebAudio = async (_fetchArrayBuffer, _cacheKey) => {
12152
+ g_audio = new AudioPlayer();
12153
+ const loadedPromise = musicAfterLoaded(); // canplaythrough/errorの発火をここで待つ
12052
12154
 
12053
- if (g_musicEncodedFlg) {
12054
- await loadScript2(_url);
12055
- if (typeof musicInit === C_TYP_FUNCTION) {
12056
- musicInit();
12057
- readyToStart(() => initWebAudioAPIfromBase64(g_musicdata, _cacheKey));
12058
- } else {
12059
- makeWarningWindow(g_msgInfoObj.E_0031);
12060
- musicAfterLoaded();
12061
- }
12155
+ const cachedBuffer = _cacheKey ? getAudioBufferFromCache(_cacheKey) : undefined;
12156
+ if (cachedBuffer !== undefined) {
12157
+ g_audio.setBuffer(cachedBuffer);
12062
12158
  } else {
12063
- readyToStart(() => loadMp3());
12159
+ const arrayBuffer = await _fetchArrayBuffer();
12160
+ await g_audio.init(arrayBuffer);
12161
+ if (_cacheKey) {
12162
+ cacheAudioBuffer(_cacheKey, g_audio.getBuffer());
12163
+ }
12064
12164
  }
12165
+ return loadedPromise;
12065
12166
  };
12066
12167
 
12067
- // Base64から音声データに変換してWebAudioAPIで再生する準備
12068
- const initWebAudioAPIfromBase64 = async (_base64, _cacheKey) => {
12069
- g_audio = new AudioPlayer();
12070
- musicAfterLoaded();
12168
+ const g_audioBufferCache = new Map();
12169
+ const AUDIO_CACHE_MAX = 5;
12071
12170
 
12072
- if (_cacheKey && g_audioBufferCache.has(_cacheKey)) {
12073
- g_audio.setBuffer(g_audioBufferCache.get(_cacheKey)); // デコード完全スキップ
12074
- return;
12075
- }
12076
- const array = base64ToUint8Array(_base64);
12077
- await g_audio.init(array.buffer);
12078
- if (_cacheKey) {
12079
- cacheAudioBuffer(_cacheKey, g_audio.getBuffer());
12171
+ /**
12172
+ * デコード済みAudioBufferのキャッシュからの取得
12173
+ * - Mapは挿入順を保持するのみでアクセス順を保持しないため、
12174
+ * ヒット時にエントリを一度削除して再挿入し、最新として扱う(LRU方式)
12175
+ * @param {string} _key キャッシュキー(楽曲の実URL)
12176
+ * @returns {AudioBuffer|undefined} キャッシュされたAudioBuffer。存在しない場合はundefined
12177
+ */
12178
+ const getAudioBufferFromCache = (_key) => {
12179
+ if (!g_audioBufferCache.has(_key)) {
12180
+ return undefined;
12080
12181
  }
12182
+ const buffer = g_audioBufferCache.get(_key);
12183
+ g_audioBufferCache.delete(_key);
12184
+ g_audioBufferCache.set(_key, buffer); // 末尾(最新)に再挿入
12185
+ return buffer;
12081
12186
  };
12082
12187
 
12083
- // 音声ファイルを読み込んでWebAudioAPIで再生する準備
12084
- const initWebAudioAPIfromURL = async (_url) => {
12085
- g_audio = new AudioPlayer();
12086
- musicAfterLoaded();
12087
- const promise = await fetch(_url);
12088
- const arrayBuffer = await promise.arrayBuffer();
12089
- await g_audio.init(arrayBuffer);
12090
- };
12091
-
12092
- const g_audioBufferCache = new Map();
12093
- const AUDIO_CACHE_MAX = 5;
12094
-
12188
+ /**
12189
+ * デコード済みAudioBufferのキャッシュへの登録
12190
+ * - 直近 AUDIO_CACHE_MAX 件を保持するLRU方式。上限を超えた場合は最も古いエントリから破棄する
12191
+ * @param {string} _key キャッシュキー(楽曲の実URL)
12192
+ * @param {AudioBuffer} _buffer デコード済みのAudioBuffer
12193
+ */
12095
12194
  const cacheAudioBuffer = (_key, _buffer) => {
12096
12195
  g_audioBufferCache.set(_key, _buffer);
12097
12196
  if (g_audioBufferCache.size > AUDIO_CACHE_MAX) {
@@ -12099,6 +12198,13 @@ const cacheAudioBuffer = (_key, _buffer) => {
12099
12198
  }
12100
12199
  };
12101
12200
 
12201
+ /**
12202
+ * base64文字列をUint8Arrayに変換
12203
+ * - Uint8Array.from(atob(str), callback)によるコールバック呼び出し形式は
12204
+ * 大容量データで変換オーバーヘッドが大きいため、forループによる直接代入で高速化している
12205
+ * @param {string} _base64Str base64エンコードされた文字列
12206
+ * @returns {Uint8Array} 変換後のバイト配列
12207
+ */
12102
12208
  const base64ToUint8Array = (_base64Str) => {
12103
12209
  const binaryStr = atob(_base64Str);
12104
12210
  const len = binaryStr.length;
@@ -12109,34 +12215,41 @@ const base64ToUint8Array = (_base64Str) => {
12109
12215
  return array;
12110
12216
  };
12111
12217
 
12112
- const musicAfterLoaded = () => {
12218
+ /**
12219
+ * 音源の再生準備完了を待つ
12220
+ * - g_audio が Audio要素の場合は canplaythrough/error イベントの発火を待つ
12221
+ * - g_audio が AudioPlayer の場合、readyState が既に4(デコード済み、キャッシュヒット時など)であれば即時解決する
12222
+ * - canplaythrough/error のどちらが発火しても、もう一方のリスナーも確実に削除する(cleanup)
12223
+ * @returns {Promise<void>} 再生準備が完了したら解決し、読込エラー時は例外を投げて拒否するPromise
12224
+ */
12225
+ const musicAfterLoaded = () => new Promise((resolve, reject) => {
12113
12226
  g_audio.load();
12114
12227
 
12115
12228
  if (g_audio.readyState === 4) {
12116
- // audioの読み込みが終わった後の処理
12117
- loadingScoreInit();
12229
+ resolve();
12118
12230
  } else {
12119
- // 読込中の状態
12120
- g_audio.addEventListener(`canplaythrough`, (() => function f() {
12121
- g_audio.removeEventListener(`canplaythrough`, f, false);
12122
- loadingScoreInit();
12123
- })(), false);
12124
-
12125
- // エラー時
12126
- g_audio.addEventListener(`error`, (() => function f() {
12127
- g_audio.removeEventListener(`error`, f, false);
12231
+ const onCanPlay = () => { cleanup(); resolve(); };
12232
+ const onError = () => {
12233
+ cleanup();
12128
12234
  makeWarningWindow(g_msgInfoObj.E_0041.split(`{0}`).join(g_audio.src), { backBtnUse: true });
12129
- })(), false);
12235
+ reject(new Error(`audio load error`));
12236
+ };
12237
+ const cleanup = () => {
12238
+ g_audio.removeEventListener(`canplaythrough`, onCanPlay, false);
12239
+ g_audio.removeEventListener(`error`, onError, false);
12240
+ };
12241
+ g_audio.addEventListener(`canplaythrough`, onCanPlay, false);
12242
+ g_audio.addEventListener(`error`, onError, false);
12130
12243
  }
12131
- };
12244
+ });
12132
12245
 
12133
12246
  /**
12134
- * 読込画面初期化
12247
+ * 譜面データの変換処理
12248
+ * - 音源データの状態に依存しない部分のみを担う(g_audio非参照)
12249
+ * - loadMusic経由(並行フロー)、executeRetry経由(曲中リトライ)の両方から呼ばれる
12135
12250
  */
12136
- const loadingScoreInit = async () => {
12251
+ const prepareScoreData = () => {
12137
12252
 
12138
- // 譜面データの読み込み
12139
- await loadChartFile();
12140
12253
  const tkObj = getKeyInfo();
12141
12254
  const [keyCtrlPtn, keyNum] = [tkObj.keyCtrlPtn, tkObj.keyNum];
12142
12255
  g_headerObj.blankFrameDef = setVal(g_headerObj.blankFrameDefs[g_stateObj.scoreId], g_headerObj.blankFrameDefs[0]);
@@ -12284,8 +12397,6 @@ const loadingScoreInit = async () => {
12284
12397
 
12285
12398
  // ユーザカスタムイベント
12286
12399
  safeExecuteCustomHooks(`g_customJsObj.loading`, g_customJsObj.loading);
12287
-
12288
- mainInit();
12289
12400
  };
12290
12401
 
12291
12402
  /**
@@ -14243,9 +14354,6 @@ const getArrowSettings = () => {
14243
14354
  g_workObj.frzReturnTimerId = null;
14244
14355
  }
14245
14356
 
14246
- // AutoRetryの初期化
14247
- g_workObj.autoRetryFlg = false;
14248
-
14249
14357
  // Camoufrageの設定
14250
14358
  if (g_stateObj.camoufrage !== C_FLG_OFF) {
14251
14359
 
@@ -14422,7 +14530,7 @@ const setKeyCtrl = (_localStorage, _keyNum, _keyCtrlPtn) => {
14422
14530
  * メイン画面初期化
14423
14531
  */
14424
14532
  const mainInit = () => {
14425
- clearWindow(true, `Main`);
14533
+ clearWindow(`Main`);
14426
14534
  const divRoot = document.getElementById(`divRoot`);
14427
14535
  document.oncontextmenu = () => false;
14428
14536
  g_currentPage = `main`;
@@ -14875,7 +14983,7 @@ const mainInit = () => {
14875
14983
  };
14876
14984
 
14877
14985
  // キー操作イベント
14878
- document.onkeydown = evt => {
14986
+ document.onkeydown = async evt => {
14879
14987
  evt.preventDefault();
14880
14988
  const setCode = transCode(evt);
14881
14989
 
@@ -14887,17 +14995,16 @@ const mainInit = () => {
14887
14995
 
14888
14996
  // 曲中リトライ、タイトルバック
14889
14997
  if (setCode === g_kCdN[g_headerObj.keyRetry]) {
14890
- g_audio.pause();
14891
- clearTimeout(g_timeoutEvtId);
14892
14998
 
14893
14999
  if (g_isMac && keyIsShift()) {
14894
15000
  // Mac OS、IPad OSはDeleteキーが無いためShift+BSで代用
15001
+ g_audio.pause();
15002
+ clearTimeout(g_timeoutEvtId);
14895
15003
  titleInit();
14896
15004
 
14897
15005
  } else {
14898
15006
  // その他の環境では単にRetryに対応するキーのみで適用
14899
- clearWindow();
14900
- musicAfterLoaded();
15007
+ await executeRetry(`Retry`);
14901
15008
  }
14902
15009
 
14903
15010
  } else if (setCode === g_kCdN[g_headerObj.keyTitleBack]) {
@@ -16179,6 +16286,32 @@ const executeFrzReturn = (_seq, _idx, _axis) => {
16179
16286
  g_workObj.frzReturnTimerId = setTimeout(() => executeFrzReturn(_seq, _idx + 1, _axis), 20);
16180
16287
  };
16181
16288
 
16289
+ /**
16290
+ * 曲中リトライの共通処理
16291
+ * - 手動リトライ、AutoRetryの両方から呼ばれる
16292
+ * - g_retryInProgressによる多重実行防止、失敗時のログ出力・フラグ復帰を一括で行う
16293
+ * @param {string} [_logLabel='Retry'] エラーログに表示するラベル(手動/自動の区別用)
16294
+ */
16295
+ const executeRetry = async (_logLabel = `Retry`) => {
16296
+ if (g_retryInProgress) {
16297
+ return;
16298
+ }
16299
+ g_retryInProgress = true;
16300
+ try {
16301
+ g_audio.pause();
16302
+ clearTimeout(g_timeoutEvtId);
16303
+ clearWindow(`Main`);
16304
+ await musicAfterLoaded();
16305
+ await loadChartFile();
16306
+ prepareScoreData();
16307
+ mainInit();
16308
+ } catch (e) {
16309
+ console.warn(`${_logLabel} audio load error: ${e}`);
16310
+ } finally {
16311
+ g_retryInProgress = false;
16312
+ }
16313
+ };
16314
+
16182
16315
  /**
16183
16316
  * AutoRetryの設定
16184
16317
  * @param {string} _retryCondition リトライ基準となるAutoRetry名
@@ -16188,13 +16321,9 @@ const quickRetry = (_retryCondition) => {
16188
16321
  if (retryNum < 0) {
16189
16322
  return;
16190
16323
  }
16191
- if (g_settings.autoRetryNum >= retryNum && !g_workObj.autoRetryFlg) {
16192
- g_workObj.autoRetryFlg = true;
16193
- setTimeout(() => {
16194
- g_audio.pause();
16195
- clearTimeout(g_timeoutEvtId);
16196
- clearWindow();
16197
- musicAfterLoaded();
16324
+ if (g_settings.autoRetryNum >= retryNum && !g_retryInProgress) {
16325
+ setTimeout(async () => {
16326
+ await executeRetry(`AutoRetry`);
16198
16327
  }, 16);
16199
16328
  }
16200
16329
  };
@@ -16795,7 +16924,7 @@ const finishViewing = () => {
16795
16924
  */
16796
16925
  const resultInit = () => {
16797
16926
 
16798
- clearWindow(true);
16927
+ clearWindow();
16799
16928
  g_currentPage = `result`;
16800
16929
 
16801
16930
  // 結果画面用フレーム初期化
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Source by tickle
7
7
  * Created : 2019/11/19
8
- * Revised : 2026/06/23 (v49.0.0)
8
+ * Revised : 2026/07/04 (v49.1.1)
9
9
  *
10
10
  * https://github.com/cwtickle/danoniplus
11
11
  */
@@ -145,7 +145,8 @@ const g_localeObj = {
145
145
  };
146
146
 
147
147
  const g_userAgent = window.navigator.userAgent.toLowerCase(); // msie, edge, chrome, safari, firefox, opera
148
- const g_isIos = listMatching(g_userAgent, [`iphone`, `ipad`, `ipod`]);
148
+ const g_isIos = listMatching(g_userAgent, [`iphone`, `ipad`, `ipod`]) ||
149
+ (navigator.maxTouchPoints > 1 && /macintosh/i.test(g_userAgent));
149
150
  const g_isMac = listMatching(g_userAgent, [`iphone`, `ipad`, `ipod`, `mac os`]);
150
151
 
151
152
  // 変数型
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "danoniplus",
3
- "version": "49.1.0",
3
+ "version": "49.2.0",
4
4
  "description": "Dancing☆Onigiri (CW Edition) - Web-based Rhythm Game",
5
5
  "main": "./js/danoni_main.js",
6
6
  "jsdelivr": "./js/danoni_main.js",