diff --git a/Pal98Timer/GForm.cs b/Pal98Timer/GForm.cs
index f22811e..1955240 100644
--- a/Pal98Timer/GForm.cs
+++ b/Pal98Timer/GForm.cs
@@ -547,22 +547,12 @@ public void OnKeyPress(KeyboardLib.HookStruct hookStruct, out bool handle)
if (KeyChangerDel.IsEnable())
{
KeyChangerDel.Disable();
- ShowKCEnable();
- KeyChangerDel.Close();
}
else
{
- Run(delegate() {
- KeyChangerDel.Open();
- // Wait for the KeyChanger window to be ready (up to 3 seconds)
- for (int i = 0; i < 30 && !KeyChangerDel.IsWindowOpen(); i++)
- {
- System.Threading.Thread.Sleep(100);
- }
- KeyChangerDel.Enable();
- UI(delegate() { ShowKCEnable(); });
- });
+ KeyChangerDel.Enable();
}
+ ShowKCEnable();
core.OnFunctionKey(11);
}
handle = core.NeedBlockFunctionKey(11);
diff --git a/Pal98Timer/Pal98Timer.csproj b/Pal98Timer/Pal98Timer.csproj
index 68aa8cc..5c1a410 100644
--- a/Pal98Timer/Pal98Timer.csproj
+++ b/Pal98Timer/Pal98Timer.csproj
@@ -236,6 +236,7 @@
Upload.cs
+
@@ -330,4 +331,4 @@
-->
-
\ No newline at end of file
+
diff --git a/Pal98Timer/TimerCore.cs b/Pal98Timer/TimerCore.cs
index 2b0ad56..ea0cd5d 100644
--- a/Pal98Timer/TimerCore.cs
+++ b/Pal98Timer/TimerCore.cs
@@ -95,6 +95,7 @@ public TimerCore(GForm form)
{
this.form = form;
CMD5 = GetFileMD5(this.GetType().Assembly.Location);
+ VoicePrompt.ReloadConfig();
}
///
/// 计算文件的MD5
@@ -512,11 +513,22 @@ protected void Checking()
if (CurrentStep < CheckPoints.Count)
{
- CheckPoints[CurrentStep].Current = MT.CurrentTSOnly;
- if (CheckPoints[CurrentStep].Check())
+ CheckPoint currentCheckPoint = CheckPoints[CurrentStep];
+ currentCheckPoint.Current = MT.CurrentTSOnly;
+ if (currentCheckPoint.Check())
{
- CheckPoints[CurrentStep].Current = new TimeSpan(MT.CurrentTSOnly.Ticks);
- CheckPoints[CurrentStep].IsEnd = true;
+ currentCheckPoint.Current = new TimeSpan(MT.CurrentTSOnly.Ticks);
+ currentCheckPoint.IsEnd = true;
+ VoicePrompt.PlayCheckpointSound(currentCheckPoint.GetNickName());
+ long deltaSeconds = currentCheckPoint.GetCHA();
+ if (deltaSeconds < 0)
+ {
+ VoicePrompt.PlayFasterSound();
+ }
+ else if (deltaSeconds > 0)
+ {
+ VoicePrompt.PlaySlowerSound();
+ }
//CurrentStep++;
int nextstep = CurrentStep + 1;
if (nextstep >= CheckPoints.Count)
diff --git a/Pal98Timer/VoicePrompt.cs b/Pal98Timer/VoicePrompt.cs
new file mode 100644
index 0000000..86af4c7
--- /dev/null
+++ b/Pal98Timer/VoicePrompt.cs
@@ -0,0 +1,345 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace Pal98Timer
+{
+ public static class VoicePrompt
+ {
+ [DllImport("winmm.dll", CharSet = CharSet.Auto)]
+ private static extern int mciSendString(string command, StringBuilder buffer, int bufferSize, IntPtr hwndCallback);
+
+ private static readonly object _configLock = new object();
+ private static readonly object _playLock = new object();
+ private static readonly string _playerAlias = "paltimer_voice";
+ private static readonly string[] _soundExts = new string[] { ".mp3", ".wav" };
+ private static bool _isLoaded = false;
+ private static bool _isEnable = true;
+ private static string _fasterPath = @"sounds\faster.mp3";
+ private static string _slowerPath = @"sounds\slower.mp3";
+ private static Dictionary _checkpointSounds = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ private static string AppDir
+ {
+ get
+ {
+ try
+ {
+ string p = Path.GetDirectoryName(Process.GetCurrentProcess().MainModule.FileName);
+ if (!string.IsNullOrEmpty(p))
+ {
+ return p;
+ }
+ }
+ catch
+ { }
+ try
+ {
+ string p = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
+ if (!string.IsNullOrEmpty(p))
+ {
+ return p;
+ }
+ }
+ catch
+ { }
+ return Environment.CurrentDirectory;
+ }
+ }
+
+ public static void ReloadConfig()
+ {
+ lock (_configLock)
+ {
+ _isLoaded = false;
+ }
+ EnsureConfigLoaded();
+ }
+
+ private static void EnsureConfigLoaded()
+ {
+ if (_isLoaded)
+ {
+ return;
+ }
+ lock (_configLock)
+ {
+ if (_isLoaded)
+ {
+ return;
+ }
+ _isEnable = true;
+ _fasterPath = @"sounds\faster.mp3";
+ _slowerPath = @"sounds\slower.mp3";
+ _checkpointSounds = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ string cfgPath = Path.Combine(AppDir, "voice_config.txt");
+ if (File.Exists(cfgPath))
+ {
+ try
+ {
+ Encoding charset = TimerCore.GetFileEncodeType(cfgPath);
+ if (charset == null)
+ {
+ charset = Encoding.UTF8;
+ }
+ using (FileStream fs = new FileStream(cfgPath, FileMode.Open, FileAccess.Read))
+ {
+ using (StreamReader sr = new StreamReader(fs, charset))
+ {
+ while (!sr.EndOfStream)
+ {
+ string line = sr.ReadLine();
+ if (line == null)
+ {
+ continue;
+ }
+ line = line.Trim();
+ if (line == "" || line.StartsWith("#") || line.StartsWith(";") || line.StartsWith("//"))
+ {
+ continue;
+ }
+ int idx = line.IndexOf('=');
+ if (idx <= 0)
+ {
+ continue;
+ }
+ string key = line.Substring(0, idx).Trim();
+ string val = line.Substring(idx + 1).Trim();
+ if (key == "" || val == "")
+ {
+ continue;
+ }
+ string lowKey = key.ToLowerInvariant();
+ if (lowKey == "enable" || lowKey == "enabled" || lowKey == "voice_enable")
+ {
+ _isEnable = ParseBool(val, true);
+ }
+ else if (lowKey == "faster" || lowKey == "faster_sound")
+ {
+ _fasterPath = val;
+ }
+ else if (lowKey == "slower" || lowKey == "slower_sound")
+ {
+ _slowerPath = val;
+ }
+ else if (lowKey.StartsWith("checkpoint."))
+ {
+ string checkpointName = key.Substring("checkpoint.".Length).Trim();
+ if (checkpointName != "")
+ {
+ _checkpointSounds[checkpointName] = val;
+ }
+ }
+ else if (lowKey.StartsWith("cp."))
+ {
+ string checkpointName = key.Substring("cp.".Length).Trim();
+ if (checkpointName != "")
+ {
+ _checkpointSounds[checkpointName] = val;
+ }
+ }
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine("VoicePrompt config load failed: " + ex.Message);
+ }
+ }
+ _isLoaded = true;
+ }
+ }
+
+ private static bool ParseBool(string str, bool defaultValue)
+ {
+ if (string.IsNullOrEmpty(str))
+ {
+ return defaultValue;
+ }
+ string s = str.Trim().ToLowerInvariant();
+ if (s == "1" || s == "true" || s == "yes" || s == "on")
+ {
+ return true;
+ }
+ if (s == "0" || s == "false" || s == "no" || s == "off")
+ {
+ return false;
+ }
+ return defaultValue;
+ }
+
+ public static void PlaySound(string filePath)
+ {
+ EnsureConfigLoaded();
+ if (!_isEnable)
+ {
+ return;
+ }
+ string fullPath = ResolvePath(filePath);
+ if (fullPath == "")
+ {
+ return;
+ }
+ if (!File.Exists(fullPath))
+ {
+ return;
+ }
+ string normalizedPath;
+ try
+ {
+ normalizedPath = Path.GetFullPath(fullPath);
+ }
+ catch
+ {
+ return;
+ }
+ if (!IsAllowedAudioFile(normalizedPath))
+ {
+ return;
+ }
+ if (normalizedPath.IndexOf('\r') >= 0 || normalizedPath.IndexOf('\n') >= 0 || normalizedPath.IndexOf('"') >= 0)
+ {
+ return;
+ }
+
+ lock (_playLock)
+ {
+ try
+ {
+ mciSendString("close " + _playerAlias, null, 0, IntPtr.Zero);
+ int openRes = mciSendString("open \"" + normalizedPath + "\" alias " + _playerAlias, null, 0, IntPtr.Zero);
+ if (openRes == 0)
+ {
+ mciSendString("play " + _playerAlias + " from 0", null, 0, IntPtr.Zero);
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine("VoicePrompt.PlaySound error: " + ex.Message);
+ }
+ }
+ }
+
+ public static void PlayCheckpointSound(string checkpointName)
+ {
+ EnsureConfigLoaded();
+ if (string.IsNullOrWhiteSpace(checkpointName))
+ {
+ return;
+ }
+ string mapping;
+ if (_checkpointSounds.TryGetValue(checkpointName.Trim(), out mapping))
+ {
+ PlaySound(mapping);
+ return;
+ }
+
+ string autoPath = FindNamedSound(checkpointName.Trim());
+ if (autoPath != "")
+ {
+ PlaySound(autoPath);
+ }
+ }
+
+ public static void PlayFasterSound()
+ {
+ EnsureConfigLoaded();
+ PlaySound(_fasterPath);
+ }
+
+ public static void PlaySlowerSound()
+ {
+ EnsureConfigLoaded();
+ PlaySound(_slowerPath);
+ }
+
+ private static string ResolvePath(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return "";
+ }
+ string p = path.Trim().Trim('"');
+ if (Path.IsPathRooted(p))
+ {
+ return p;
+ }
+ return Path.Combine(AppDir, p);
+ }
+
+ private static string FindNamedSound(string checkpointName)
+ {
+ string[] bases = new string[]
+ {
+ Path.Combine(AppDir, "sounds"),
+ Path.Combine(AppDir, "voice")
+ };
+
+ foreach (string baseDir in bases)
+ {
+ string matched = FindNamedSoundInDir(baseDir, checkpointName);
+ if (matched != "")
+ {
+ return matched;
+ }
+ }
+ return "";
+ }
+
+ private static string FindNamedSoundInDir(string baseDir, string checkpointName)
+ {
+ if (!Directory.Exists(baseDir))
+ {
+ return "";
+ }
+ string safeName = MakeSafeFileName(checkpointName);
+ foreach (string ext in _soundExts)
+ {
+ string p1 = Path.Combine(baseDir, checkpointName + ext);
+ if (File.Exists(p1))
+ {
+ return p1;
+ }
+ if (safeName != checkpointName)
+ {
+ string p2 = Path.Combine(baseDir, safeName + ext);
+ if (File.Exists(p2))
+ {
+ return p2;
+ }
+ }
+ }
+ return "";
+ }
+
+ private static string MakeSafeFileName(string fileName)
+ {
+ string res = fileName;
+ char[] invs = Path.GetInvalidFileNameChars();
+ foreach (char c in invs)
+ {
+ res = res.Replace(c, '_');
+ }
+ return res;
+ }
+
+ private static bool IsAllowedAudioFile(string path)
+ {
+ string ext = Path.GetExtension(path);
+ foreach (string item in _soundExts)
+ {
+ if (string.Equals(ext, item, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+}
diff --git a/docs/TODO_PLAN.md b/docs/TODO_PLAN.md
new file mode 100644
index 0000000..3d7fc73
--- /dev/null
+++ b/docs/TODO_PLAN.md
@@ -0,0 +1,13 @@
+# TODO PLAN
+
+- [ ] 支持 LiveSplit 的 .lss(LiveSplit Splits)格式导入导出
+- [ ] 支持 LiveSplit Server 协议通信,使 PalTimer 可以作为 LiveSplit 的数据源
+- [ ] 实现 ASL(Auto Split Language)兼容层,复用 LiveSplit 社区已有的 Auto Splitter 脚本
+- [ ] 支持 Sum of Best Segments 计算
+- [ ] 支持 Delta 显示(对比最佳/平均的时间差)
+- [ ] 支持多种比较方式(Personal Best, Average, Median, Best Segments 等)
+- [ ] 支持 Subsplits(子节点分组)
+- [ ] 节点语音提示功能增强(图形化配置、试听、每节点独立开关与优先级)
+- [ ] 支持通过 WebSocket/HTTP 暴露实时计时数据给 OBS 等工具
+- [ ] 支持回放(Run History)统计和图表
+- [ ] 支持自定义主题/皮肤导入
diff --git a/docs/livesplit_pal98_kb.md b/docs/livesplit_pal98_kb.md
new file mode 100644
index 0000000..93385a2
--- /dev/null
+++ b/docs/livesplit_pal98_kb.md
@@ -0,0 +1,85 @@
+# LiveSplit 与仙剑98 DX9 知识库
+
+## 1) LiveSplit 简介
+
+- LiveSplit 是著名的开源速通计时器项目。
+- GitHub: https://github.com/LiveSplit/LiveSplit
+- 典型能力:分段计时(splits)、自动分段(Auto Splitter)、丰富布局组件、对外通信(如 LiveSplit Server)。
+
+## 2) LiveSplit 与 PalTimer 对比
+
+- **共同点**:都支持分段计时、PB/最佳段等速通核心场景。
+- **PalTimer 优势**:针对仙剑系列(尤其仙剑98 DX9)内核已做深度适配,已有检查点与内存读取逻辑。
+- **LiveSplit 优势**:生态成熟(ASL 脚本社区、组件体系、布局可定制、外部工具兼容广)。
+
+## 3) 通过 Auto Splitter(ASL)支持仙剑98 DX9
+
+LiveSplit 通常通过 ASL(Auto Split Language)脚本读取进程内存并驱动计时状态:
+
+- 识别目标进程(如 `PAL98.exe`/DX9 对应进程名)
+- 读取关键状态(地图、剧情标志、战斗状态、章节变量等)
+- 根据状态变化触发 `start/split/reset`
+- 可通过 `isLoading` 控制计时是否扣除读盘/载入时间
+
+## 4) ASL 脚本结构示例(简化)
+
+```asl
+state("PAL98")
+{
+ int sceneId : 0x123456;
+ int flagMainQuest : 0x234567;
+}
+
+start
+{
+ return old.flagMainQuest == 0 && current.flagMainQuest == 1;
+}
+
+split
+{
+ // 示例:场景切换到某关键节点
+ return old.sceneId != current.sceneId && current.sceneId == 2101;
+}
+
+reset
+{
+ return current.flagMainQuest == 0 && current.sceneId == 1001;
+}
+
+isLoading
+{
+ // 示例:按加载状态位判断
+ return false;
+}
+```
+
+## 5) 仙剑98 DX9 内存地址读取思路
+
+可参考 PalTimer 现有内核做法:
+
+1. 先定位目标进程与模块基址
+2. 维护稳定的“检查点判定字段”(地图ID、剧情变量、道具标志等)
+3. 用“状态边沿变化”判定节点触发(避免同一状态重复触发)
+4. 把检查点与最佳时间线关联,生成当前段差与预计完赛时间
+
+实践中建议:
+
+- 优先使用“语义稳定”的判定字段(剧情flag比瞬时动画状态更稳)
+- 对易抖动状态加去抖/二次确认
+- 地址版本变化时通过签名或版本分支管理地址表。
+
+## 6) LiveSplit Server 与 WebSocket 通信
+
+LiveSplit Server 常用于把计时状态提供给外部工具(覆盖层、OBS脚本、远端控制器等):
+
+- 可通过 TCP 文本命令协议交互(开始/分段/重置/读状态)
+- 在工程扩展里也可做 WebSocket 网关,将状态转发给网页或直播辅助层
+- PalTimer 若对齐该协议,可直接复用大量现有工具链。
+
+## 7) 自定义 Layout 与组件配置
+
+LiveSplit 的 Layout 可组合多种组件:
+
+- Timer / Splits / Delta / Previous Segment / Sum of Best / Graph 等
+- 支持字体、颜色、间距、阴影、背景等细粒度配置
+- 通过布局配置与组件组合,可以快速适配“练习版”“比赛版”“直播版”三类界面需求。