
黑夜,总是给人以无限浪漫的遐想,那深邃的夜空充满了神秘的浪漫;黑夜,让思考更自由,更安静。如何用程序员的思维来传达爱的感觉呢?首先需要定义想要传达的预期,希望对方看到后的感觉是怎样的:黑夜中,享受静谧与浪漫,所以希望它是不喧哗的,安静的,也希望它有音乐盒一般让人心弦颤动的美妙乐声。
在表达的开头,以类似书信的方式,用文字来进行基础的阐释,比如进行如下三句来开头:
private const string TextLine1 = "你总是喜欢黑夜";
private const string TextLine2 = "因为那里藏着你的灵感";
private const string TextLine3 = "那就让这星空陪着你";
为了让展示更精美,系统本身的字体是不容易满足的,我们从字库网站上摘取到喜欢的文字,把它们以PNG图像来进行存储,也就是在软件中以图片的形式来进行文字的展示,同时尽量配以动画来进行柔和的过渡,让整体更流畅,具有一种协调的美感,比如对下面三句话进行了如下的展示:

关键代码:
cs
/// <summary>
/// 合成整句长图,再以右侧透明渐变从左向右柔和展开。
/// </summary>
public async Task RevealLineAsync(string lineFolderName, TimeSpan revealDuration)
{
Clear();
if (_loader is not { IsPrepared: true }) return;
var glyphs = _loader.LoadLineGlyphs(lineFolderName);
if (glyphs.Count == 0) return;
var cellW = _loader.CellDisplayWidth;
var cellH = _loader.CellDisplayHeight;
var textWidth = cellW * glyphs.Count;
// 右侧留白:避免渐变前沿扫过最后一个字时裁切半字
var rightPad = Math.Max(cellW, textWidth * FeatherRatio);
var composite = GlyphLineCompositor.Compose(glyphs, cellW, cellH, rightPad);
LineImage.Source = composite;
LineImage.Width = textWidth + rightPad;
LineImage.Height = cellH;
LineImage.Opacity = 1;
UpdateRevealMask(0);
await AnimateRevealAsync(revealDuration, glyphs.Count);
}
private async Task AnimateRevealAsync(TimeSpan duration, int charCount)
{
_ticksPlayed = 0;
var start = DateTime.UtcNow;
var totalMs = duration.TotalMilliseconds;
while (true)
{
var elapsed = (DateTime.UtcNow - start).TotalMilliseconds;
var t = MathUtil.Clamp(elapsed / totalMs, 0, 1);
var eased = EaseInOut(t);
UpdateRevealMask(eased);
SyncTypewriterTicks(eased, charCount);
if (t >= 1) break;
await Task.Delay(16);
}
UpdateRevealMask(1);
SyncTypewriterTicks(1.0, charCount);
}
private static double EaseInOut(double t) =>
t < 0.5 ? 2 * t * t : 1 - Math.Pow(-2 * t + 2, 2) / 2;
关键XAML:
cs
<Image x:Name="LineImage" ...>
<Image.OpacityMask>
<LinearGradientBrush x:Name="RevealMask"
StartPoint="0,0.5"
EndPoint="1,0.5"
MappingMode="RelativeToBoundingBox">
<GradientStop x:Name="StopSolid" Color="White" Offset="0" />
<GradientStop x:Name="StopSolidEnd" Color="White" Offset="0" />
<GradientStop x:Name="StopFadeEnd" Color="Transparent" Offset="0" />
<GradientStop x:Name="StopTransparent" Color="Transparent" Offset="1" />
</LinearGradientBrush>
</Image.OpacityMask>
</Image>
安静的文字展示,此时是没有配置任何的音乐播放的,展示之后,由这些话引出了星空的展示,但是星空的展示一定要配上引人无限畅想的音乐才更有感觉,所以我摘取了星空的伴奏乐,在完全黑色的背景中,以透明渐变的形式来逐渐呈现。未见其人,先听其声,因此我们先让音乐入耳,而后陆续让星星登场,使用各种不同大小的、彩色的星星进行循环的bling bling 的闪烁展示,但是这样单纯的展示会显得有些单调,我们要同时把心中所要表达的想法或一些旁白插入其中,比如加入 "我和星星陪着你",效果:

比星星更递进一层的更美的是什么呢?那无疑是天空中划过一道流星了。因此我们加入了粉红色的随机流星的展示,并且在流星划过的时候再配置上一些旁白文字,就好像在Ta耳边细语 "看!还有流星~":

关键代码:
cs
private void UpdateMeteors(double dt)
{
// ...
if (_spawnCooldown <= 0 && _meteors.Count < _maxConcurrent)
{
TrySpawnMeteor(w, h);
_spawnCooldown = _spawnIntervalMin
+ _rng.NextDouble() * (_spawnIntervalMax - _spawnIntervalMin);
}
// 更新 HeadX/HeadY,飞出屏外则删除
}
protected override void OnRender(DrawingContext dc)
{
var layer = MathUtil.Clamp(LayerOpacity, 0, 1);
foreach (var m in _meteors)
{
var tail = new Point(m.HeadX - m.DirX * m.Length, m.HeadY - m.DirY * m.Length);
var head = new Point(m.HeadX, m.HeadY);
DrawHazyStreak(dc, tail, head, m.Thickness, layer);
}
}
两个基础的展示结束后,怎样更递进一层呢?爱的表达肯定少不了心形。那么顺着导引,把星空中的星星,以一种动态的形式聚合到一个心形中展示。我们用一句旁白来进行导引,给Ta一种惊喜感, "我要给你变个魔术", 为了让文字导引变得有趣,我们采用了一些比较俏皮的动画, 显示动画和消失动画呈现对称性,即以反向动画的方式来进行消失:

关键代码:
cs
private void PrepareHeartFormation(double w, double h)
{
// ...
var targets = HeartShapePoints.Generate(targetCount, rng, heartCx, heartCy, HeartLayout.BaseScale);
// 记录当前位置为 MorphStart,按角度排序后一一对应 Target
for (var i = 0; i < sortedMovers.Count; i++)
{
var s = sortedMovers[i];
s.TargetX = sortedTargets[i].X;
s.TargetY = sortedTargets[i].Y;
s.MorphDelay = ...; // 错开每颗星起飞时间
}
// 星不够则 CreateHeartFillStar 从中心补新星
}
public async Task AnimateHeartFormationAsync(TimeSpan duration)
{
PrepareHeartFormation(ActualWidth, ActualHeight);
_heartPhase = HeartPhase.Forming;
while (true)
{
HeartMorphProgress = SmoothStep(t); // 0→1,约 60fps
InvalidateVisual();
}
_heartPhase = HeartPhase.Formed;
FinalizeHeartLock(); // 锁死在 Target 位置
}
在文字导引结束之后,就立即让所有的星星以缓动函数动画的方式汇集到目标心形的区域内,进行聚集,形成了一个稍微接近心形的形状,但是我们要让这个形状更加的明显,因此添加了红色的心型背景展示,同时让整个夜空消失 。
整个展示基本就结束了,最后道一句晚安 ,将晚安的文字显示出来,并且以一种闭幕的方式在心型粉红泡泡中浪漫地消失,同时也停止音乐播放,整个进程结束 :

关键代码:
cs
foreach (var heart in drawOrder)
{
var life = ((_time + heart.PhaseOffset) % heart.CycleSec) / heart.CycleSec;
var fade = HeartFadeEnvelope(life);
if (fade <= 0.015) continue;
var yDrift = (heart.NormY * zoneH * 0.48 - _time * heart.RiseSpeed) % (zoneH + 60);
var xWobble = Math.Sin(_time * heart.DriftSpeed + heart.PhaseOffset) * heart.WobbleAmp;
// 随机位置、大小、样式 → DrawStyledHeart 画心形
}
private static double HeartFadeEnvelope(double life)
{
if (life < 0.18) return life / 0.18; // 淡入
if (life < 0.62) return 1.0;
return Math.Max(0, (1.0 - life) / 0.38); // 淡出
}
完整效果低帧演示:

完整的项目源码(WPF-.NetFramework 4.6.2):https://download.csdn.net/download/LateFrames/92887201?spm=1001.2014.3001.5501
完整的演示程序: https://download.csdn.net/download/LateFrames/92887179?spm=1001.2014.3001.5501
注意:
对于输出程序的格式,我进行了一个比较严格的限定,希望给到Ta之后,Ta只看到一个文件,并且点开之后就可以直接看到效果。依赖的图片资源、音频文件 都以嵌入的方式打包到了单一程序集中,并且在程序运行时将资源存储到当前用户系统的临时目录中,因此保持了当前执行目录下很干净 ,所以我们只看到一个文件 "晚安.exe",就能看到以上所有的效果,整体的效果是比较符合预期的,基本比较准确地传达的内心的情感表达。