danoniplus 44.5.21 → 44.5.23

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.
Files changed (2) hide show
  1. package/js/danoni_main.js +134 -14
  2. package/package.json +1 -1
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/08/01
7
+ * Revised : 2026/08/19
8
8
  *
9
9
  * https://github.com/cwtickle/danoniplus
10
10
  */
11
- const g_version = `Ver 44.5.21`;
12
- const g_revisedDate = `2026/08/01`;
11
+ const g_version = `Ver 44.5.23`;
12
+ const g_revisedDate = `2026/08/19`;
13
13
 
14
14
  // カスタム用バージョン (danoni_custom.js 等で指定可)
15
15
  let g_localVersion = ``;
@@ -134,6 +134,19 @@ let g_fps = 60;
134
134
  // プレイ画面再生時の内部スケジューリング用のマージン時間(100ms)
135
135
  let g_scheduleLead = 0.1;
136
136
 
137
+ // フレーム進行を音源クロック(AudioContext.currentTime)基準で補正するか
138
+ // - false の場合は従来通り performance.now() 基準で動作
139
+ let g_audioClockSync = true;
140
+
141
+ // 出力遅延(AudioContext.outputLatency)をタイミング補正に含めるか
142
+ // - true にすると出力デバイス(有線/Bluetooth等)によらず同じAdjustmentが使えるが、
143
+ // 既存のAdjustment設定値と互換性がなくなるため既定は false
144
+ let g_audioLatencyCompensation = false;
145
+
146
+ // 次フレームまでの待機時間の上限(ms)
147
+ // - 音源クロックが一時的に停止した際に待ち続けないようにするための保険
148
+ let g_maxFrameWait = 50;
149
+
137
150
  // 譜面データの&区切りを有効にするか
138
151
  let g_enableAmpersandSplit = true;
139
152
 
@@ -173,6 +186,9 @@ let g_maxScore = 1000000;
173
186
  let g_gameOverFlg = false;
174
187
  let g_finishFlg = true;
175
188
 
189
+ // 音源のAudioContext管理
190
+ let g_sharedAudioContext = null;
191
+
176
192
  /** 共通オブジェクト */
177
193
  const g_loadObj = {};
178
194
  const g_rootObj = {};
@@ -2382,10 +2398,11 @@ const drawTitleResultMotion = _displayName =>
2382
2398
  // WebAudioAPIでAudio要素風に再生するクラス
2383
2399
  class AudioPlayer {
2384
2400
  constructor() {
2385
- this._context = new AudioContext();
2401
+ this._context = getSharedAudioContext();
2386
2402
  this._gain = this._context.createGain();
2387
2403
  this._gain.connect(this._context.destination);
2388
2404
  this._startTime = 0;
2405
+ this._scheduledTime = 0;
2389
2406
  this._fadeinPosition = 0;
2390
2407
  this._eventListeners = {};
2391
2408
  this.playbackRate = 1;
@@ -2410,17 +2427,25 @@ class AudioPlayer {
2410
2427
  * - scheduleLead は安定した再生タイミングを確保するための内部マージン
2411
2428
  */
2412
2429
  play(_adjustmentTime = 0) {
2430
+ // AudioContextの時計は1回だけ読み、以降はその値を使い回す
2431
+ // - currentTimeはレンダークォンタム単位でしか進まないため、複数回読むと
2432
+ // 予約時刻と論理開始時刻が最大1クォンタム分ずれる
2433
+ const ctxNow = this._context.currentTime;
2434
+
2413
2435
  this._source = this._context.createBufferSource();
2414
2436
  this._source.buffer = this._buffer;
2415
2437
  this._source.playbackRate.value = this.playbackRate;
2416
2438
  this._source.connect(this._gain);
2417
2439
 
2418
2440
  // 実際の予約時刻(内部スケジューリング用のマージンを含む)
2419
- const startAt = this._context.currentTime + g_scheduleLead + _adjustmentTime;
2441
+ const startAt = ctxNow + g_scheduleLead + _adjustmentTime;
2420
2442
  this._source.start(startAt, this._fadeinPosition);
2421
2443
 
2422
2444
  // ゲーム側の論理的開始時刻(g_scheduleLead を含めない)
2423
- this._startTime = this._context.currentTime + _adjustmentTime;
2445
+ this._startTime = ctxNow + _adjustmentTime;
2446
+
2447
+ // 実際に音が鳴り始めるAudioContext上の時刻(フレーム同期の基準)
2448
+ this._scheduledTime = startAt;
2424
2449
  }
2425
2450
 
2426
2451
  pause() {
@@ -2448,6 +2473,26 @@ class AudioPlayer {
2448
2473
  return this._context.currentTime - this._startTime + this._fadeinPosition;
2449
2474
  }
2450
2475
 
2476
+ /** AudioContextの現在時刻(秒) */
2477
+ get contextTime() {
2478
+ return this._context.currentTime;
2479
+ }
2480
+
2481
+ /** 実際に音が鳴り始めるAudioContext上の時刻(秒) */
2482
+ get scheduledTime() {
2483
+ return this._scheduledTime;
2484
+ }
2485
+
2486
+ /** 音が出力デバイスから実際に出るまでの遅延(秒) */
2487
+ get outputLatency() {
2488
+ return this._context.outputLatency || this._context.baseLatency || 0;
2489
+ }
2490
+
2491
+ /** AudioContextの状態(running / suspended / closed) */
2492
+ get contextState() {
2493
+ return this._context.state;
2494
+ }
2495
+
2451
2496
  set currentTime(_currentTime) {
2452
2497
  this._fadeinPosition = _currentTime;
2453
2498
  }
@@ -2512,6 +2557,47 @@ class AudioPlayer {
2512
2557
  load() { }
2513
2558
  dispatchEvent() { }
2514
2559
  }
2560
+ // グローバルで1つだけ保持(遅延生成)
2561
+ const getSharedAudioContext = () => {
2562
+ if (!g_sharedAudioContext) {
2563
+ g_sharedAudioContext = new AudioContext();
2564
+ }
2565
+ // タブのバックグラウンド化等でsuspendedになることがあるため念のためresume
2566
+ if (g_sharedAudioContext.state === `suspended`) {
2567
+ g_sharedAudioContext.resume();
2568
+ }
2569
+ return g_sharedAudioContext;
2570
+ };
2571
+ /**
2572
+ * AudioContextのウォームアップ
2573
+ * - 生成直後・resume直後のAudioContextは出力デバイスの起動待ちのため、
2574
+ * しばらく currentTime が進まない(環境により数十〜数百ms)
2575
+ * - この状態で音源をスケジュールすると、起動に要した時間がそのまま
2576
+ * 音源と譜面のずれになるため、無音を1回鳴らして時計が動き出すまで待つ
2577
+ * @returns {Promise<void>}
2578
+ */
2579
+ const warmUpAudioContext = async () => {
2580
+ const ctx = getSharedAudioContext();
2581
+ if (ctx.state !== `running`) {
2582
+ await ctx.resume().catch(() => { });
2583
+ }
2584
+ if (ctx.state !== `running`) {
2585
+ return; // ジェスチャー未取得等でresumeできない場合は何もしない
2586
+ }
2587
+
2588
+ // 無音を1サンプルだけ鳴らして出力デバイスを起動させる
2589
+ const source = ctx.createBufferSource();
2590
+ source.buffer = ctx.createBuffer(1, 1, ctx.sampleRate);
2591
+ source.connect(ctx.destination);
2592
+ source.start();
2593
+
2594
+ // currentTimeが実際に進み始めるまで待機(最大500ms)
2595
+ const baseTime = ctx.currentTime;
2596
+ const limitTime = performance.now() + 500;
2597
+ while (ctx.currentTime === baseTime && performance.now() < limitTime) {
2598
+ await new Promise(resolve => setTimeout(resolve, 10));
2599
+ }
2600
+ };
2515
2601
 
2516
2602
  /**
2517
2603
  * クリップボードコピー関数
@@ -9819,6 +9905,7 @@ const setAudio = async (_url) => {
9819
9905
  g_currentPage = `loadingIos`;
9820
9906
  lblLoading.textContent = `Click to Start!`;
9821
9907
  divRoot.appendChild(makePlayButton(evt => {
9908
+ getSharedAudioContext().resume();
9822
9909
  g_currentPage = `loading`;
9823
9910
  resetKeyControl();
9824
9911
  divRoot.removeChild(evt.target);
@@ -10037,6 +10124,9 @@ const loadingScoreInit = async () => {
10037
10124
  // ユーザカスタムイベント
10038
10125
  g_customJsObj.loading.forEach(func => func());
10039
10126
 
10127
+ // 初回プレイ時に出力デバイスの起動待ちでずれるのを防ぐため、
10128
+ // メイン画面へ移行する前にAudioContextを起動させておく
10129
+ await warmUpAudioContext();
10040
10130
  mainInit();
10041
10131
  };
10042
10132
 
@@ -10528,7 +10618,7 @@ const scoreConvert = (_dosObj, _scoreId, _preblankFrame, _dummyNo = ``,
10528
10618
  let layerTrans = tmpScrollchData[4] ?? ``;
10529
10619
  maxLayerGroup = Math.max(maxLayerGroup, layerGroup);
10530
10620
  if (g_stateObj.reverse === C_FLG_ON) {
10531
- layerTrans = invertSpecificTransforms(layerTrans);
10621
+ layerTrans = invertSpecificTransforms(layerTrans, [`translateX`]);
10532
10622
  }
10533
10623
 
10534
10624
  scrollchData.push([frame, arrowNum, frame, scrollDir, layerGroup, layerTrans]);
@@ -11643,15 +11733,20 @@ const pushScrollchs = (_header, _frameArrow, _val, _frameStep, _scrollDir, _laye
11643
11733
  /**
11644
11734
  * ホワイトリストに登録されたCSS関数内の数値符号のみを反転する関数
11645
11735
  * @param {string} cssString
11736
+ * @param {string[]} exceptList 反転対象から除外するCSS関数名のリスト
11646
11737
  * @returns {string}
11647
11738
  */
11648
- const invertSpecificTransforms = (cssString) => {
11739
+ const invertSpecificTransforms = (cssString, exceptList = []) => {
11649
11740
  // 反転対象にしたいCSS関数名を指定(ホワイトリスト)
11650
- const targetFunctions = [
11741
+ const baseTargetFunctions = [
11651
11742
  `rotate`, `rotateX`, `rotateY`, `rotateZ`, `rotate3d`,
11652
11743
  `translate`, `translateX`, `translateY`, `translateZ`, `translate3d`,
11653
11744
  `skew`, `skewX`, `skewY`
11654
11745
  ];
11746
+ const targetFunctions = baseTargetFunctions.filter(pattern => !exceptList.includes(pattern));
11747
+ if (targetFunctions.length === 0) {
11748
+ return cssString; // 反転対象がない場合は元の文字列を返す
11749
+ }
11655
11750
 
11656
11751
  // RegExパターンを作成: /(rotate|translate...)\(([^)]+)\)/g
11657
11752
  const pattern = new RegExp(`\\b(${targetFunctions.join(`|`)})\\(([^)]+)\\)`, `g`);
@@ -11731,7 +11826,8 @@ const getArrowSettings = () => {
11731
11826
  if (g_keyObj[`layerTrans${keyCtrlPtn}`]?.[0]) {
11732
11827
  g_workObj.layerTrans = structuredClone(g_keyObj[`layerTrans${keyCtrlPtn}`][0]);
11733
11828
  if (g_stateObj.reverse === C_FLG_ON) {
11734
- g_workObj.layerTrans = g_workObj.layerTrans.map(val => invertSpecificTransforms(val));
11829
+ // Reverse時はX軸反転のため、X軸の座標変換では符号を変換しない
11830
+ g_workObj.layerTrans = g_workObj.layerTrans.map(val => invertSpecificTransforms(val, [`translateX`]));
11735
11831
  }
11736
11832
  }
11737
11833
 
@@ -12291,6 +12387,7 @@ const mainInit = () => {
12291
12387
  let thisTime;
12292
12388
  let buffTime;
12293
12389
  let musicStartTime;
12390
+ let musicStartCtxTime;
12294
12391
  let musicStartFlg = false;
12295
12392
 
12296
12393
  g_inputKeyBuffer = {};
@@ -13463,14 +13560,32 @@ const mainInit = () => {
13463
13560
  }
13464
13561
 
13465
13562
  // 60fpsから遅延するため、その差分を取って次回のタイミングで遅れをリカバリする
13563
+ // - WebAudioAPI使用時は音源クロック(AudioContext.currentTime)を基準とする
13564
+ // performance.now()とAudioContextの時計は独立して進むため、後者を基準にしないと
13565
+ // 再生開始時のオフセットずれやsuspend/resumeによるずれを吸収できない
13466
13566
  thisTime = performance.now();
13467
13567
  buffTime = 0;
13468
- if (g_audio instanceof AudioPlayer || currentFrame >= musicStartFrame) {
13568
+ let holdFrame = false;
13569
+
13570
+ if (g_audio instanceof AudioPlayer && g_audioClockSync && musicStartCtxTime !== undefined) {
13571
+ if (g_audio.contextState === `running`) {
13572
+ buffTime = (g_audio.contextTime - musicStartCtxTime) * 1000
13573
+ - (currentFrame - musicStartFrame) * 1000 / g_fps;
13574
+ } else {
13575
+ // AudioContext停止中は音源も止まっているため、フレーム進行も止めて復帰を待つ
13576
+ getSharedAudioContext(); // suspended時はresumeを試行
13577
+ holdFrame = true;
13578
+ }
13579
+ } else if (g_audio instanceof AudioPlayer || currentFrame >= musicStartFrame) {
13469
13580
  buffTime = (thisTime - musicStartTime - (currentFrame - musicStartFrame) * 1000 / g_fps);
13470
13581
  }
13471
- g_scoreObj.frameNum++;
13472
- g_scoreObj.baseFrame++;
13473
- g_timeoutEvtId = setTimeout(flowTimeline, 1000 / g_fps - buffTime);
13582
+
13583
+ if (!holdFrame) {
13584
+ g_scoreObj.frameNum++;
13585
+ g_scoreObj.baseFrame++;
13586
+ }
13587
+ g_timeoutEvtId = setTimeout(flowTimeline, holdFrame ? g_maxFrameWait :
13588
+ Math.min(Math.max(1000 / g_fps - buffTime, 0), g_maxFrameWait));
13474
13589
  }
13475
13590
  };
13476
13591
  g_skinJsObj.main.forEach(func => func());
@@ -13483,6 +13598,11 @@ const mainInit = () => {
13483
13598
  const musicStartAdjustment = (g_headerObj.blankFrame - g_stateObj.decimalAdjustment + 1) / g_fps;
13484
13599
  musicStartTime = performance.now() + (musicStartAdjustment + g_scheduleLead) * 1000;
13485
13600
  g_audio.play(musicStartAdjustment);
13601
+
13602
+ // 音源クロック基準の開始時刻(フレーム進行の基準)
13603
+ // - g_audioLatencyCompensationが有効な場合は出力遅延分だけ表示を後ろへずらす
13604
+ musicStartCtxTime = g_audio.scheduledTime
13605
+ + (g_audioLatencyCompensation ? g_audio.outputLatency : 0);
13486
13606
  }
13487
13607
 
13488
13608
  g_timeoutEvtId = setTimeout(flowTimeline, 1000 / g_fps);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "danoniplus",
3
- "version": "44.5.21",
3
+ "version": "44.5.23",
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",