欢迎光临
我们一直在努力

【PyGame】随机游走及其模拟方法

前言

这是某大学的《概率论与数理统计》课程里出现的一个问题,挺有意思的也挺适合开发一个小程序的,所以就拿过来了。

这个题的题目背景是数学,但是本身也挺适合作为一个课程设计项目,所以就拿过来举例,顺带说明如何只用 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=1n(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<jLiLjcosθicosθj+2i<jLiLjsinθisinθj=i=1nLi2+21i<jncos(θ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=1nLi2+21i<jncos(θiθj))1/2,对每个

θ

i

\\theta_i

θi

L

i

L_i

Li 进行积分会产生没有闭式解的椭圆积分,从

n

2

n\\ge 2

n2 开始就是如此了。

一种比较合理的估算方法是利用瑞利分布来估计。具体说来,当

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=1n(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=1nE[Li2cos2θi]=nE(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π

=21(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.6720.4292×2844.6721220.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

    7090 厘米左右);

  • 走路速度

    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=θi1+Δθi,ΔθiU[α,α],α=15°=12π

θ

1

U

[

0

,

2

π

)

\\theta_1 \\sim U[0, 2\\pi)

θ1U[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=ij,则

θ

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=1kE[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)]=cij,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=1nj=1nE(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)cij=

l2+3ϵ2l2cij,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=1nj=1ncij,则:

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(Sn)=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=1n1(nk)ck=n1c1+c(1c)22c(1cn)

于是能得到

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+l21c1+c)(1c)22l2c(1cn)

代入

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 运行效果

也可以加入了网格、坐标尺度还有障碍物等要素。

在这里插入图片描述

赞(0)
未经允许不得转载:171主机测评 » 【PyGame】随机游走及其模拟方法
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址