danoniplus 49.6.0 → 50.0.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/08/16
7
+ * Revised : 2026/08/24
8
8
  *
9
9
  * https://github.com/cwtickle/danoniplus
10
10
  */
11
- const g_version = `Ver 49.6.0`;
12
- const g_revisedDate = `2026/08/16`;
11
+ const g_version = `Ver 50.0.0`;
12
+ const g_revisedDate = `2026/08/24`;
13
13
 
14
14
  // カスタム用バージョン (danoni_custom.js 等で指定可)
15
15
  let g_localVersion = ``;
@@ -135,6 +135,19 @@ let g_fps = 60;
135
135
  // プレイ画面再生時の内部スケジューリング用のマージン時間(100ms)
136
136
  let g_scheduleLead = 0.1;
137
137
 
138
+ // フレーム進行を音源クロック(AudioContext.currentTime)基準で補正するか
139
+ // - false の場合は従来通り performance.now() 基準で動作
140
+ let g_audioClockSync = true;
141
+
142
+ // 出力遅延(AudioContext.outputLatency)をタイミング補正に含めるか
143
+ // - true にすると出力デバイス(有線/Bluetooth等)によらず同じAdjustmentが使えるが、
144
+ // 既存のAdjustment設定値と互換性がなくなるため既定は false
145
+ let g_audioLatencyCompensation = false;
146
+
147
+ // 次フレームまでの待機時間の上限(ms)
148
+ // - 音源クロックが一時的に停止した際に待ち続けないようにするための保険
149
+ let g_maxFrameWait = 50;
150
+
138
151
  // 譜面データの&区切りを有効にするか
139
152
  let g_enableAmpersandSplit = true;
140
153
 
@@ -2562,6 +2575,7 @@ class AudioPlayer {
2562
2575
  this._gain = this._context.createGain();
2563
2576
  this._gain.connect(this._context.destination);
2564
2577
  this._startTime = 0;
2578
+ this._scheduledTime = 0;
2565
2579
  this._fadeinPosition = 0;
2566
2580
  this._eventListeners = {};
2567
2581
  this.playbackRate = 1;
@@ -2586,17 +2600,25 @@ class AudioPlayer {
2586
2600
  * - scheduleLead は安定した再生タイミングを確保するための内部マージン
2587
2601
  */
2588
2602
  play(_adjustmentTime = 0) {
2603
+ // AudioContextの時計は1回だけ読み、以降はその値を使い回す
2604
+ // - currentTimeはレンダークォンタム単位でしか進まないため、複数回読むと
2605
+ // 予約時刻と論理開始時刻が最大1クォンタム分ずれる
2606
+ const ctxNow = this._context.currentTime;
2607
+
2589
2608
  this._source = this._context.createBufferSource();
2590
2609
  this._source.buffer = this._buffer;
2591
2610
  this._source.playbackRate.value = this.playbackRate;
2592
2611
  this._source.connect(this._gain);
2593
2612
 
2594
2613
  // 実際の予約時刻(内部スケジューリング用のマージンを含む)
2595
- const startAt = this._context.currentTime + g_scheduleLead + _adjustmentTime;
2614
+ const startAt = ctxNow + g_scheduleLead + _adjustmentTime;
2596
2615
  this._source.start(startAt, this._fadeinPosition);
2597
2616
 
2598
2617
  // ゲーム側の論理的開始時刻(g_scheduleLead を含めない)
2599
- this._startTime = this._context.currentTime + _adjustmentTime;
2618
+ this._startTime = ctxNow + _adjustmentTime;
2619
+
2620
+ // 実際に音が鳴り始めるAudioContext上の時刻(フレーム同期の基準)
2621
+ this._scheduledTime = startAt;
2600
2622
  }
2601
2623
 
2602
2624
  pause() {
@@ -2621,6 +2643,26 @@ class AudioPlayer {
2621
2643
  return this._context.currentTime - this._startTime + this._fadeinPosition;
2622
2644
  }
2623
2645
 
2646
+ /** AudioContextの現在時刻(秒) */
2647
+ get contextTime() {
2648
+ return this._context.currentTime;
2649
+ }
2650
+
2651
+ /** 実際に音が鳴り始めるAudioContext上の時刻(秒) */
2652
+ get scheduledTime() {
2653
+ return this._scheduledTime;
2654
+ }
2655
+
2656
+ /** 音が出力デバイスから実際に出るまでの遅延(秒) */
2657
+ get outputLatency() {
2658
+ return this._context.outputLatency || this._context.baseLatency || 0;
2659
+ }
2660
+
2661
+ /** AudioContextの状態(running / suspended / closed) */
2662
+ get contextState() {
2663
+ return this._context.state;
2664
+ }
2665
+
2624
2666
  set currentTime(_currentTime) {
2625
2667
  this._fadeinPosition = _currentTime;
2626
2668
  }
@@ -2708,6 +2750,37 @@ const getSharedAudioContext = () => {
2708
2750
  return g_sharedAudioContext;
2709
2751
  };
2710
2752
 
2753
+ /**
2754
+ * AudioContextのウォームアップ
2755
+ * - 生成直後・resume直後のAudioContextは出力デバイスの起動待ちのため、
2756
+ * しばらく currentTime が進まない(環境により数十〜数百ms)
2757
+ * - この状態で音源をスケジュールすると、起動に要した時間がそのまま
2758
+ * 音源と譜面のずれになるため、無音を1回鳴らして時計が動き出すまで待つ
2759
+ * @returns {Promise<void>}
2760
+ */
2761
+ const warmUpAudioContext = async () => {
2762
+ const ctx = getSharedAudioContext();
2763
+ if (ctx.state !== `running`) {
2764
+ await ctx.resume().catch(() => { });
2765
+ }
2766
+ if (ctx.state !== `running`) {
2767
+ return; // ジェスチャー未取得等でresumeできない場合は何もしない
2768
+ }
2769
+
2770
+ // 無音を1サンプルだけ鳴らして出力デバイスを起動させる
2771
+ const source = ctx.createBufferSource();
2772
+ source.buffer = ctx.createBuffer(1, 1, ctx.sampleRate);
2773
+ source.connect(ctx.destination);
2774
+ source.start();
2775
+
2776
+ // currentTimeが実際に進み始めるまで待機(最大500ms)
2777
+ const baseTime = ctx.currentTime;
2778
+ const limitTime = performance.now() + 500;
2779
+ while (ctx.currentTime === baseTime && performance.now() < limitTime) {
2780
+ await new Promise(resolve => setTimeout(resolve, 10));
2781
+ }
2782
+ };
2783
+
2711
2784
  /**
2712
2785
  * クリップボードコピー関数
2713
2786
  * 入力値をクリップボードへコピーし、メッセージを表示
@@ -8388,6 +8461,7 @@ const setDifficulty = (_initFlg) => {
8388
8461
 
8389
8462
  // ユーザカスタムイベント(初期)
8390
8463
  safeExecuteCustomHooks(`g_customJsObj.difficulty`, g_customJsObj.difficulty, _initFlg, g_canLoadDifInfoFlg);
8464
+ resolveKeyFamily();
8391
8465
 
8392
8466
  // 設定サマリー表示の更新
8393
8467
  updateSettingSummary();
@@ -8397,6 +8471,77 @@ const setDifficulty = (_initFlg) => {
8397
8471
  g_canLoadDifInfoFlg = true;
8398
8472
  };
8399
8473
 
8474
+ /**
8475
+ * keyLabelの系統ごとに、選択時に強制したい設定値・選択/離脱時の個別処理を登録する
8476
+ * @param {string} _name 系統名
8477
+ * @param {(key: string) => boolean} _match この系統に属するkeyLabelかどうか
8478
+ * @param {object} [_fields] path -> 値 or 値を返す関数(単純代入で済むもの)
8479
+ * @param {object} [_hooks] { onAcquire, onRelease } 選択/離脱の瞬間に1回だけ呼ばれる(複合処理用)
8480
+ * @param {object} [_appliers] path -> 個別の適用関数(単純代入で済まないfields用)
8481
+ */
8482
+ const registerKeyFamily = (_name, _match, _fields = {}, _hooks = {}, _appliers = {}) => {
8483
+ g_familyObj.families.push({ name: _name, match: _match, fields: _fields, appliers: _appliers, ..._hooks });
8484
+ };
8485
+
8486
+ /**
8487
+ * 現在のキー状態に応じて適用すべき設定群を判定し、
8488
+ * 各パラメータの値・スナップショット・フックを管理・適用する
8489
+ */
8490
+ const resolveKeyFamily = () => {
8491
+ // 1. 現在のキーに一致するファミリーを検索(見つからなければ null = 標準状態)
8492
+ const newFamily = g_familyObj.families.find(f => f.match(g_keyObj.currentKey)) ?? null;
8493
+
8494
+ // 2. ファミリーが切り替わった瞬間(入場・離脱)にフック(固有の副作用)を実行
8495
+ if (newFamily !== g_familyObj.currentFamily) {
8496
+ g_familyObj.currentFamily?.onRelease?.();
8497
+ newFamily?.onAcquire?.();
8498
+ g_familyObj.currentFamily = newFamily;
8499
+ }
8500
+
8501
+ // 3. 登録されているすべてのファミリーが持つパス(プロパティ)の全リストを取得
8502
+ const allPaths = new Set(g_familyObj.families.flatMap(f => Object.keys(f.fields)));
8503
+ allPaths.forEach(path => {
8504
+ const spec = newFamily?.fields[path];
8505
+ // 該当プロパティのカスタム処理があれば優先し、なければ通常のプロパティ代入を使用
8506
+ const applier = g_familyObj.families.map(f => f.appliers[path]).find(Boolean) ?? (v => setPathVal(path, v));
8507
+
8508
+ if (spec !== undefined) {
8509
+ // 【変更値の適用】現在のファミリーにそのプロパティの設定がある場合
8510
+ // 初めて書き換えるプロパティの場合のみ、元の値をスナップショット(初期値)として退避
8511
+ if (!g_familyObj.ownership[path]) {
8512
+ g_familyObj.snapshot[path] = getPathVal(path);
8513
+ g_familyObj.ownership[path] = true;
8514
+ }
8515
+ // 関数なら評価し、値ならそのまま適用値とする
8516
+ applyIfChanged(path, typeof spec === `function` ? spec() : spec, applier);
8517
+ } else if (g_familyObj.ownership[path]) {
8518
+ // 【標準への復元】別のファミリーに移行し、かつ元々自分が書き換えていたプロパティの場合
8519
+ // スナップショットから元の値(初期値)を復元し、オーナーシップを解放
8520
+ applyIfChanged(path, g_familyObj.snapshot[path], applier);
8521
+ g_familyObj.ownership[path] = false;
8522
+ }
8523
+ // ownership[path]がfalseのままなら、標準同士の切り替えでも一切触らない
8524
+ });
8525
+ };
8526
+
8527
+ /**
8528
+ * 値に変更がある場合のみ、代入またはカスタム処理を実行してキャッシュを更新する
8529
+ * @param {string} _path 設定のパス
8530
+ * @param {string|number} _value 適用する値
8531
+ * @param {Function} _applier 実際の適用処理を行う関数
8532
+ */
8533
+ const applyIfChanged = (_path, _value, _applier) => {
8534
+ // すでに同じ値が適用済みの場合は、無駄な再描画や再代入を防ぐためスキップ
8535
+ if (g_familyObj.appliedPaths.has(_path) && g_familyObj.applied[_path] === _value) return;
8536
+
8537
+ // 適用値をキャッシュに保存
8538
+ g_familyObj.applied[_path] = _value;
8539
+ g_familyObj.appliedPaths.add(_path);
8540
+
8541
+ // 実際の反映処理(値の代入、または画像再描画などの副作用)を実行
8542
+ _applier(_value);
8543
+ };
8544
+
8400
8545
  /**
8401
8546
  * 設定・オプション画面のラベル・ボタン処理の描画
8402
8547
  * @param {Object} _sprite 基準とするスプライト(ここで指定する座標は、そのスプライトからの相対位置)
@@ -12208,6 +12353,9 @@ const loadMusic = async () => {
12208
12353
  }
12209
12354
 
12210
12355
  if (loadSucceeded) {
12356
+ // 初回プレイ時に出力デバイスの起動待ちでずれるのを防ぐため、
12357
+ // メイン画面へ移行する前にAudioContextを起動させておく
12358
+ await warmUpAudioContext();
12211
12359
  mainInit(); // 音源・譜面変換双方の完了後にまとめて呼ぶ
12212
12360
  }
12213
12361
  };
@@ -14964,6 +15112,7 @@ const mainInit = () => {
14964
15112
  let thisTime;
14965
15113
  let buffTime;
14966
15114
  let musicStartTime;
15115
+ let musicStartCtxTime;
14967
15116
  let musicStartFlg = false;
14968
15117
 
14969
15118
  g_inputKeyBuffer = {};
@@ -16197,14 +16346,32 @@ const mainInit = () => {
16197
16346
  }
16198
16347
 
16199
16348
  // 60fpsから遅延するため、その差分を取って次回のタイミングで遅れをリカバリする
16349
+ // - WebAudioAPI使用時は音源クロック(AudioContext.currentTime)を基準とする
16350
+ // performance.now()とAudioContextの時計は独立して進むため、後者を基準にしないと
16351
+ // 再生開始時のオフセットずれやsuspend/resumeによるずれを吸収できない
16200
16352
  thisTime = performance.now();
16201
16353
  buffTime = 0;
16202
- if (g_audio instanceof AudioPlayer || currentFrame >= musicStartFrame) {
16354
+ let holdFrame = false;
16355
+
16356
+ if (g_audio instanceof AudioPlayer && g_audioClockSync && musicStartCtxTime !== undefined) {
16357
+ if (g_audio.contextState === `running`) {
16358
+ buffTime = (g_audio.contextTime - musicStartCtxTime) * 1000
16359
+ - (currentFrame - musicStartFrame) * 1000 / g_fps;
16360
+ } else {
16361
+ // AudioContext停止中は音源も止まっているため、フレーム進行も止めて復帰を待つ
16362
+ getSharedAudioContext(); // suspended時はresumeを試行
16363
+ holdFrame = true;
16364
+ }
16365
+ } else if (g_audio instanceof AudioPlayer || currentFrame >= musicStartFrame) {
16203
16366
  buffTime = (thisTime - musicStartTime - (currentFrame - musicStartFrame) * 1000 / g_fps);
16204
16367
  }
16205
- g_scoreObj.frameNum++;
16206
- g_scoreObj.baseFrame++;
16207
- g_timeoutEvtId = setTimeout(flowTimeline, 1000 / g_fps - buffTime);
16368
+
16369
+ if (!holdFrame) {
16370
+ g_scoreObj.frameNum++;
16371
+ g_scoreObj.baseFrame++;
16372
+ }
16373
+ g_timeoutEvtId = setTimeout(flowTimeline, holdFrame ? g_maxFrameWait :
16374
+ Math.min(Math.max(1000 / g_fps - buffTime, 0), g_maxFrameWait));
16208
16375
  }
16209
16376
  };
16210
16377
  safeExecuteCustomHooks(`g_skinJsObj.main`, g_skinJsObj.main);
@@ -16217,6 +16384,11 @@ const mainInit = () => {
16217
16384
  const musicStartAdjustment = (g_headerObj.blankFrame - g_stateObj.decimalAdjustment + 1) / g_fps;
16218
16385
  musicStartTime = performance.now() + (musicStartAdjustment + g_scheduleLead) * 1000;
16219
16386
  g_audio.play(musicStartAdjustment);
16387
+
16388
+ // 音源クロック基準の開始時刻(フレーム進行の基準)
16389
+ // - g_audioLatencyCompensationが有効な場合は出力遅延分だけ表示を後ろへずらす
16390
+ musicStartCtxTime = g_audio.scheduledTime
16391
+ + (g_audioLatencyCompensation ? g_audio.outputLatency : 0);
16220
16392
  }
16221
16393
 
16222
16394
  g_timeoutEvtId = setTimeout(flowTimeline, 1000 / g_fps);
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Source by tickle
7
7
  * Created : 2019/11/19
8
- * Revised : 2026/08/16 (v49.6.0)
8
+ * Revised : 2026/08/24 (v50.0.0)
9
9
  *
10
10
  * https://github.com/cwtickle/danoniplus
11
11
  */
@@ -5273,6 +5273,27 @@ const g_skinJsObj = {
5273
5273
  result: [],
5274
5274
  };
5275
5275
 
5276
+ const g_root = {
5277
+ get g_diffObj() { return g_diffObj; },
5278
+ get g_posObj() { return g_posObj; },
5279
+ get g_headerObj() { return g_headerObj; },
5280
+ get g_stateObj() { return g_stateObj; },
5281
+ get g_keycons() { return g_keycons; },
5282
+ };
5283
+ const getPathVal = _path => _path.split(`.`).reduce((o, k) => o?.[k], g_root);
5284
+ const setPathVal = (_path, _value) => {
5285
+ const keys = _path.split(`.`);
5286
+ const last = keys.pop();
5287
+ const target = keys.reduce((o, k) => o?.[k], g_root);
5288
+ if (target == null || !Object.hasOwn(target, last)) {
5289
+ console.warn(`setPathVal: path not found for "${_path}"`);
5290
+ return;
5291
+ }
5292
+ target[last] = _value;
5293
+ };
5294
+
5295
+ const g_familyObj = { families: [], currentFamily: null, ownership: {}, snapshot: {}, applied: {}, appliedPaths: new Set() };
5296
+
5276
5297
  const g_errorCache = {
5277
5298
  'g_customJsObj.titleEnterFrame': [],
5278
5299
  'g_customJsObj.mainEnterFrame': [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "danoniplus",
3
- "version": "49.6.0",
3
+ "version": "50.0.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",