前言
这是某大学的《概率论与数理统计》课程里出现的一个问题,挺有意思的也挺适合开发一个小程序的,所以就拿过来了。
这个题的题目背景是数学,但是本身也挺适合作为一个课程设计项目,所以就拿过来举例,顺带说明如何只用 PyGame 这种看似过时的框架设计出令人惊艳(bushi)的小程序。
想要源码的,请直接跳转到这里👇
🔗https://gitcode.com/weixin_52027970/Walker-v1.6
1. 原题回顾
某质点在
x
y
xy
xy 平面内随机移动。
这个质点每分钟会移动
k
k
k 次,每次会朝着
θ
\\theta
θ 角度移动
L
L
L 的步长,其中
θ
\\theta
θ 服从
[
0
,
2
π
)
[0,2\\pi)
[0,2π) 的均匀分布,
L
L
L 服从
[
l
−
ϵ
,
l
+
ϵ
]
[l-\\epsilon,l+\\epsilon]
[l−ϵ,l+ϵ] 的均匀分布。每次移动的方向和距离全部独立。
(1)请求出移动了
n
n
n 次后,质点到原点距离的方均根位移(即:位移的平方的数学期望的算术平方根); (2)若
l
=
0.86
l=0.86
l=0.86,
ϵ
=
0.06
\\epsilon = 0.06
ϵ=0.06,
k
=
128
k=128
k=128,求出
1
1
1 个小时后,第 1 问答案的具体数值。 (3)请尝试选用一种合理的办法,估计出位移的数学期望以及标准差。 (4)(选做)若现在加入马尔可夫性质,每一步移动的角度只能在上一步移动的角度
±
15
°
±15°
±15° 的范围内波动,服从均匀分布,情况又会如何呢?
2. 怎么做这道题
作为一个数学题首先肯定还是要进行理论分析的。如果作为读者的你只是想快速实现代码拿到程序,请直接翻到前言部分点击那个链接或者跳转到第 3 节。
2.1 第一问
设每一步的长度为
L
i
L_i
Li,移动的角度为
θ
i
\\theta_i
θi,
R
n
→
=
(
X
n
,
Y
n
)
=
∑
i
=
1
n
(
L
i
cos
θ
i
,
L
i
sin
θ
i
)
\\overrightarrow{R_n}=(X_n,Y_n) = \\displaystyle \\sum_{i=1}^n (L_i\\cos\\theta_i, L_i\\sin \\theta_i)
Rn
=(Xn,Yn)=i=1∑n(Licosθi,Lisinθi)
距离的平方为:
∣
R
n
→
∣
2
=
(
L
1
cos
θ
1
+
L
2
cos
θ
2
+
⋯
+
L
k
cos
θ
k
)
2
+
(
L
1
sin
θ
1
+
L
2
sin
θ
2
+
⋯
+
L
k
sin
θ
k
)
2
|\\overrightarrow{R_n}|^2 = (L_1\\cos\\theta_1 + L_2\\cos\\theta_2 + \\cdots + L_k\\cos\\theta_k)^2 + (L_1\\sin\\theta_1 + L_2\\sin\\theta_2 + \\cdots + L_k\\sin\\theta_k)^2
∣Rn
∣2=(L1cosθ1+L2cosθ2+⋯+Lkcosθk)2+(L1sinθ1+L2sinθ2+⋯+Lksinθk)2
则
∣
R
n
→
∣
2
=
L
1
2
+
L
2
2
+
⋯
+
L
k
2
+
2
∑
i
<
j
L
i
L
j
cos
θ
i
cos
θ
j
+
2
∑
i
<
j
L
i
L
j
sin
θ
i
sin
θ
j
=
∑
i
=
1
n
L
i
2
+
2
∑
1
≤
i
<
j
≤
n
cos
(
θ
i
−
θ
j
)
\\begin{aligned} |\\overrightarrow{R_n}|^2 &= L^2_1 + L^2_2 + \\cdots + L^2_k + 2\\sum_{i < j} L_i L_j \\cos\\theta_i\\cos\\theta_j + 2\\sum_{i < j} L_i L_j \\sin\\theta_i\\sin\\theta_j \\\\ &=\\sum_{i=1}^n L_i^2 + 2 \\sum_{1\\le i < j \\le n} \\cos(\\theta_i – \\theta_j) \\end{aligned}
∣Rn
∣2=L12+L22+⋯+Lk2+2i<j∑LiLjcosθicosθj+2i<j∑LiLjsinθisinθj=i=1∑nLi2+21≤i<j≤n∑cos(θi−θj)
由于所有的
θ
\\theta
θ 全部服从
[
0
,
2
π
)
[0,2\\pi)
[0,2π) 的均匀分布,因此
cos
θ
\\cos\\theta
cosθ 和
sin
θ
\\sin\\theta
sinθ 的数学期望都是 0,所以所有的交叉项皆为 0,那么所求的数学期望就是
L
i
2
L_i^2
Li2 之和的期望。由于全部独立,因此
E
(
L
)
=
l
E(L) = l
E(L)=l
E
(
L
2
)
=
∫
l
−
ϵ
l
+
ϵ
x
2
1
2
ϵ
d
x
=
(
l
+
ϵ
)
3
−
(
l
−
ϵ
)
3
6
ϵ
=
l
2
+
ϵ
2
/
3
E(L^2) = \\int_{l-\\epsilon}^{l+\\epsilon} x^2 \\frac{1}{2\\epsilon} dx = \\dfrac{(l+\\epsilon)^3 – (l-\\epsilon)^3}{6\\epsilon} = l^2 + \\epsilon^2 / 3
E(L2)=∫l−ϵl+ϵx22ϵ1dx=6ϵ(l+ϵ)3−(l−ϵ)3=l2+ϵ2/3
从而得到
E
(
∣
R
n
→
∣
2
)
=
n
E
(
L
2
)
=
n
(
l
2
+
ϵ
2
/
3
)
E(|\\overrightarrow{R_n}|^2) = n E(L^2) = n(l^2 + \\epsilon^2 / 3)
E(∣Rn
∣2)=nE(L2)=n(l2+ϵ2/3)
RMS
(
∣
R
n
→
∣
)
=
n
(
l
2
+
ϵ
2
/
3
)
\\text{RMS}(|\\overrightarrow{R_n}|) =\\boxed{ \\sqrt{n(l^2+\\epsilon^2/3)}}
RMS(∣Rn
∣)=n(l2+ϵ2/3)
即为第一问的答案。
2.2 第二问
然后在每分钟
128
128
128 步的机制下,一个小时会移动
7680
7680
7680 次。把
n
=
7680
n=7680
n=7680,
l
=
0.86
l=0.86
l=0.86,
ϵ
=
0.06
\\epsilon=0.06
ϵ=0.06 代入进去就能求出
E
(
∣
R
n
→
∣
2
)
=
7
,
680
⋅
(
0.86
2
+
0.06
2
/
3
)
=
5689.344
E(|\\overrightarrow{R_n}|^2) = 7,680 \\cdot (0.86^2+0.06^2/3)= 5689.344
E(∣Rn
∣2)=7,680⋅(0.862+0.062/3)=5689.344
E
(
∣
R
n
→
∣
2
)
=
RMS
(
∣
R
n
→
∣
)
=
75.428
\\sqrt{E(|\\overrightarrow{R_n}|^2)} = \\text{RMS}(|\\overrightarrow{R_n}|) = \\boxed{\\mathbf{75.428}}
E(∣Rn
∣2)
=RMS(∣Rn
∣)=75.428
就是第二问的答案了。
2.3 第三问
至于说第三问:
- 不少人认为整个模型辐射对称所以位移的期望为
0
0
0; - 也有人认为,上面的方均根位移就是期望。
事实上这两种思路都不对,由于方差等于平方的期望减去期望的平方,即
D
(
s
)
=
E
(
∣
R
n
→
∣
2
)
−
(
E
(
∣
R
n
→
∣
)
)
2
D(s) = E(|\\overrightarrow{R_n}|^2) – (E(|\\overrightarrow{R_n}|))^2
D(s)=E(∣Rn
∣2)−(E(∣Rn
∣))2
而本题模型的方差和位移的期望不可能为
0
0
0,因此位移的期望一定是会小于其方均根的。事实上所有角度都独立的情况下,总位移
s
=
(
∑
i
=
1
n
L
i
2
+
2
∑
1
≤
i
<
j
≤
n
cos
(
θ
i
−
θ
j
)
)
1
/
2
s = \\displaystyle \\Big(\\sum_{i=1}^n L_i^2 + 2 \\sum_{1\\le i < j \\le n} \\cos(\\theta_i – \\theta_j)\\Big)^{1/2}
s=(i=1∑nLi2+21≤i<j≤n∑cos(θi−θj))1/2,对每个
θ
i
\\theta_i
θi 和
L
i
L_i
Li 进行积分会产生没有闭式解的椭圆积分,从
n
≥
2
n\\ge 2
n≥2 开始就是如此了。
一种比较合理的估算方法是利用瑞利分布来估计。具体说来,当
n
n
n 较大时,由中心极限定理,若移动
n
n
n 步后的位移矢量
R
n
→
=
(
X
n
,
Y
n
)
=
∑
i
=
1
n
(
L
i
cos
θ
i
,
L
i
sin
θ
i
)
\\overrightarrow{R_n}=(X_n,Y_n) = \\displaystyle \\sum_{i=1}^n (L_i\\cos\\theta_i, L_i\\sin \\theta_i)
Rn
=(Xn,Yn)=i=1∑n(Licosθi,Lisinθi),则
X
n
X_n
Xn 和
Y
n
Y_n
Yn 近似独立正态分布:
E
(
X
n
)
=
E
(
Y
n
)
=
0
E(X_n) = E(Y_n) = 0
E(Xn)=E(Yn)=0
D
(
X
n
)
=
∑
i
=
1
n
E
[
L
i
2
cos
2
θ
i
]
=
n
⋅
E
(
L
2
)
⋅
E
(
cos
2
θ
)
=
n
2
(
l
2
+
ϵ
2
3
)
≡
σ
2
D(X_n) = \\sum_{i=1}^n E[L_i^2 \\cos^2\\theta_i] = n \\cdot E(L^2) \\cdot E(\\cos^2\\theta) = \\frac{n}{2}\\left(l^2 + \\frac{\\epsilon^2}{3}\\right) \\equiv \\sigma^2
D(Xn)=i=1∑nE[Li2cos2θi]=n⋅E(L2)⋅E(cos2θ)=2n(l2+3ϵ2)≡σ2
同理
D
(
Y
n
)
=
σ
2
D(Y_n) = \\sigma^2
D(Yn)=σ2,
Cov
(
X
n
,
Y
n
)
=
0
\\text{Cov}(X_n, Y_n) = 0
Cov(Xn,Yn)=0。
再利用瑞利分布的近似公式
E
(
∣
R
n
→
∣
)
≈
σ
π
2
=
1
2
n
π
(
l
2
+
ϵ
2
3
)
E(|\\overrightarrow{R_n}|)\\approx \\sigma\\sqrt{\\frac{\\pi}{2}} = \\frac{1}{2}\\sqrt{n\\pi\\left(l^2 + \\frac{\\epsilon^2}{3}\\right)}
E(∣Rn
∣)≈σ2π
=21nπ(l2+3ϵ2)
D
(
∣
R
n
→
∣
)
≈
4
−
π
2
σ
2
=
(
4
−
π
)
n
4
(
l
2
+
ϵ
2
3
)
D(|\\overrightarrow{R_n}|) \\approx \\frac{4-\\pi}{2}\\sigma^2 = \\frac{(4-\\pi)n}{4}\\left(l^2 + \\frac{\\epsilon^2}{3}\\right)
D(∣Rn
∣)≈24−πσ2=4(4−π)n(l2+3ϵ2)
并把
n
=
7680
n=7680
n=7680,
l
=
0.86
l=0.86
l=0.86,
ϵ
=
0.06
\\epsilon=0.06
ϵ=0.06 代入,能得到
σ
2
=
7680
2
×
0.7408
=
2844.672
\\sigma^2 = \\frac{7680}{2} \\times 0.7408 = 2844.672
σ2=27680×0.7408=2844.672
D
(
∣
R
n
→
∣
)
≈
4
−
π
2
×
2844.672
≈
0.4292
×
2844.672
≈
1220.94
D(|\\overrightarrow{R_n}|) \\approx \\frac{4-\\pi}{2} \\times 2844.672 \\approx 0.4292 \\times 2844.672 \\approx 1220.94
D(∣Rn
∣)≈24−π×2844.672≈0.4292×2844.672≈1220.94
因此
E
(
∣
R
n
→
∣
)
≈
2844.672
×
π
2
≈
66.85
E(|\\overrightarrow{R_n}|) \\approx \\sqrt{2844.672 \\times \\frac{\\pi}{2}} \\approx \\boxed{\\mathbf{66.85}}
E(∣Rn
∣)≈2844.672×2π
≈66.85
Std
(
∣
R
n
→
∣
)
≈
1220.94
≈
34.94
\\text{Std}(|\\overrightarrow{R_n}|) \\approx \\sqrt{1220.94} \\approx \\boxed{\\mathbf{34.94}}
Std(∣Rn
∣)≈1220.94
≈34.94
那如果不懂瑞利分布怎么办呢?
2.3.1 蒙特卡洛法 & 模拟
那当然就是直接上蒙特卡洛法了,虽然简单粗暴,但是总归能得到较为合理的结果。
import random
import matplotlib.pyplot as plt
import numpy as np
import math
import tqdm
n = 7680
l = 0.86
epsilon = 0.06
k = 128
seed = 44
random.seed(seed)
class Traveller:
def __init__(self, x = 0, y = 0):
self.x = x
self.y = y
def move(self):
angle = random.uniform(0, 2 * math.pi)
step = random.uniform(l – epsilon, l + epsilon)
self.x += step * math.cos(angle)
self.y += step * math.sin(angle)
def distance(self):
return math.sqrt(self.x ** 2 + self.y ** 2)
def reset(self):
self.x = 0
self.y = 0
traveller = Traveller()
results = []
bar = tqdm.tqdm(range(10000), desc = "calculating", bar_format = "{l_bar}{bar:50}{r_bar}", colour = "cyan")
for i in bar:
traveller.reset()
for _ in range(n):
traveller.move()
results.append(traveller.distance())
print("Mean: ", np.mean(results))
print("Std. Deviation: ", np.std(results))
print("RMS: ", np.sqrt(np.mean(np.square(results))))
plt.hist(results, bins = 50)
plt.show()
输出:
calculating: 100%|██████████████████████████████| 10000/10000 [01:52<00:00, 88.57it/s]
Mean: 66.6142049340539
Std. Deviation: 34.95911293488749
RMS: 75.23025904641263
和前面理论分析的结果存在一定误差,但总体是能对的上的。更有意思的是,如果把每次的总位移存起来,会发现总体的结果出现了明显的右偏。 
2.3.2 小趣事
做完第三问,再看看那道题里面
128
128
128 步/分钟的步频以及
0.86
0.86
0.86 米左右的步长,你有没有感觉这个数据来的很接地气?
-
128
128
128 的步频非常接近一个人正常步行时的状态,此外128
128
128 还是许多电子音乐的每分钟拍数(BPM); -
86
86
86 厘米的步长也比较契合一个普通人走路的步长(通常在70
∼
90
70\\sim 90
70∼90 厘米左右); - 走路速度
6.6
6.6
6.6 公里/小时(=
128
×
60
×
0.86
=128\\times 60\\times 0.86
=128×60×0.86)对于生活节奏快的大城市而言再正常不过了。
如此一来我们很容易脑补出这样一个非常带有烟火气的场景:
一个戴着耳机的年轻人,在城市闲逛。
然后你发现,足足一个小时过去了,却只产生了
67
67
67 米的净位移,属实让人大跌眼镜。
为何会这样?
其实原因非常简单,这个模型里经常会出现,上一步走完下一步紧接着就
180
°
180°
180° 转弯掉头的,这种明显不符合人体工学的行为模式。如此一来很多的位移都“抵消”了。更深入了说,当角度完全随机时,走
n
n
n 步的位移是
O
(
n
)
O(\\sqrt{n})
O(n
) 的阶的,因此位移的累积会很慢。
而至于说如何让这个模型合理化,第四问就派上用场了。
2.4 第四问
加入马尔可夫性质后,角度序列
{
θ
i
}
\\{\\theta_i\\}
{θi} 成为一个持久随机游走(Persistent Random Walk):
θ
i
=
θ
i
−
1
+
Δ
θ
i
,
Δ
θ
i
∼
U
[
−
α
,
α
]
,
α
=
15
°
=
π
12
\\theta_i = \\theta_{i-1} + \\Delta\\theta_i, \\quad \\Delta\\theta_i \\sim U[-\\alpha,\\, \\alpha], \\quad \\alpha = 15° = \\frac{\\pi}{12}
θi=θi−1+Δθi,Δθi∼U[−α,α],α=15°=12π
θ
1
∼
U
[
0
,
2
π
)
\\theta_1 \\sim U[0, 2\\pi)
θ1∼U[0,2π),所有
Δ
θ
i
\\Delta\\theta_i
Δθi 和
L
i
L_i
Li 相互独立。
核心变化在于:相邻步的方向高度相关,质点倾向于沿同一方向持续前进多步后才逐渐转向。这将导致位移显著增大。
设
i
>
j
i > j
i>j,令
k
=
i
−
j
k = i – j
k=i−j,则
θ
i
−
θ
j
=
∑
m
=
1
k
Δ
θ
m
\\theta_i – \\theta_j = \\sum_{m=1}^{k} \\Delta\\theta_m
θi−θj=∑m=1kΔθm。利用特征函数:
E
[
e
i
(
θ
i
−
θ
j
)
]
=
∏
m
=
1
k
E
[
e
i
Δ
θ
m
]
=
(
1
2
α
∫
−
α
α
e
i
t
d
t
)
k
=
(
sin
α
α
)
k
E\\!\\left[e^{i(\\theta_i – \\theta_j)}\\right] = \\prod_{m=1}^{k} E\\!\\left[e^{i\\Delta\\theta_m}\\right] = \\left(\\frac{1}{2\\alpha}\\int_{-\\alpha}^{\\alpha} e^{it}\\,dt\\right)^k = \\left(\\frac{\\sin\\alpha}{\\alpha}\\right)^k
E[ei(θi−θj)]=m=1∏kE[eiΔθm]=(2α1∫−ααeitdt)k=(αsinα)k
由于
Δ
θ
\\Delta\\theta
Δθ 关于
0
0
0 对称,虚部为零,因此:
E
[
cos
(
θ
i
−
θ
j
)
]
=
c
∣
i
−
j
∣
,
c
≡
sin
α
α
\\boxed{E[\\cos(\\theta_i – \\theta_j)] = c^{|i-j|}, \\quad c \\equiv \\frac{\\sin\\alpha}{\\alpha}}
E[cos(θi−θj)]=c∣i−j∣,c≡αsinα
于是我们可以发现
E
(
∣
R
n
→
∣
2
)
=
∑
i
=
1
n
∑
j
=
1
n
E
(
L
i
L
j
)
E
[
cos
(
θ
i
−
θ
j
)
]
E(|\\overrightarrow{R_n}|^2) = \\sum_{i=1}^n \\sum_{j=1}^n E(L_i L_j)\\, E[\\cos(\\theta_i – \\theta_j)]
E(∣Rn
∣2)=i=1∑nj=1∑nE(LiLj)E[cos(θi−θj)]
则有
E
(
∣
R
n
→
∣
2
)
=
E
(
L
i
)
E
(
L
j
)
⋅
c
∣
i
−
j
∣
=
{
l
2
+
ϵ
2
3
,
i
=
j
l
2
c
∣
i
−
j
∣
,
i
≠
j
E(|\\overrightarrow{R_n}|^2) = E(L_i)\\,E(L_j) \\cdot c^{|i-j|} = \\begin{cases} l^2 + \\dfrac{\\epsilon^2}{3} &, i = j \\\\ l^2\\, c^{|i-j|} &, i \\ne j \\end{cases}
E(∣Rn
∣2)=E(Li)E(Lj)⋅c∣i−j∣=⎩
⎨
⎧l2+3ϵ2l2c∣i−j∣,i=j,i=j
令
S
=
∑
i
=
1
n
∑
j
=
1
n
c
∣
i
−
j
∣
S = \\displaystyle \\sum_{i=1}^n \\sum_{j=1}^n c^{|i-j|}
S=i=1∑nj=1∑nc∣i−j∣,则:
E
(
∣
R
n
→
∣
2
)
=
n
(
l
2
+
ϵ
2
3
)
+
l
2
(
S
−
n
)
=
n
ϵ
2
3
+
l
2
S
E(|\\overrightarrow{R_n}|^2) = n\\left(l^2 + \\frac{\\epsilon^2}{3}\\right) + l^2(S – n) = \\frac{n\\epsilon^2}{3} + l^2 S
E(∣Rn
∣2)=n(l2+3ϵ2)+l2(S−n)=3nϵ2+l2S
对
S
S
S 求和(利用等比数列求和公式):
S
=
n
+
2
∑
k
=
1
n
−
1
(
n
−
k
)
c
k
=
n
1
+
c
1
−
c
−
2
c
(
1
−
c
n
)
(
1
−
c
)
2
S = n + 2\\sum_{k=1}^{n-1}(n-k)c^k = n\\,\\frac{1+c}{1-c} – \\frac{2c(1-c^n)}{(1-c)^2}
S=n+2k=1∑n−1(n−k)ck=n1−c1+c−(1−c)22c(1−cn)
于是能得到
E
(
∣
R
n
→
∣
2
)
=
n
(
ϵ
2
3
+
l
2
1
+
c
1
−
c
)
−
2
l
2
c
(
1
−
c
n
)
(
1
−
c
)
2
E(|\\overrightarrow{R_n}|^2)= \\boxed{n\\left(\\frac{\\epsilon^2}{3} + l^2\\frac{1+c}{1-c}\\right) – \\frac{2l^2 c\\,(1 – c^n)}{(1-c)^2}}
E(∣Rn
∣2)=n(3ϵ2+l21−c1+c)−(1−c)22l2c(1−cn)
代入
n
=
7680
l
=
0.86
α
=
π
/
12
c
=
sin
α
α
≈
0.98862
n = 7680 \\quad l = 0.86 \\quad \\alpha = \\pi / 12 \\quad c = \\dfrac{\\sin \\alpha}{\\alpha} \\approx 0.98862
n=7680l=0.86α=π/12c=αsinα≈0.98862
可以求得
E
(
∣
R
n
→
∣
2
)
≈
990.47
E(|\\overrightarrow{R_n}|^2) \\approx \\boxed{\\mathbf{990.47}}
E(∣Rn
∣2)≈990.47。
然后接着利用瑞利分布的近似公式
E
(
∣
R
n
→
∣
)
≈
σ
π
2
=
π
4
E
(
∣
R
n
→
∣
2
)
E(|\\overrightarrow{R_n}|) \\approx \\sigma\\sqrt{\\frac{\\pi}{2}} = \\sqrt{\\frac{\\pi}{4}\\,E(|\\overrightarrow{R_n}|^2)}
E(∣Rn
∣)≈σ2π
=4πE(∣Rn
∣2)
D
(
∣
R
n
→
∣
)
≈
4
−
π
2
σ
2
=
4
−
π
4
E
(
∣
R
n
→
∣
2
)
D(|\\overrightarrow{R_n}|) \\approx \\frac{4-\\pi}{2}\\,\\sigma^2 = \\frac{4-\\pi}{4}\\,E(|\\overrightarrow{R_n}|^2)
D(∣Rn
∣)≈24−πσ2=44−πE(∣Rn
∣2)
Std
(
∣
R
n
→
∣
)
≈
σ
⋅
4
−
π
2
\\text{Std}(|\\overrightarrow{R_n}|) \\approx \\sigma\\cdot \\sqrt{\\frac{4-\\pi}{2}}
Std(∣Rn
∣)≈σ⋅24−π
可以求得
E
(
∣
R
n
→
∣
)
≈
877.78
E(|\\overrightarrow{R_n}|) \\approx \\boxed{\\mathbf{877.78}}
E(∣Rn
∣)≈877.78
D
(
∣
R
n
→
∣
)
≈
210530
D(|\\overrightarrow{R_n}|) \\approx \\boxed{\\mathbf{210530}}
D(∣Rn
∣)≈210530
Std
(
∣
R
n
→
∣
)
≈
458.84
\\text{Std}(|\\overrightarrow{R_n}|) \\approx \\boxed{\\mathbf{458.84}}
Std(∣Rn
∣)≈458.84
这也就意味着,如果每次的移动角度只能再上一次的基础上变化不超过
15
°
15°
15°,则最终能够产生约
0.88
0.88
0.88 公里的净位移。
和朋友出去逛街,如果沿着一个区域绕了一个大半圈,一个小时产生
0.88
0.88
0.88 公里的净位移已经挺合理了。
3. pygame 部分
除了用于计算以外,你甚至可以用 PyGame 写一个能够模拟整个过程的,观赏性和教育性兼具的小程序。
核心思路还是模拟。
3.1 难点和要点
从实现过程上看,要点主要包括以下几个方面:
- 绘制历史轨迹以及渐变色隐去较早的轨迹;
- 画面追踪与缩放(这也是绝大多数地图软件的基本要素);
- 调参面板以及 UI 逻辑(按钮、输入框等),包括角度、步长、方差等参数;
- 音效、HUD(非必须);
- 利用 PyInstaller 生成可执行文件;
-
⋯
\\cdots
⋯
3.2 完整代码
🔗项目连接:https://gitcode.com/weixin_52027970/Walker-v1.6
字体的话是用的 Sans 系列字体(应该是契合大多数人审美观的)。
音效文件和图标的图片素材来源于网络,侵删。
import pygame
import numpy as np
import sys
import os
from collections import deque
import math
import ctypes
# Resource helpers
def resource_path(relative_path):
try:
base_path = sys._MEIPASS
except Exception:
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)
# icon
def load_icon(icon = "./logo.ico"):
try:
icon_path = resource_path(icon)
return pygame.image.load(icon_path)
except:
return None
# i18n
class I18n:
def __init__(self, language = "zh-cn"):
self.language = language
self.texts = {}
self.load_language(language = language)
def load_language(self, language = "zh-cn") –> dict[str, str]:
try:
texts = {}
language_path = resource_path(f"./i18n.{language}.txt")
if os.path.exists(language_path):
with open(language_path, "r", encoding = "utf-8") as f:
lines = f.readlines()
for ln in lines:
ln = ln.split("=")
# print(len(ln))
ln[1] = ln[1].strip()
if ln[1] != "":
texts[ln[0]] = ln[1]
self.texts = texts
except Exception as e:
print(e)
pass
language_select = ["en-us", "zh-cn", "zh-tw", "ja-jp"] # coming soon
i18ns = {item: I18n(language = item) for item in language_select}
current_language = 0
i18n = i18ns[language_select[current_language]]
# fonts
def load_font(font = "./Sen-Regular.otf", size = 20):
try:
font_path = resource_path(font)
return pygame.font.Font(font_path, size = size)
except:
return pygame.font.SysFont("Segoe UI Emoji", size)
# ——————————–
# 计时器
# ——————————–
class Stopwatch:
def __init__(self):
self.reset()
def start(self):
"""启动秒表"""
if not self.running:
self.running = True
if self.start_time == 0: # 第一次启动
self.start_time = pygame.time.get_ticks()
else: # 从暂停恢复
pause_duration = pygame.time.get_ticks() – self.pause_time
self.start_time += pause_duration
def pause(self):
"""暂停秒表"""
if self.running:
self.running = False
self.pause_time = pygame.time.get_ticks()
def stop(self):
"""停止秒表(同暂停)"""
self.pause()
def reset(self):
"""重置秒表"""
self.running = False
self.start_time = 0
self.pause_time = 0
self.elapsed_before_pause = 0
# 将毫秒数格式化为 天数:小时:分钟:秒钟.毫秒
def format_ticks(ticks):
milliseconds = ticks % 1000
total_seconds = ticks // 1000
seconds = total_seconds % 60
total_minutes = total_seconds // 60
minutes = total_minutes % 60
total_hours = total_minutes // 60
hours = total_hours % 24
days = total_hours // 24
return f"{days:02d}:{hours:02d}:{minutes:02d}:{seconds:02d}"
def get_elapsed_time(self):
"""获取经过的时间(毫秒)"""
if not self.running:
if self.start_time == 0: # 未启动过
return 0
else: # 暂停状态
return self.pause_time – self.start_time
else: # 运行中
return pygame.time.get_ticks() – self.start_time
def get_formatted_time(self):
"""获取格式化时间 00:00:00.000"""
elapsed_ms = self.get_elapsed_time()
return Stopwatch.format_ticks(elapsed_ms)
timer = Stopwatch()
# ————————————————————————————————
# 参数配置
# ————————————————————————————————
L_MEAN = 0.86 # 米/步
EPSILON = 0.06 # 步长波动 ±0.06 m
DELTA = math.pi / 12 # 最大转向角
TEMPO = 128
MAX_POINTS = 128 # 显示最近轨迹
SCALE = 50.0 # 初始缩放:1 米 = 50 像素
AIR_WALL_X = None
AIR_WALL_Y = None
BUILDING_L = 5.0
STREET_D = 3.0
TURN_ANGLE = None
sound_on = True
params = {
"L_MEAN": L_MEAN,
"EPSILON": EPSILON,
"DELTA": DELTA,
"TEMPO": TEMPO,
"MAX_POINTS": MAX_POINTS,
"BUILDING_L": BUILDING_L,
"STREET_D": STREET_D,
"AIR_WALL_X": AIR_WALL_X,
"AIR_WALL_Y": AIR_WALL_Y,
"TURN_ANGLE": TURN_ANGLE
}
default = {
"L_MEAN": 0.86,
"EPSILON": 0.06,
"DELTA": math.pi / 12,
"TEMPO": 128,
"MAX_POINTS": 128,
"BUILDING_L": 5.00,
"STREET_D": 3.00,
"TURN_ANGLE": None
}
FPS = 60
period = 60000 / params["TEMPO"] # ms
# ——————————–
# sound fx
# ——————————–
# sound fx
pygame.mixer.init()
sound_kick = pygame.mixer.Sound(resource_path("./kick.wav"))
sound_clap = pygame.mixer.Sound(resource_path("./clap.wav"))
sound_hihat = pygame.mixer.Sound(resource_path("./hi-hat.wav"))
sound_snare = pygame.mixer.Sound(resource_path("./snare.wav"))
sound_cymbal = pygame.mixer.Sound(resource_path("./cymbal.wav"))
sound_kick.set_volume(0.4)
sound_clap.set_volume(0.35)
sound_hihat.set_volume(0.25)
sound_snare.set_volume(0.45)
sound_cymbal.set_volume(0.95)
SOUNDS = {
"kick": sound_kick,
"snare": sound_snare,
"hihat": sound_hihat,
"cowbell": sound_clap,
"cymbal": sound_cymbal,
}
sound_volumes = {
"global": 1.0,
"kick": 0.4,
"snare": 0.45,
"hihat": 0.25,
"cymbal": 0.95,
"cowbell": 0.35
}
# ————————————————————————————————
# 文本输入框
# ————————————————————————————————
class TextInput:
def __init__(self, x, y, w, h, text = ""):
self.rect = pygame.Rect(x, y, w, h)
self.text = str(text)
self.active = False
def handle_event(self, event):
if event.type == pygame.MOUSEBUTTONDOWN:
self.active = self.rect.collidepoint(event.pos)
if self.active and event.type == pygame.KEYDOWN:
if event.key == pygame.K_BACKSPACE:
self.text = self.text[:–1]
elif event.unicode.isprintable():
self.text += event.unicode
def draw(self, screen, font):
color = (120, 200, 255) if self.active else (180, 180, 180)
pygame.draw.rect(screen, color, self.rect, 2)
txt = font.render(self.text, True, TEXT_COLOR)
screen.blit(txt, (self.rect.x + 6, self.rect.y + 6))
# ————————————————————————————————
# 按钮
# ————————————————————————————————
class Button:
def __init__(self, x, y, w, h, label, color = (70, 120, 70)):
self.rect = pygame.Rect(x, y, w, h)
self.label = label
self.color = color
def clicked(self, event):
return (
event.type == pygame.MOUSEBUTTONDOWN and
self.rect.collidepoint(event.pos)
)
def draw(self, screen, font):
pygame.draw.rect(screen, self.color, self.rect)
txt = font.render(self.label, True, (255, 255, 255))
screen.blit(txt, txt.get_rect(center = self.rect.center))
# ————————————————————————————————
# 初始化 Pygame
# ————————————————————————————————
pygame.init()
pygame.display.set_icon(load_icon())
# enable_dark_title_bar(pygame.display.get_surface())
try:
ctypes.windll.shcore.SetProcessDpiAwareness(2)
except:
ctypes.windll.user32.SetProcessDPIAware()
PLOT_WIDTH = 1600
PANEL_WIDTH = 400
WIDTH, HEIGHT = 1600, 1200
screen = pygame.display.set_mode((PLOT_WIDTH + PANEL_WIDTH, HEIGHT),)
pygame.display.set_caption("Urban Drift " + os.path.basename(__file__)[:–3] + " by @0x00ac3375")
clock = pygame.time.Clock()
# 颜色定义
BACKGROUND = (32, 32, 32)
AGENT_COLOR = (100, 200, 255)
AXIS_COLOR = (200, 200, 200, 150) # 坐标轴颜色
GRID_COLOR = (80, 80, 80, 80) # 网格颜色
TEXT_COLOR = (220, 220, 220)
# ——————————–
# 初始化 UI
# ——————————–
ui_x = PLOT_WIDTH + 20
UI_WIDTH = PANEL_WIDTH – 40
BTN_WIDTH = 180
ui_font = load_font("./global.ttf", 18)
title_font = load_font("./global.ttf", 21)
font = load_font("./global.ttf", 18)
inputs = {
"L_MEAN": TextInput(ui_x, 120, UI_WIDTH, 30, str(L_MEAN)),
"EPSILON": TextInput(ui_x, 200, UI_WIDTH, 30, str(EPSILON)),
"DELTA": TextInput(ui_x, 280, UI_WIDTH, 30, f"{DELTA * 180 / math.pi:.2f}"),
"TEMPO": TextInput(ui_x, 360, UI_WIDTH, 30, str(TEMPO)),
"MAX_POINTS": TextInput(ui_x, 440, UI_WIDTH, 30, str(MAX_POINTS)),
"AIR_WALL_X": TextInput(ui_x, 520, UI_WIDTH, 30, ""),
"AIR_WALL_Y": TextInput(ui_x, 600, UI_WIDTH, 30, ""),
"BUILDING_L": TextInput(ui_x, 680, UI_WIDTH, 30, str(BUILDING_L)),
"STREET_D": TextInput(ui_x, 760, UI_WIDTH, 30, str(STREET_D)),
"TURN_ANGLE": TextInput(ui_x, 840, UI_WIDTH, 30, ""),
}
apply_btn = Button(ui_x, 1080, UI_WIDTH, 40, "Apply", (70, 120, 70))
mesh_btn = Button(80, 1140, BTN_WIDTH, 40, "Mesh: On", (70, 70, 170))
reset_btn = Button(ui_x, 1140, UI_WIDTH, 40, "Reset", (170, 70, 70))
zoom_btn = Button(280, 1140, BTN_WIDTH, 40, "Auto Zoom: On", (70, 170, 170))
sound_btn = Button(480, 1140, BTN_WIDTH, 40, "Sound: On", (170, 170, 70))
# ————————————————————————————————
# 智能坐标轴系统类
# ————————————————————————————————
class SmartAxisSystem:
def __init__(self, screen_width, screen_height, margin=80):
self.screen_width = screen_width
self.screen_height = screen_height
self.margin = margin # 边距,用于显示坐标刻度
# 绘图区域(去掉边距后的实际绘图区)
self.plot_x = margin
self.plot_y = margin
self.plot_width = screen_width – 2 * margin
self.plot_height = screen_height – 2 * margin
# 动态缩放参数
self.auto_zoom = True
self.zoom_speed = 0.2
# 平滑度参数
self.base_smoothness = 0.05 # 基础平滑度
self.max_smoothness = 0.75 # 最大平滑度(紧急追赶)
# 坐标轴显示参数
self.grid_spacing = 50 # 网格间距(像素)
self.show_grid = True
def update_viewport(self, points):
"""根据数据点自动调整视图范围和缩放"""
if len(points) < 2:
return 1.0, 0.0, 0.0 # zoom, offset_x, offset_y
# 提取所有点的坐标
x_coords = [p[0] for p in points]
y_coords = [p[1] for p in points]
# 计算数据范围
x_min, x_max = min(x_coords) – 1, max(x_coords) + 1
y_min, y_max = min(y_coords) – 1, max(y_coords) + 1
# 避免除零
data_width = max(x_max – x_min, 0.1)
data_height = max(y_max – y_min, 0.1)
# 计算最佳缩放比例(让数据适应绘图区域)
zoom_x = self.plot_width / data_width
zoom_y = self.plot_height / data_height
optimal_zoom = min(zoom_x, zoom_y) * 0.8 # 留20%边距
# (可选)限制缩放范围
# optimal_zoom = max(5.0, min(optimal_zoom, 200.0))
# 计算视图中心(数据边界中心)
center_x = (x_min + x_max) / 2
center_y = (y_min + y_max) / 2
# 计算偏移量(让数据居中)
offset_x = self.plot_x + self.plot_width/2 – center_x * optimal_zoom
offset_y = self.plot_y + self.plot_height/2 – center_y * optimal_zoom
return optimal_zoom, offset_x, offset_y
def world_to_screen(self, world_x, world_y, zoom, offset_x, offset_y):
"""世界坐标转屏幕坐标"""
screen_x = int(world_x * zoom + offset_x)
screen_y = int(world_y * zoom + offset_y)
return screen_x, screen_y
def draw_axes(self, screen, zoom, offset_x, offset_y):
"""绘制坐标轴和网格"""
# 绘制外边框
pygame.draw.rect(screen, AXIS_COLOR, (self.plot_x, self.plot_y, self.plot_width, self.plot_height), 2)
if not self.show_grid:
return
# 计算网格线的世界坐标间隔(保持屏幕间距大致恒定)
world_spacing = self.grid_spacing / zoom
# 找到最近的"整齐"的网格间隔
magnitude = 10 ** math.floor(math.log10(world_spacing))
grid_step = round(world_spacing / magnitude) * magnitude
if grid_step < 0.1: # 避免网格过密
grid_step = 0.1
# 计算网格起点(对齐到整齐的数值)
origin_x, origin_y = self.world_to_screen(0, 0, zoom, offset_x, offset_y)
# 绘制网格线
font = load_font(font = "./global.ttf", size = 14)
# 水平网格线
y = math.ceil(–offset_y / zoom / grid_step) * grid_step
while True:
screen_y = int(y * zoom + offset_y)
if screen_y < self.plot_y:
y += grid_step
continue
if screen_y > self.plot_y + self.plot_height:
break
if abs(y) > 0.001: # 非零线用虚线
for x in range(self.plot_x, self.plot_x + self.plot_width, 4):
if x % 8 < 4:
pygame.draw.line(screen, GRID_COLOR, (x, screen_y), (x+2, screen_y), 1)
else: # 零线用实线
pygame.draw.line(screen, AXIS_COLOR,
(self.plot_x, screen_y),
(self.plot_x + self.plot_width, screen_y), 1)
# 绘制Y轴刻度标签
if self.plot_x – 30 > 0:
label = font.render(f"{y:.1f}" if grid_step < 10 else f"{y:.0f}", True, TEXT_COLOR)
screen.blit(label, (self.plot_x – 44, screen_y – 10))
y += grid_step
# 垂直网格线(类似逻辑)
x = math.ceil(–offset_x / zoom / grid_step) * grid_step
while True:
screen_x = int(x * zoom + offset_x)
if screen_x < self.plot_x:
x += grid_step
continue
if screen_x > self.plot_x + self.plot_width:
break
if abs(x) > 0.001:
for y in range(self.plot_y, self.plot_y + self.plot_height, 4):
if y % 8 < 4:
pygame.draw.line(screen, GRID_COLOR,
(screen_x, y), (screen_x, y+2), 1)
else:
pygame.draw.line(screen, AXIS_COLOR,
(screen_x, self.plot_y),
(screen_x, self.plot_y + self.plot_height), 1)
if self.plot_y – 30 > 0:
label = font.render(f"{x:.1f}" if grid_step < 10 else f"{x:.0f}", True, TEXT_COLOR)
screen.blit(label, (screen_x – 15, self.plot_y – 30))
x += grid_step
# 绘制原点标记
if (self.plot_x <= origin_x <= self.plot_x + self.plot_width and
self.plot_y <= origin_y <= self.plot_y + self.plot_height):
pygame.draw.circle(screen, AXIS_COLOR, (origin_x, origin_y), 3)
origin_label = font.render("(0,0)", True, TEXT_COLOR)
screen.blit(origin_label, (origin_x + 5, origin_y + 5))
# ——————————–
# 绘制障碍物
# ——————————–
def draw_buildings(screen, axis: SmartAxisSystem, zoom, offset_x, offset_y, L, D):
if L <= 0:
return
period = L + D
# 当前可见世界范围
left = (–offset_x) / zoom
right = (axis.plot_width – offset_x) / zoom
bottom = (–offset_y) / zoom
top = (axis.plot_height – offset_y) / zoom
ix_min = int(math.floor(left / period)) – 1
ix_max = int(math.ceil(right / period)) + 1
iy_min = int(math.floor(bottom / period)) – 1
iy_max = int(math.ceil(top / period)) + 1
color = (255, 220, 80) # 黄色
for ix in range(ix_min, ix_max):
for iy in range(iy_min, iy_max):
bx = ix * period + D / 2
by = iy * period + D / 2
x0, y0 = axis.world_to_screen(bx, by, zoom, offset_x, offset_y)
x1, y1 = axis.world_to_screen(bx + L, by + L, zoom, offset_x, offset_y)
if x1 < axis.plot_x or y1 < axis.plot_y:
continue
if x0 > axis.plot_x + axis.plot_width or y0 > axis.plot_y + axis.plot_height:
continue
if x1 > axis.plot_x + axis.plot_width:
x1 = axis.plot_x + axis.plot_width
if x0 < axis.plot_x:
x0 = axis.plot_x
if y1 > axis.plot_y + axis.plot_height:
y1 = axis.plot_y + axis.plot_height
if y0 < axis.plot_y:
y0 = axis.plot_y
rect = pygame.Rect(
min(x0, x1),
min(y0, y1),
abs(x1 – x0),
abs(y1 – y0),
)
pygame.draw.rect(screen, color, rect, 2)
# ——————————–
# 空气墙
# ——————————–
def hit_air_wall(x, y, wx, wy):
if wx is None or wy is None:
return False
return not (–wx <= x <= wx and –wy <= y <= wy)
# ——————————–
# 障碍物检测
# ——————————–
def in_building(x, y, L, D):
if L <= 0:
return False
period = L + D
xm = abs(x) % period
ym = abs(y) % period
street_half = D / 2
return (
street_half < xm < street_half + L and
street_half < ym < street_half + L
)
# ————————————————————————————————
# 初始化系统和状态
# ————————————————————————————————
axis_system = SmartAxisSystem(WIDTH, HEIGHT)
# 智能体状态
x, y = 0.0, 0.0
theta = np.random.uniform(0, 2 * math.pi)
points = deque(maxlen = MAX_POINTS)
points.append((x, y))
step_count = 0
total_distance = 0
running = True
# 视图参数
zoom = SCALE
offset_x = WIDTH // 2
offset_y = HEIGHT // 2
# ————————————————————————————————
# 主循环
# ————————————————————————————————
timer.start()
while running:
# 事件处理
for event in pygame.event.get():
if apply_btn.clicked(event):
# 重置
try:
params["L_MEAN"] = float(inputs["L_MEAN"].text)
params["EPSILON"] = float(inputs["EPSILON"].text)
params["DELTA"] = float(inputs["DELTA"].text) * math.pi / 180
params["TEMPO"] = float(inputs["TEMPO"].text)
params["MAX_POINTS"] = int(inputs["MAX_POINTS"].text)
L_MEAN = params["L_MEAN"]
EPSILON = params["EPSILON"]
DELTA = params["DELTA"]
MAX_POINTS = params["MAX_POINTS"]
TEMPO = params["TEMPO"]
def parse_or_none(txt):
return None if txt.strip() == "" else float(txt)
AIR_WALL_X = parse_or_none(inputs["AIR_WALL_X"].text)
AIR_WALL_Y = parse_or_none(inputs["AIR_WALL_Y"].text)
BUILDING_L = float(inputs["BUILDING_L"].text)
STREET_D = float(inputs["STREET_D"].text)
params["TURN_ANGLE"] = parse_or_none(inputs["TURN_ANGLE"].text)
x, y, total_distance = 0, 0, 0
theta = np.random.uniform(0, 2 * math.pi)
step_count = 0
timer.reset()
period = 60000 / params["TEMPO"] # ms
points = deque(points, maxlen = MAX_POINTS)
points.clear()
points.append((x, y))
timer.start()
except:
print("Invalid parameter.")
inputs = {
"L_MEAN": TextInput(ui_x, 120, UI_WIDTH, 30, L_MEAN),
"EPSILON": TextInput(ui_x, 200, UI_WIDTH, 30, EPSILON),
"DELTA": TextInput(ui_x, 280, UI_WIDTH, 30, round(DELTA * 180 / math.pi, 2)),
"TEMPO": TextInput(ui_x, 360, UI_WIDTH, 30, TEMPO),
"MAX_POINTS": TextInput(ui_x, 440, UI_WIDTH, 30, MAX_POINTS),
"AIR_WALL_X": TextInput(ui_x, 520, UI_WIDTH, 30, ""),
"AIR_WALL_Y": TextInput(ui_x, 600, UI_WIDTH, 30, ""),
"BUILDING_L": TextInput(ui_x, 680, UI_WIDTH, 30, "0"),
"STREET_D": TextInput(ui_x, 760, UI_WIDTH, 30, "0"),
"TURN_ANGLE": TextInput(ui_x, 840, UI_WIDTH, 30, ""),
}
elif mesh_btn.clicked(event):
axis_system.show_grid = not axis_system.show_grid
mesh_btn.label = "Mesh: Off" if not axis_system.show_grid else "Mesh: On"
elif zoom_btn.clicked(event):
axis_system.auto_zoom = not axis_system.auto_zoom
zoom_btn.label = "Auto Zoom: Off" if not axis_system.auto_zoom else "Auto Zoom: On"
elif reset_btn.clicked(event):
# 重置
x, y, total_distance = 0, 0, 0
theta = np.random.uniform(0, 2 * math.pi)
points.clear()
points = deque(points, maxlen = MAX_POINTS)
points.append((x, y))
step_count = 0
inputs = {
"L_MEAN": TextInput(ui_x, 120, UI_WIDTH, 30, default["L_MEAN"]),
"EPSILON": TextInput(ui_x, 200, UI_WIDTH, 30, default["EPSILON"]),
"DELTA": TextInput(ui_x, 280, UI_WIDTH, 30, round(default["DELTA"] * 180 / math.pi, 2)),
"TEMPO": TextInput(ui_x, 360, UI_WIDTH, 30, default["TEMPO"]),
"MAX_POINTS": TextInput(ui_x, 440, UI_WIDTH, 30, default["MAX_POINTS"]),
"AIR_WALL_X": TextInput(ui_x, 520, UI_WIDTH, 30, ""),
"AIR_WALL_Y": TextInput(ui_x, 600, UI_WIDTH, 30, ""),
"BUILDING_L": TextInput(ui_x, 680, UI_WIDTH, 30, default["BUILDING_L"]),
"STREET_D": TextInput(ui_x, 760, UI_WIDTH, 30, default["STREET_D"]),
"TURN_ANGLE": TextInput(ui_x, 840, UI_WIDTH, 30, ""),
}
try:
params["L_MEAN"] = float(inputs["L_MEAN"].text)
params["EPSILON"] = float(inputs["EPSILON"].text)
params["DELTA"] = float(inputs["DELTA"].text) * math.pi / 180
params["TEMPO"] = float(inputs["TEMPO"].text)
params["MAX_POINTS"] = int(inputs["MAX_POINTS"].text)
params["TURN_ANGLE"] = ""
L_MEAN = params["L_MEAN"]
EPSILON = params["EPSILON"]
DELTA = params["DELTA"]
MAX_POINTS = params["MAX_POINTS"]
TEMPO = params["TEMPO"]
period = 60000 / params["TEMPO"] # ms
points = deque(points, maxlen = MAX_POINTS)
points.clear()
points.append((x, y))
timer.reset()
timer.start()
except:
print("Invalid parameter.")
inputs = {
"L_MEAN": TextInput(ui_x, 120, UI_WIDTH, 30, L_MEAN),
"EPSILON": TextInput(ui_x, 200, UI_WIDTH, 30, EPSILON),
"DELTA": TextInput(ui_x, 280, UI_WIDTH, 30, round(DELTA * 180 / math.pi, 2)),
"TEMPO": TextInput(ui_x, 360, UI_WIDTH, 30, TEMPO),
"MAX_POINTS": TextInput(ui_x, 440, UI_WIDTH, 30, MAX_POINTS),
"TURN_ANGLE": TextInput(ui_x, 840, UI_WIDTH, 30, ""),
}
elif sound_btn.clicked(event):
sound_btn.label = "Sound: Off" if not sound_btn.label == "Sound: Off" else "Sound: On"
sound_on = not sound_on
elif event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
if event.key in (pygame.K_q, pygame.K_ESCAPE):
running = False
elif event.key == pygame.K_SPACE:
# 重置
x, y = 0, 0
theta = np.random.uniform(0, 2 * math.pi)
points.clear()
points.append((x, y))
step_count = 0
inputs = {
"L_MEAN": TextInput(ui_x, 120, UI_WIDTH, 30, default["L_MEAN"]),
"EPSILON": TextInput(ui_x, 200, UI_WIDTH, 30, default["EPSILON"]),
"DELTA": TextInput(ui_x, 280, UI_WIDTH, 30, round(default["DELTA"] * 180 / math.pi, 2)),
"TEMPO": TextInput(ui_x, 360, UI_WIDTH, 30, default["TEMPO"]),
"MAX_POINTS": TextInput(ui_x, 440, UI_WIDTH, 30, default["MAX_POINTS"]),
"AIR_WALL_X": TextInput(ui_x, 520, UI_WIDTH, 30, ""),
"AIR_WALL_Y": TextInput(ui_x, 600, UI_WIDTH, 30, ""),
"BUILDING_L": TextInput(ui_x, 680, UI_WIDTH, 30, default["BUILDING_L"]),
"STREET_D": TextInput(ui_x, 760, UI_WIDTH, 30, default["STREET_D"]),
"TURN_ANGLE": TextInput(ui_x, 840, UI_WIDTH, 30, ""),
}
elif event.key == pygame.K_g:
axis_system.show_grid = not axis_system.show_grid
elif event.key == pygame.K_a:
axis_system.auto_zoom = not axis_system.auto_zoom
elif event.type == pygame.MOUSEWHEEL:
# 手动缩放(仅在自动缩放关闭时有效)
if not axis_system.auto_zoom:
zoom *= 1.1 if event.y > 0 else 0.9
zoom = max(5.0, min(zoom, 200.0))
for inp in inputs.values():
inp.handle_event(event)
if timer.get_elapsed_time() >= period * step_count:
# —— 每步移动 ——
L = np.random.uniform(L_MEAN – EPSILON, L_MEAN + EPSILON)
d_theta = np.random.uniform(–DELTA, DELTA)
theta += d_theta
x_next = x + L * math.cos(theta)
y_next = y + L * math.sin(theta)
# —— 空气墙 ——
if hit_air_wall(x_next, y_next, AIR_WALL_X, AIR_WALL_Y):
theta = (theta + math.pi) % (2 * math.pi)
x_next, y_next = x, y
# —— 建筑块 ——
elif in_building(x_next, y_next, BUILDING_L, STREET_D):
# theta = np.random.uniform(0, 2 * math.pi)
# 向右转
if params["TURN_ANGLE"] == None:
theta = np.random.uniform(0, 2 * math.pi)
else:
theta = (theta + float(params["TURN_ANGLE"]) * math.pi / 180) % (2 * math.pi)
x_next, y_next = x, y
sx, sy = axis_system.world_to_screen(x_next, y_next, zoom, offset_x, offset_y)
out_of_view = not (
axis_system.plot_x <= sx <= axis_system.plot_x + axis_system.plot_width and
axis_system.plot_y <= sy <= axis_system.plot_y + axis_system.plot_height
)
if out_of_view and axis_system.auto_zoom:
# 临时把“预测点”加入历史
temp_points = list(points) + [(x_next, y_next)]
new_zoom, new_offset_x, new_offset_y = axis_system.update_viewport(temp_points)
# 这里可以选择:
# A. 立刻跳变(强制重构)
zoom = new_zoom
offset_x = new_offset_x
offset_y = new_offset_y
x,y = x_next, y_next
points.append((x, y))
total_distance += L
step_count += 1
if sound_on:
if step_count % 16 == 1:
sound_cymbal.play()
if step_count % 2 == 0:
sound_snare.play()
if step_count % 2 == 1:
sound_kick.play()
# —— 智能视图调整 ——
if axis_system.auto_zoom and len(points) > 1:
new_zoom, new_offset_x, new_offset_y = axis_system.update_viewport(points)
# 平滑过渡
zoom += (new_zoom – zoom) * axis_system.zoom_speed
offset_x += (new_offset_x – offset_x) * axis_system.zoom_speed
offset_y += (new_offset_y – offset_y) * axis_system.zoom_speed
# —— 清屏和绘制 ——
screen.fill(BACKGROUND)
# 绘制坐标轴系统
axis_system.draw_axes(screen, zoom, offset_x, offset_y)
# —— 建筑物(黄色) ——
draw_buildings(screen, axis_system, zoom, offset_x, offset_y, BUILDING_L, STREET_D)
# 绘制轨迹(渐变效果)
if len(points) > 1:
screen_points = []
for px, py in points:
sx, sy = axis_system.world_to_screen(px, py, zoom, offset_x, offset_y)
screen_points.append((sx, sy))
# 渐变线段
n = len(screen_points)
r, g, b, a = 100, 200, 255, 255
for i in range(n, 1, –1):
# 颜色插值:从旧到新,蓝色渐变
t = 1 – i / MAX_POINTS
r = int(32 + t * 68)
g = int(32 + t * 168)
b = int(32 + t * 223)
pygame.draw.line(screen, (r, g, b), screen_points[–i], screen_points[–i + 1], 2)
# 绘制智能体
agent_x, agent_y = axis_system.world_to_screen(x, y, zoom, offset_x, offset_y)
pygame.draw.circle(screen, AGENT_COLOR, (agent_x, agent_y), 6)
# 方向指示
dx = math.cos(theta) * 12
dy = math.sin(theta) * 12
pygame.draw.line(screen, (255, 255, 200),
(agent_x, agent_y),
(agent_x + dx, agent_y + dy), 3)
# —— HUD 信息 ——
hud = pygame.Surface((380, 185), pygame.SRCALPHA)
hud.fill((64, 64, 64, 140))
info_lines = [
f"Steps: {step_count}",
f"Time: {timer.get_formatted_time()}",
f"Position: ({x:.2f}, {y:.2f}) m",
f"Displacement from (0,0): {math.hypot(x, y):.2f} m",
f"Total distance: {total_distance:.2f} m",
f"Zoom: {zoom:.2f} px/m",
]
for i, line in enumerate(info_lines):
text = font.render(line, True, TEXT_COLOR)
hud.blit(text, (10, 10 + i * 28))
screen.blit(hud, (100, 100))
panel = pygame.Surface((PANEL_WIDTH, HEIGHT))
panel.fill((40, 40, 40))
screen.blit(panel, (PLOT_WIDTH, 0))
title = title_font.render("Control Panel", True, TEXT_COLOR)
screen.blit(title, (ui_x, 35))
labels = [
("Average step length (m)", "L_MEAN"),
("Step length fluctuate ± (m)", "EPSILON"),
("Maximum direction-change angle ± (degree)", "DELTA"),
("Step per minute (aka. Tempo) ", "TEMPO"),
("Locus length (points)", "MAX_POINTS"),
("Air wall half-width X (m) (blank = no wall)", "AIR_WALL_X"),
("Air wall half-height Y (m) (blank = no wall)", "AIR_WALL_Y"),
("Building size L (m) (0 = no building)", "BUILDING_L"),
("Street width D (m) (ignore if L=0)", "STREET_D"),
("Turn angle (degree) (blank = random)", "TURN_ANGLE"),
]
for i, (label, key) in enumerate(labels):
text = ui_font.render(label, True, TEXT_COLOR)
screen.blit(text, (ui_x, 90 + i * 80))
inputs[key].draw(screen, ui_font)
apply_btn.draw(screen, ui_font)
mesh_btn.draw(screen, ui_font)
reset_btn.draw(screen, ui_font)
zoom_btn.draw(screen, ui_font)
sound_btn.draw(screen, ui_font)
# 更新显示
pygame.display.flip()
clock.tick(60)
pygame.quit()
sys.exit()
3.3 运行效果
也可以加入了网格、坐标尺度还有障碍物等要素。




