diff --git a/README.md b/README.md index 7162fbc..32ee10b 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ These guides explain how to add the legacy self-contained 32-bit build to the os - Template and bookmark tools, including an optional global hotkey for bookmarks - Quick toggle bar under the menu bar: docked by default, or dragged out into a floating window for instant switches while editing - "Freeze preview" switch: pins the preview at the current editor time while the editor keeps running (the distance helper still follows the editor) +- Y axis (time) scale from x0.5 to x4.0 in 0.1 steps, via the quick toggle bar's "Y Scale" slider or by holding **Ctrl** and scrolling inside the viewer window (only while the viewer is in the foreground; the X axis is unaffected) - Unified user settings and crash logs under a fixed `%LocalAppData%` path ## Requirements diff --git a/osucatch-editor-realtimeviewer/DrawingHelper.cs b/osucatch-editor-realtimeviewer/DrawingHelper.cs index 414b0ec..c24756d 100644 --- a/osucatch-editor-realtimeviewer/DrawingHelper.cs +++ b/osucatch-editor-realtimeviewer/DrawingHelper.cs @@ -163,6 +163,19 @@ public void LoadBeatmap(IBeatmap convertedBeatmap, int mods = 0) if (CustomComboColours.Count <= 0) CustomComboColours = DefaultCustomComboColours; } + /// + /// 由 approach time(ms)反推 AR: 里正向换算的逆运算 + /// (AR < 5 段 AT = 1800 - 120·AR,AR ≥ 5 段 AT = 1200 - 150·(AR-5),两段在 AR=5 / AT=1200 处衔接)。 + /// 用于把“当前 Y 轴缩放看到的纵向时间尺度”折算成大家熟悉的 AR: + /// 缩放到 r 时画面上 432 像素对应的时间是 ApproachTime / r, + /// 把它当成 AR 的 approach time 代进来即可。 + /// + /// + /// approach time(ms)。 + /// 等效 AR;超出 0~10 的值照实返回(不夹取)。 + public static double ApproachRateFromApproachTime(double approachTime) + => (approachTime > 1200) ? (1800 - approachTime) / 120.0 : (1950 - approachTime) / 150.0; + /// /// 将后台流水线构建好的数据原子地应用到当前绘制实例。 /// 只替换装载期字段,不动 CurrentTime / NearbyHitObjects / Bookmarks 等运行时状态。 diff --git a/osucatch-editor-realtimeviewer/Form1.cs b/osucatch-editor-realtimeviewer/Form1.cs index da96935..fa16063 100644 --- a/osucatch-editor-realtimeviewer/Form1.cs +++ b/osucatch-editor-realtimeviewer/Form1.cs @@ -87,6 +87,19 @@ public partial class Form1 : Form /// 滑块刻度间隔(同样以 0.1 为单位):每 0.5 倍画一根刻度线。 private const int VerticalScaleTickTenths = 5; + /// 一格滚轮(Ctrl + 滚轮)调整多少:1 = 0.1,与滑块步进一致。 + private const int VerticalScaleTenthsPerWheelNotch = 1; + + /// Y 缩放功能区里“当前缩放相当于多少 AR”那一段文字的标识。 + private const string VerticalScaleArTextKey = "VerticalScaleAR"; + + /// + /// 当前 Y 轴缩放(以 0.1 为单位)。与滑块位置、 + /// 一起由 统一维护,Ctrl + 滚轮也走同一条路径, + /// 这样“滚轮改完滑块跟着动”天然成立(浮点倍率不适合拿来累加,故另存一份整数)。 + /// + private int verticalScaleTenths = VerticalScaleDefaultTenths; + /// 快捷开关条上的下拉框 / 滑块所用的值标识。 private const string FreezeValueKey = FreezePreviewTimeKey; @@ -345,6 +358,14 @@ private void Form1_Load(object sender, EventArgs e) ApplyBarLineMode(BarLineSettings.CurrentMode, persist: false); // 垂直缩放不持久化:每次启动都把滑块与画面恢复成默认的 x1.0 ApplyVerticalScale(VerticalScaleDefaultTenths); + // Ctrl + 滚轮缩放 Y 轴:滚轮消息发给“拥有键盘焦点的窗口”(焦点可能在画布上), + // 用消息过滤器在消息分发前拦下,不依赖 WinForms 的滚轮冒泡行为 + wheelFilter = new VerticalScaleWheelFilter(this); + Application.AddMessageFilter(wheelFilter); + FormClosed += (sender, e) => + { + if (wheelFilter != null) Application.RemoveMessageFilter(wheelFilter); + }; quickToggleDocking?.ApplyStartupState( app.Default.QuickToggle_Visible, app.Default.QuickToggle_Floating, @@ -635,6 +656,8 @@ private void RequestDraw(int? editorTime, string? title, string statusText) if (IsDisposed || Disposing) return; if (title != null && this.Text != title) this.Text = title; if (StateToolStripStatusLabel.Text != statusText) StateToolStripStatusLabel.Text = statusText; + // 后台重建刚提交的话 ApproachTime 可能已变(开/关 EZ、HR 或换谱面):顺带刷新“等效 AR” + RefreshVerticalScaleArText(); this.Canvas.Canvas_Paint(null, null); })); Log.ConsoleLog("Draw a frame successful.", Log.LogType.Drawing, Log.LogLevel.Debug); @@ -1592,6 +1615,11 @@ private void CreateQuickToggles() VerticalScaleDefaultTenths, VerticalScaleTickTenths); scaleGroup.AddLabel(VerticalScaleValueKey + "_Text", VerticalScaleStatusText()); + // 当前缩放相当于多少 AR:紧跟在比例后面(开 EZ / HR 时按 mod 后的实际 AR 折算) + scaleGroup.AddLabel(VerticalScaleArTextKey, VerticalScaleArText()); + // 条上只放得下“x1.0”“AR 9.3”,Ctrl+滚轮与折算口径写进悬停提示 + quickToggleBar.SetGroupLabelToolTip(VerticalScaleGroupKey, VerticalScaleValueKey + "_Text", VerticalScaleHintText()); + quickToggleBar.SetGroupLabelToolTip(VerticalScaleGroupKey, VerticalScaleArTextKey, VerticalScaleArHintText()); ApplyQuickToggleIcons(); } @@ -1769,10 +1797,13 @@ private static string LabelToggleToolTip(QuickLabelMode mode) private void ApplyVerticalScale(int tenths) { int clamped = Math.Clamp(tenths, VerticalScaleMinTenths, VerticalScaleMaxTenths); + verticalScaleTenths = clamped; drawingHelper.VerticalScale = clamped / 10f; quickToggleBar?.SetGroupSliderValue(VerticalScaleGroupKey, VerticalScaleValueKey, clamped); quickToggleBar?.SetGroupLabelText(VerticalScaleGroupKey, VerticalScaleValueKey + "_Text", VerticalScaleStatusText()); + // 比例一变,等效 AR 跟着变(同一帧内一起刷新,两个数字不会出现一帧的错位) + RefreshVerticalScaleArText(); } /// @@ -1781,6 +1812,144 @@ private void ApplyVerticalScale(int tenths) /// private static string VerticalScaleStatusText() => "x" + drawingHelper.VerticalScale.ToString("0.0"); + /// + /// 当前 Y 轴缩放相当于多少 AR(形如 AR 9.3)。 + /// 折算口径:画面上 432 像素对应的时间就是 的来历 + /// (TimePerPixels = ApproachTime / 432)。缩放 r 倍后同样 432 像素对应 + /// ApproachTime / r 毫秒,把这个时间当成 AR 的 approach time 反推,就得到等效 AR: + /// 放大(x>1)看到的时间尺度更短 → 等效 AR 更高,压扁则更低。 + /// + /// 取自绘制状态,而它是由 + /// convertedBeatmap.Difficulty.ApproachRate 算出来的——EZ / HR 属于 + /// IApplicableToDifficulty,在换谱面的转换阶段就已经作用到难度值上, + /// 所以开 mod 后这里显示的自然是按 mod 后实际 AR 折算的结果。 + /// + /// 等效值可能超出 0~10(放大到 4x 会高于 AR10,压扁到 0.5x 可能低于 AR0),一律照实显示。 + /// + /// + private static string VerticalScaleArText() + { + int approachTime = drawingHelper.ApproachTime; + float scale = drawingHelper.VerticalScale; + // 还没读到谱面(ApproachTime 尚未装载)时给个占位符,避免显示 AR NaN / 负无穷 + if (approachTime <= 0 || !(scale > 0)) return "AR -"; + + double ar = DrawingHelper.ApproachRateFromApproachTime(approachTime / (double)scale); + return "AR" + ar.ToString("0.0"); + } + + /// 上次刷新“等效 AR”用的输入,避免每帧都重新格式化字符串。 + private int arTextCachedApproachTime = -1; + private float arTextCachedScale = -1f; + + /// + /// 刷新“等效 AR”文字(只在 UI 线程调用)。 + /// 两个触发点:比例变化时由 立即刷新; + /// 后台重建提交后 会变(开 / 关 EZ、HR 或换谱面都走这条路, + /// 而提交发生在后台线程),由每帧绘制前的 顺带补上。 + /// + /// + private void RefreshVerticalScaleArText() + { + int approachTime = drawingHelper.ApproachTime; + float scale = drawingHelper.VerticalScale; + if (approachTime == arTextCachedApproachTime && scale == arTextCachedScale) return; + + arTextCachedApproachTime = approachTime; + arTextCachedScale = scale; + quickToggleBar?.SetGroupLabelText(VerticalScaleGroupKey, VerticalScaleArTextKey, VerticalScaleArText()); + } + + /// + /// Y 缩放比例文字的悬停提示:条上只显示“x1.0”,这里把 Ctrl + 滚轮的快捷操作一并说明。 + /// + private static string VerticalScaleHintText() + { + bool chinese = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName == "zh"; + return chinese + ? "Y 轴缩放比例(在 viewer 窗口内按住 Ctrl 滚滚轮也能调,每格 0.1)" + : "Y axis scale (hold Ctrl and scroll inside the viewer window; 0.1 per notch)"; + } + + /// “等效 AR”文字的悬停提示:说明它是按当前缩放折算出来的,并点明 mod 口径。 + private static string VerticalScaleArHintText() + { + bool chinese = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName == "zh"; + return chinese + ? "按当前 Y 缩放折算的等效 AR:把画面上“432 像素对应的时间”当成 AR 的 approach time 反推;" + + "开 EZ / HR 时按 mod 后的实际 AR 计算(缩放只改显示比例,不改谱面数据)" + : "Effective AR implied by the current Y scale (the 432-pixel approach distance is re-expressed as an AR); " + + "uses the modded AR when EZ / HR is on (the scale only changes the view, not the beatmap)"; + } + + #region Y 轴缩放:Ctrl + 滚轮 + + /// 滚轮消息(WM_MOUSEWHEEL);wParam 高 16 位是带符号的滚动量。 + private const int WM_MOUSEWHEEL = 0x020A; + + /// Ctrl + 滚轮的消息过滤器实例(随窗体关闭一并注销)。 + private VerticalScaleWheelFilter? wheelFilter; + + /// + /// Ctrl + 滚轮调整 Y 轴缩放的消息过滤器。 + /// 为什么用消息过滤器而不是重写 OnMouseWheel:滚轮消息由系统发给 + /// “当前拥有键盘焦点的窗口”。焦点可能落在画布上( 是 GLControl, + /// 有自己独立的窗口句柄),也可能落在主窗口本身;过滤器在消息进入控件之前就能看到, + /// 因此不必依赖“未处理的滚轮消息会被 WinForms 冒泡给父控件”这个实现细节。 + /// + /// 回调里的三道闸(前台、光标在窗口内、按住 Ctrl)都在 。 + /// + /// + private sealed class VerticalScaleWheelFilter : IMessageFilter + { + private readonly Form1 owner; + + internal VerticalScaleWheelFilter(Form1 owner) => this.owner = owner; + + public bool PreFilterMessage(ref Message m) + { + if (m.Msg != WM_MOUSEWHEEL) return false; + + int delta = (short)((m.WParam.ToInt64() >> 16) & 0xFFFF); + return owner.TryZoomVerticalScaleByWheel(delta); + } + } + + /// + /// 处理一次滚轮消息:只有“viewer 在前台 + 光标在 viewer 窗口内 + 按住 Ctrl”时才调整 Y 轴缩放。 + /// 向上滚 = 放大(把时间轴拉长),一格 0.1,与滑块步进一致;缩放基准仍是判定线, + /// 与拖滑块完全等价(滚轮只是另一个入口)。 + /// + /// + /// true 表示这条滚轮消息已被消费,不再交给画布 / 快捷开关栏等控件。 + private bool TryZoomVerticalScaleByWheel(int wheelDelta) + { + // 正在关闭时不再动控件(消息过滤器要等 FormClosed 才注销,期间可能还有滚轮消息在途) + if (IsDisposed || Disposing) return false; + + // ① 本窗口必须在前台:焦点在窗口内的任意子控件上才算。 + // “viewer 不在前台”包含两类情况,这里一并挡掉: + // - 焦点在别的程序(或本程序的浮窗 / 设置窗口)上; + // - Windows 的“悬停时滚动非活动窗口”把滚轮直接送到悬停窗口,但焦点并不在我们的窗口上。 + if (!ContainsFocus) return false; + + // ② 光标必须落在 viewer 窗口内(Ctrl+滚轮甩到窗口外时不该改缩放) + if (!Bounds.Contains(Cursor.Position)) return false; + + // ③ 必须按住 Ctrl:没按 Ctrl 时把滚轮让给其它控件(菜单栏溢出滚动、下拉框等) + if ((ModifierKeys & Keys.Control) != Keys.Control) return false; + + int notches = wheelDelta / SystemInformation.MouseWheelScrollDelta; + // 高精度滚轮 / 触摸板可能只报小于一格(120)的增量:按方向算作一格,避免完全没反应 + if (notches == 0) notches = Math.Sign(wheelDelta); + if (notches == 0) return false; + + ApplyVerticalScale(verticalScaleTenths + notches * VerticalScaleTenthsPerWheelNotch); + return true; + } + + #endregion + @@ -2088,6 +2257,10 @@ private void ApplyQuickToggleLanguage() quickToggleBar.SetGroupLabelText(BarLineGroupKey, BarLineValueKey + "_Text", BarLineStatusText()); quickToggleBar.SetGroupLabelText(VerticalScaleGroupKey, VerticalScaleValueKey + "_Text", VerticalScaleStatusText()); + // 等效 AR 的文字本身与语言无关(“AR 9.3”),但提示要跟着语言走 + RefreshVerticalScaleArText(); + quickToggleBar.SetGroupLabelToolTip(VerticalScaleGroupKey, VerticalScaleValueKey + "_Text", VerticalScaleHintText()); + quickToggleBar.SetGroupLabelToolTip(VerticalScaleGroupKey, VerticalScaleArTextKey, VerticalScaleArHintText()); } /// diff --git a/osucatch-editor-realtimeviewer/QuickToggleBar.cs b/osucatch-editor-realtimeviewer/QuickToggleBar.cs index c55f70d..3cd43c2 100644 --- a/osucatch-editor-realtimeviewer/QuickToggleBar.cs +++ b/osucatch-editor-realtimeviewer/QuickToggleBar.cs @@ -729,6 +729,15 @@ internal void SetGroupLabelText(string groupKey, string labelKey, string text) if (groups.TryGetValue(groupKey, out QuickToggleGroup? group)) group.SetLabelText(labelKey, text); } + /// + /// 设置功能区里一段只读文字的悬停提示(语言切换等场景调用)。 + /// 条上的文字通常很短(如“x1.0”),快捷键之类的补充说明放在这里。 + /// + internal void SetGroupLabelToolTip(string groupKey, string labelKey, string toolTip) + { + if (groups.TryGetValue(groupKey, out QuickToggleGroup? group)) group.SetLabelToolTip(labelKey, toolTip); + } + #endregion #region 停靠 / 浮动 @@ -1120,6 +1129,16 @@ internal void SetLabelText(string labelKey, string text) if (labels.TryGetValue(labelKey, out ToolStripLabel? item)) item.Text = text; } + /// 设置功能区里一段只读文字的悬停提示。 + internal void SetLabelToolTip(string labelKey, string toolTip) + { + if (!labels.TryGetValue(labelKey, out ToolStripLabel? item)) return; + + // AutoToolTip = true 会用 Text 覆盖 ToolTipText,这里必须关掉才显示得出提示 + item.AutoToolTip = false; + item.ToolTipText = toolTip; + } + /// 功能区里的只读文字项,键为文字标识。 private readonly Dictionary labels = new(StringComparer.Ordinal);