项目 7 情景控制:虚拟时钟、项目资源、Windows Media Player、TrackBar
- 虚拟时钟:实训台时钟面板可拨时间、1×/10×/60× 倍速、恢复真实时间;编译时把 DateTime.Now / Today / UtcNow 改写成 SimClock;评分规则加 clock 步骤 - 项目资源(Properties.Resources):平台素材库(自绘开关图片、合成门铃声),"项目资源"对话框勾选, 平台生成 Properties/Resources.Designer.cs;设计器图片框的"图片"属性可选资源;程序目录下的 Resources 指向素材库;未添加资源时给中文提示 - Windows Media Player(AxWMPLib):设置 URL 自动播放、Ctlcontrols.play/stop,浏览器里播放声音, 实训台加广播喇叭;编译时给窗体嵌空资源,OcxState 取到 null 不报错;图片和声音统一按内容缓存(blob) - TrackBar:拖动、点轨道、方向键,先 ValueChanged 再 Scroll;设计器加"滑动条""媒体播放器" - 评分规则加 value、media 检查;示例项目 7 任务 1–3(修正书上开关灯判断写反、漏填串口列表) - 修正:Properties/ 下的资源类不当作窗体;带命名空间的 System.AppDomain.CurrentDomain.BaseDirectory 放行 - 测试:虚拟时钟、TrackBar、项目 7 示例评分、作业起点带资源类 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a9af26c2d6
commit
eedf1aa2fb
66 files changed
+3538
-43
No files matched your search
@@ -0,0 +1,166 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Windows.Forms;
|
||||
using Sim.Host;
|
||||
|
||||
namespace System.Windows.Forms
|
||||
{
|
||||
/// <summary>
|
||||
/// ActiveX 控件的基类。VS 把 COM 控件拖到窗体上时生成 OcxState = resources.GetObject("…OcxState"),
|
||||
/// 平台编译时给每个窗体嵌了空的资源,这里取到的是 null,不影响使用。
|
||||
/// </summary>
|
||||
public abstract class AxHost : Control, ISupportInitialize
|
||||
{
|
||||
protected AxHost(string clsid) { }
|
||||
|
||||
public State? OcxState { get; set; }
|
||||
|
||||
public void BeginInit() { }
|
||||
public void EndInit() { }
|
||||
|
||||
public class State { }
|
||||
}
|
||||
}
|
||||
|
||||
namespace WMPLib
|
||||
{
|
||||
public enum WMPPlayState
|
||||
{
|
||||
wmppsUndefined = 0, wmppsStopped = 1, wmppsPaused = 2, wmppsPlaying = 3, wmppsScanForward = 4, wmppsScanReverse = 5,
|
||||
wmppsBuffering = 6, wmppsWaiting = 7, wmppsMediaEnded = 8, wmppsTransitioning = 9, wmppsReady = 10, wmppsReconnecting = 11,
|
||||
}
|
||||
|
||||
/// <summary>播放控制:axWindowsMediaPlayer1.Ctlcontrols.play()</summary>
|
||||
public interface IWMPControls
|
||||
{
|
||||
void play();
|
||||
void pause();
|
||||
void stop();
|
||||
double currentPosition { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>播放设置:axWindowsMediaPlayer1.settings.volume</summary>
|
||||
public interface IWMPSettings
|
||||
{
|
||||
bool autoStart { get; set; }
|
||||
int volume { get; set; }
|
||||
bool mute { get; set; }
|
||||
void setMode(string bstrMode, bool varfMode);
|
||||
}
|
||||
}
|
||||
|
||||
namespace AxWMPLib
|
||||
{
|
||||
using WMPLib;
|
||||
|
||||
/// <summary>
|
||||
/// Windows Media Player 控件(教材项目 7 的"广播"播放 ring.mp3)。和真的一样:设置 URL 后自动开始播放(settings.autoStart),
|
||||
/// 也可以调用 Ctlcontrols.play()。声音在浏览器里播放;实训台上的广播喇叭随之亮起。只支持本机文件。
|
||||
/// </summary>
|
||||
[ClientType("AxWindowsMediaPlayer")]
|
||||
public class AxWindowsMediaPlayer : AxHost
|
||||
{
|
||||
private string _url = "";
|
||||
private WMPPlayState _playState = WMPPlayState.wmppsUndefined;
|
||||
|
||||
public AxWindowsMediaPlayer() : base("6bf52a52-394a-11d3-b153-00c04f79faa6")
|
||||
{
|
||||
Ctlcontrols = new Controls(this);
|
||||
settings = new Settings();
|
||||
}
|
||||
|
||||
protected override Size DefaultSize => new(245, 240);
|
||||
|
||||
public string URL
|
||||
{
|
||||
get => _url;
|
||||
set
|
||||
{
|
||||
_url = value ?? "";
|
||||
if (settings.autoStart && _url.Length > 0) Play();
|
||||
}
|
||||
}
|
||||
|
||||
public IWMPControls Ctlcontrols { get; }
|
||||
|
||||
// 属性名和 COM 接口一样是小写
|
||||
public IWMPSettings settings { get; }
|
||||
|
||||
public WMPPlayState playState => _playState;
|
||||
|
||||
public string uiMode { get; set; } = "full";
|
||||
public bool stretchToFit { get; set; }
|
||||
public bool enableContextMenu { get; set; } = true;
|
||||
public string status => _playState == WMPPlayState.wmppsPlaying ? "正在播放" : "已停止";
|
||||
|
||||
public event EventHandler? PlayStateChange;
|
||||
|
||||
private void Play()
|
||||
{
|
||||
LegacyThreads.CheckPoint();
|
||||
// 教材写 BaseDirectory + "//Resources//ring.mp3":路径里多余的斜杠在 Linux 上也能用
|
||||
var path = _url.StartsWith("file:///", StringComparison.OrdinalIgnoreCase) ? new Uri(_url).LocalPath : _url;
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
SimHost.SendConsole($"Windows Media Player:找不到要播放的文件 {_url}\n");
|
||||
SetState(WMPPlayState.wmppsStopped);
|
||||
return;
|
||||
}
|
||||
var data = File.ReadAllBytes(path);
|
||||
var mime = Path.GetExtension(path).ToLowerInvariant() switch
|
||||
{
|
||||
".wav" => "audio/wav",
|
||||
".ogg" => "audio/ogg",
|
||||
".m4a" or ".aac" => "audio/aac",
|
||||
_ => "audio/mpeg",
|
||||
};
|
||||
var key = SimHost.DataKey(Convert.ToHexString(SHA1.HashData(data)).ToLowerInvariant(), data, mime);
|
||||
SimHost.Send(new JsonObject { ["type"] = "media", ["id"] = SimId, ["action"] = "play", ["key"] = key, ["name"] = Path.GetFileName(path) });
|
||||
SetState(WMPPlayState.wmppsPlaying);
|
||||
}
|
||||
|
||||
private void Stop()
|
||||
{
|
||||
if (_playState != WMPPlayState.wmppsPlaying) return;
|
||||
SimHost.Send(new JsonObject { ["type"] = "media", ["id"] = SimId, ["action"] = "stop" });
|
||||
SetState(WMPPlayState.wmppsStopped);
|
||||
}
|
||||
|
||||
private void SetState(WMPPlayState state)
|
||||
{
|
||||
if (_playState == state) return;
|
||||
_playState = state;
|
||||
PlayStateChange?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
protected override void WriteProps(JsonObject props)
|
||||
{
|
||||
base.WriteProps(props);
|
||||
props["URL"] = _url;
|
||||
}
|
||||
|
||||
private sealed class Controls(AxWindowsMediaPlayer owner) : IWMPControls
|
||||
{
|
||||
public void play()
|
||||
{
|
||||
if (owner._playState != WMPPlayState.wmppsPlaying && owner._url.Length > 0) owner.Play();
|
||||
}
|
||||
|
||||
public void pause() => owner.Stop();
|
||||
public void stop() => owner.Stop();
|
||||
public double currentPosition { get; set; }
|
||||
}
|
||||
|
||||
private sealed class Settings : IWMPSettings
|
||||
{
|
||||
public bool autoStart { get; set; } = true;
|
||||
public int volume { get; set; } = 50;
|
||||
public bool mute { get; set; }
|
||||
public void setMode(string bstrMode, bool varfMode) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ public class DateTimePicker : Control
|
||||
public static readonly DateTime MinimumDateTime = new(1753, 1, 1);
|
||||
public static readonly DateTime MaximumDateTime = new(9998, 12, 31);
|
||||
|
||||
private DateTime _value = DateTime.Now;
|
||||
private DateTime _value = SimClock.Now;
|
||||
private DateTimePickerFormat _format = DateTimePickerFormat.Long;
|
||||
private string? _customFormat;
|
||||
private bool _showUpDown;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.ComponentModel;
|
||||
using System.Drawing;
|
||||
using System.Text.Json.Nodes;
|
||||
using Sim.Host;
|
||||
|
||||
namespace System.Windows.Forms;
|
||||
|
||||
public enum TickStyle { None, TopLeft, BottomRight, Both }
|
||||
|
||||
public enum Orientation { Horizontal, Vertical }
|
||||
|
||||
/// <summary>滑动条(教材项目 7 模拟空调温度、窗帘开合)</summary>
|
||||
public class TrackBar : Control, ISupportInitialize
|
||||
{
|
||||
private int _minimum;
|
||||
private int _maximum = 10;
|
||||
private int _value;
|
||||
private int _tickFrequency = 1;
|
||||
private TickStyle _tickStyle = TickStyle.BottomRight;
|
||||
private Orientation _orientation = Orientation.Horizontal;
|
||||
private bool _initializing;
|
||||
|
||||
public TrackBar() => AutoSize = true;
|
||||
|
||||
protected override Size DefaultSize => new(104, 45);
|
||||
|
||||
public int Minimum
|
||||
{
|
||||
get => _minimum;
|
||||
set => SetRange(value, Math.Max(value, _maximum));
|
||||
}
|
||||
|
||||
public int Maximum
|
||||
{
|
||||
get => _maximum;
|
||||
set => SetRange(Math.Min(value, _minimum), value);
|
||||
}
|
||||
|
||||
public int Value
|
||||
{
|
||||
get => _value;
|
||||
set
|
||||
{
|
||||
if (!_initializing && (value < _minimum || value > _maximum))
|
||||
throw new ArgumentOutOfRangeException(nameof(value), $"“{value}”的值对于“Value”无效。“Value”应介于 'Minimum' 和 'Maximum' 之间。");
|
||||
if (SetProp(ref _value, value, nameof(Value))) OnValueChanged(EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public int TickFrequency
|
||||
{
|
||||
get => _tickFrequency;
|
||||
set => SetProp(ref _tickFrequency, value, nameof(TickFrequency));
|
||||
}
|
||||
|
||||
public TickStyle TickStyle
|
||||
{
|
||||
get => _tickStyle;
|
||||
set => SetProp(ref _tickStyle, value, nameof(TickStyle));
|
||||
}
|
||||
|
||||
public Orientation Orientation
|
||||
{
|
||||
get => _orientation;
|
||||
set
|
||||
{
|
||||
if (value == _orientation) return;
|
||||
SetProp(ref _orientation, value, nameof(Orientation));
|
||||
Size = new Size(Height, Width);
|
||||
}
|
||||
}
|
||||
|
||||
public int LargeChange { get; set; } = 5;
|
||||
public int SmallChange { get; set; } = 1;
|
||||
|
||||
public event EventHandler? Scroll;
|
||||
public event EventHandler? ValueChanged;
|
||||
|
||||
protected virtual void OnScroll(EventArgs e) => Scroll?.Invoke(this, e);
|
||||
protected virtual void OnValueChanged(EventArgs e) => ValueChanged?.Invoke(this, e);
|
||||
|
||||
public void SetRange(int minValue, int maxValue)
|
||||
{
|
||||
SetProp(ref _minimum, minValue, nameof(Minimum));
|
||||
SetProp(ref _maximum, Math.Max(minValue, maxValue), nameof(Maximum));
|
||||
if (_value < _minimum) Value = _minimum;
|
||||
else if (_value > _maximum) Value = _maximum;
|
||||
}
|
||||
|
||||
// Designer.cs 里 VS 生成 ((ISupportInitialize)trackBar1).BeginInit() / EndInit():期间先设 Value 再设范围也不报错
|
||||
public void BeginInit() => _initializing = true;
|
||||
|
||||
public void EndInit()
|
||||
{
|
||||
_initializing = false;
|
||||
if (_value < _minimum || _value > _maximum) Value = Math.Clamp(_value, _minimum, _maximum);
|
||||
}
|
||||
|
||||
/// <summary>用户拖动滑块、点轨道或按方向键:和 WinForms 一样先改 Value(ValueChanged),再触发 Scroll</summary>
|
||||
protected override void OnUserAction(UserAction action)
|
||||
{
|
||||
if (action.Action != "scroll" || !int.TryParse(action.Value, out var value))
|
||||
{
|
||||
base.OnUserAction(action);
|
||||
return;
|
||||
}
|
||||
Value = Math.Clamp(value, _minimum, _maximum);
|
||||
OnScroll(EventArgs.Empty);
|
||||
}
|
||||
|
||||
protected override void WriteProps(JsonObject props)
|
||||
{
|
||||
base.WriteProps(props);
|
||||
props["Minimum"] = _minimum;
|
||||
props["Maximum"] = _maximum;
|
||||
props["Value"] = _value;
|
||||
props["TickFrequency"] = _tickFrequency;
|
||||
props["TickStyle"] = _tickStyle.ToString();
|
||||
props["Orientation"] = _orientation.ToString();
|
||||
props["LargeChange"] = LargeChange;
|
||||
props["SmallChange"] = SmallChange;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.ComponentModel;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
|
||||
namespace Sim.Host;
|
||||
|
||||
/// <summary>
|
||||
/// 项目资源(Properties.Resources)的素材库。平台为学生工程生成的 Properties/Resources.Designer.cs 通过这里读取素材,
|
||||
/// 和 VS 一样每次取图片都得到一个新的 Bitmap。
|
||||
/// </summary>
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public static class ProjectResources
|
||||
{
|
||||
/// <summary>素材文件所在目录(运行器目录下的 Resources/gallery)</summary>
|
||||
public static string Directory { get; } = Path.Combine(Path.GetDirectoryName(typeof(ProjectResources).Assembly.Location)!, "Resources", "gallery");
|
||||
|
||||
public static Bitmap Image(string file) => (Bitmap)System.Drawing.Image.FromStream(new MemoryStream(Bytes(file)));
|
||||
|
||||
public static byte[] Bytes(string file) => File.ReadAllBytes(Path.Combine(Directory, Path.GetFileName(file)));
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Sim.Host;
|
||||
|
||||
/// <summary>
|
||||
/// 虚拟时钟(实训台上可以拨时间、调倍速;教材项目 7 按时刻切换情景模式)。
|
||||
/// 编译时把学生代码里的 DateTime.Now / Today / UtcNow 改写成这里的属性(见 Sim.Runner/ClockRewriter.cs)。
|
||||
/// 没有拨过时就是真实时间。
|
||||
/// </summary>
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public static class SimClock
|
||||
{
|
||||
private sealed record Setting(DateTime Wall, long RealAt, double Speed);
|
||||
|
||||
private static volatile Setting? _setting;
|
||||
|
||||
public static DateTime Now
|
||||
{
|
||||
get
|
||||
{
|
||||
var setting = _setting;
|
||||
if (setting == null) return DateTime.Now;
|
||||
var elapsed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - setting.RealAt;
|
||||
return setting.Wall.AddMilliseconds(elapsed * setting.Speed);
|
||||
}
|
||||
}
|
||||
|
||||
public static DateTime Today => Now.Date;
|
||||
|
||||
public static DateTime UtcNow => Now.ToUniversalTime();
|
||||
|
||||
/// <summary>平台内部使用:应用服务器推送的 {"wall":"2024-01-16T06:40:00","realAt":…,"speed":60},null 恢复真实时间</summary>
|
||||
public static void Update(JsonNode? clock)
|
||||
{
|
||||
if (clock is not JsonObject json
|
||||
|| !DateTime.TryParseExact((string?)json["wall"], "yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var wall))
|
||||
{
|
||||
_setting = null;
|
||||
return;
|
||||
}
|
||||
_setting = new Setting(DateTime.SpecifyKind(wall, DateTimeKind.Local), (long?)json["realAt"] ?? 0, (double?)json["speed"] ?? 1);
|
||||
}
|
||||
}
|
||||
@@ -86,19 +86,23 @@ public static class SimHost
|
||||
public static string AppBaseDirectory { get; set; } = AppContext.BaseDirectory;
|
||||
|
||||
/// <summary>图片在浏览器端的编号;第一次用到时先把图片数据发过去,浏览器缓存后同一张图不再重发</summary>
|
||||
public static string ImageKey(System.Drawing.Image image)
|
||||
public static string ImageKey(System.Drawing.Image image) => DataKey(image.SimKey, image.Data, image.MimeType);
|
||||
|
||||
/// <summary>
|
||||
/// 图片、声音等数据在浏览器端的编号(key 通常是内容的哈希):第一次用到时先把数据发过去,之后只发编号。
|
||||
/// </summary>
|
||||
public static string DataKey(string key, byte[] data, string mime)
|
||||
{
|
||||
var key = image.SimKey;
|
||||
bool isNew;
|
||||
lock (SentImages) isNew = SentImages.Add(key);
|
||||
if (isNew)
|
||||
{
|
||||
Send(new JsonObject
|
||||
{
|
||||
["type"] = "image",
|
||||
["type"] = "blob",
|
||||
["key"] = key,
|
||||
["mime"] = image.MimeType,
|
||||
["data"] = Convert.ToBase64String(image.Data),
|
||||
["mime"] = mime,
|
||||
["data"] = Convert.ToBase64String(data),
|
||||
});
|
||||
}
|
||||
return key;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# 项目资源素材库
|
||||
|
||||
学生在"项目资源"里添加的图片和声音(`Properties.Resources.xxx`),名字和教材一致,全部由平台自绘或合成(不使用教材配套资料里的素材)。
|
||||
|
||||
- `gallery/`:发布给运行器和网页的文件,`index.json` 是清单(名字、文件、中文说明、类型)。
|
||||
- `*.svg`:图片的源文件,改了以后用 `rsvg-convert xxx.svg -o gallery/xxx.png` 重新生成。
|
||||
- `gallery/ring.mp3`:用 ffmpeg 合成的门铃声(叮—咚),命令见提交记录。
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{ "name": "switch_on", "file": "switch_on.png", "label": "开关:开", "kind": "image" },
|
||||
{ "name": "switch_off", "file": "switch_off.png", "label": "开关:关", "kind": "image" },
|
||||
{ "name": "ring", "file": "ring.mp3", "label": "铃声", "kind": "audio" }
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="24" viewBox="0 0 48 24">
|
||||
<rect x="1" y="2" width="46" height="20" rx="10" fill="#c7cbd1" stroke="#a9aeb6" stroke-width="1"/>
|
||||
<text x="33" y="16.5" font-family="Arial, Helvetica, sans-serif" font-size="10" font-weight="bold" fill="#ffffff" text-anchor="middle">OFF</text>
|
||||
<circle cx="12" cy="12" r="8" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 390 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="24" viewBox="0 0 48 24">
|
||||
<rect x="1" y="2" width="46" height="20" rx="10" fill="#34c759" stroke="#24a148" stroke-width="1"/>
|
||||
<text x="15" y="16.5" font-family="Arial, Helvetica, sans-serif" font-size="10" font-weight="bold" fill="#ffffff" text-anchor="middle">ON</text>
|
||||
<circle cx="36" cy="12" r="8" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 389 B |
@@ -7,4 +7,8 @@
|
||||
<!-- 仿真类型的公开 API 要和真实 WinForms 一致,很多成员没有 XML 注释 -->
|
||||
<NoWarn>$(NoWarn);CS1591;CS8618</NoWarn>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- 项目资源素材库(Properties.Resources 的图片、声音),随运行器一起输出 -->
|
||||
<None Include="Resources/gallery/*" CopyToOutputDirectory="PreserveNewest" LinkBase="Resources/gallery" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in new issue
Block a user