使用 Elm 为 Web 重现 Pong 游戏
由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!
让我们一起来看看如何制作经典雅达利游戏《Pong》的网页版!如果您错过了,这里是本系列的介绍文章。我们的目标是重现经典的互动游戏,并在过程中学习一些新知识。
🏓乒乓球
在深入探讨之前,让我们先来了解一下Pong游戏的外观。
Pong是乒乓球运动的抽象表现形式,由以下基本形状组成:
- 一个在屏幕上移动和弹跳的球
- 左侧的挡板可以上下移动,由玩家控制。
- 一个由电脑控制的、可以上下移动的右侧拨片。
- 每个球拍都有一个得分指示器。
- 一个黑色背景的矩形窗口,中间有一个“网状”图案。
多年来,Pong游戏推出了许多版本,但我们将以这些元素为蓝本打造我们的版本。不过,让我们先来了解一些有趣的历史吧。
📖 历史
《Pong》这款游戏最吸引人的地方之一在于,它在 20 世纪 70 年代初发行,当时公众对电子游戏的概念知之甚少。
当时,投币式弹球游戏可能是最接近街机游戏的产品,而雅达利需要将街机游戏“商业化”。
像《太空大战!》这样的游戏比《Pong》早十年问世,但它们只面向学术界,对普通大众来说过于复杂。因此,雅达利公司必须运用工程、设计和创业等多个学科的技能,才能创造出易于上手的游戏。
最终成品就是上图宣传材料中展示的游戏机柜。从下图我们可以看到,他们的说明书和操作步骤设计得多么简洁明了。
在我们的版本中,我们将使用键盘上的方向键来控制拨片。但很容易理解为什么人们会喜欢机柜上简单的操控方式。
据说,雅达利公司把他们的原型机放在硅谷中心一家名叫安迪·卡普酒馆的酒吧里。这台机器非常受欢迎,以至于硬币都堆满了酒吧。
为了这个项目做“研究”,我买了一款名为RetroN 77的Atari 2600模拟器,想着它能很好地模拟原版游戏。但后来发现,在 Atari 2600 发布的时候, Pong 游戏可能已经过时了。在街机版之后,还有一个专门的家用游戏机版本,比基于卡带的 Atari 2600 更早推出。
和女儿一起玩模拟器仍然很有趣:
由于其文化影响力,Pong甚至被纳入了史密森尼博物馆的馆藏。如果您有兴趣了解更多关于 Pong 的历史,可以从维基百科的Pong页面入手。此外,还有一部名为《世界 1-1》的趣味纪录片,您或许也会喜欢。
🕹 为网络重建 Pong
我最初制作这个系列的目的是重现经典游戏,然后发布分步教程,讲解如何进行同样的操作。
在仔细研究了Pong的游戏机制之后,我意识到如果用这种方式来解释会非常冗长乏味😅。要全面阐述其内容,恐怕需要一整门课程。因此,这篇文章将着重介绍整个开发过程和一些有趣的部分,最后我会把所有源代码都分享到 GitHub 上。
另外,我还在学习游戏开发,不想误导你。我会尽量用浅显易懂的语言解释,避免使用那些刚入门时可能会让人望而生畏的专业术语。
我的目标依然是忠实地复刻这些经典游戏。所以我努力研究《Pong》这款游戏如此有趣的原因。同时,我也会根据实际情况添加一些小功能和有趣的元素。
🎯 为什么乒乓球游戏如此完美
事实证明,Pong是一款非常适合入门游戏开发的游戏。和大多数软件项目一样,前 90% 都很简单。而最后 10% 才是决定成败的关键。
👏 简单却惊艳
我们可以先勾勒出构成Pong游戏的所有元素,以便更好地了解我们想要在屏幕上呈现的内容。它包含了所有基本要素,以及制作一款趣味十足的游戏所需的一切:
- 规则:清晰的球拍控制和计分规则使游戏易于上手。
- 游戏循环:循环允许我们不断地:
- 监听输入
- 随着时间的推移调整物体
- 输入处理:我们将使用键盘来处理玩家的输入并移动左侧拨片。
- 竞争:玩家得分增加了游戏的乐趣和获胜条件。
- 渲染:我们将使用 SVG 创建一个游戏窗口并渲染所有元素。
- 游戏状态:我们将在不同的状态之间转换:
StartingScreenPlayingScreenEndingScreen
- AI:我们将模拟简单的AI,用于计算机控制的右桨。
- 基础物理:我们将运用基础物理原理来保持球和球拍的位置和速度。
- 碰撞:当球与球拍或屏幕边缘发生碰撞时,我们可以处理碰撞并做出相应的反应。
- 资源:大多数游戏都会用到外部资源,例如精灵图和音效。由于我们使用的是简单的 SVG 图形,所以不需要任何精灵图。但我们会使用一个外部音效库来添加简单的音效。
对于我们这些有网页开发背景的人来说,其中一些问题可能比较棘手。当我们构建网页应用程序时,我们的应用程序大部分时间都处于静止状态。我们偶尔需要加载新页面或向数据库发出请求,但大部分内容都是静态的。即使现在您正在阅读这篇文章,页面上所有文本的加载工作也已经完成。
游戏开发过程中,各种活动源源不断地发生。我们使用游戏循环来处理玩家的输入。即使没有输入,我们也会随着时间的推移在屏幕上移动物体。
当我开始学习这些概念时,像“时间差”、“速度”和“矢量”这样的术语让我感到很困惑。如果你也有这种感觉,完全可以理解。要理解坐标系以及上下方向也确实容易让人感到迷茫。
一个有用的技巧是想想你更熟悉的词语。例如,你可以想想:
horizontalPosition而不是一个x职位verticalSpeed而不是vy速度changeInTime而不是deltaTime
一旦我们建立了一个可行的思维模型,我们就可以努力使用常用术语,并找出为什么它们更合适。
💊 更深层次
在掌握了游戏的基本要素之后,我们发现游戏的内涵远不止于此。除了让画面元素出现和移动之外,我们还可以开始关注以下方面:
- 游戏设计:这个项目的主要收获在于,一些微小的改动就能对游戏体验产生巨大的影响。上面提到的游戏元素可以让游戏顺利运行。之后,你可以调整各种细节设置,例如球速、角度和计分方式,从而增加游戏的趣味性。
- 风险与回报:用球拍边缘击球是一种“冒险”的举动,因为更容易失手。“稳妥”的做法是用球拍中心击球。但我们可以改变球的飞行角度,这样一来,你的冒险举动就变成了回报,因为对方球拍将更难回击。如果没有这种机制,乒乓球游戏就会变得异常乏味和可预测。
- 速度:我们可以先进行微调,设定一个既有趣又不会太快而导致难度过高的球速。同时,我们也会在球每次击中球拍时略微加快球速。这样一来,玩家会感觉比赛节奏随着截击而加快,从而大大提升比赛的乐趣。
- 随机性:我们先从简单的发球开始,发球位置和方向都保持不变。然后,我们会增加球的位置和方向的随机性,这样你就必须时刻保持专注,并不断提升技巧,才能稳定地回击发球。
- 公平性:事实证明,有些改动会让人感觉不公平。由于球速和随机性的变化,发球可能会让人觉得不公平,而且很容易被得分。如果不限制球速上限,如果根本无法成功回球,就会让人觉得不公平。
- 游戏测试与调整:在开发游戏之前,我对这些一无所知。这是一个漫长的过程,需要不断添加功能,并反复试玩,观察(往往是意想不到的)后果。调整游戏数值可以带来许多有趣的新想法。
- 美学和用户界面:我花了一些时间尝试重现街机的感觉。虽然最终不得不舍弃一些工作成果,但添加一些色彩和润色确实能大大提升用户体验。
- 学习与技能发展: 游戏之所以如此引人入胜,是因为我们能在玩耍中学习。我们天生就善于识别模式,而随着技能的提升,我们也能感受到进步和提升带来的成就感。如果您对这个话题感兴趣,不妨读读《游戏设计的乐趣理论》这本书。
🌳 与榆树合作
如果你之前没有接触过 Elm 编程语言,《Elm 入门》是一个很好的起点。这本书篇幅不长,提供的示例包含了使用 Elm 构建前端 Web 应用程序所需的一切。
Elm架构也非常适合用于构建游戏。一眼就能看出Elm架构的功能(init,, )与像PICO -8这样的简单游戏开发平台(update,,)之间的相似模式。viewinitupdatedraw
init我们可以使用类型来建模我们的游戏并提供初始值。例如,游戏开始时球和球拍应该位于哪里?update这使我们能够随着时间的推移不断改变模型值。我们将更新球员得分、移动球以及在游戏状态之间转换。view这将使用 SVG 在屏幕上持续绘制更新后的模型。
本文余下部分将深入探讨 Elm 代码。但如果您还不了解 Elm,可以先快速浏览一下内容,了解一下整个流程。希望这篇文章能激发您进一步学习 Elm 和函数式编程的兴趣!
🤔 游戏组件建模
在构建数据模型的初始版本时,不必追求完美。我们可以先从几个已知的必要元素入手。然后,我们可以将这些元素引入view函数,以便进行分析:
- 窗户
- 球
- 左桨
- 右桨
⬛️ 窗户
这里有一个Window类型别名,我们可以用它来创建一个 SVG 矩形元素来容纳游戏。请记住,SVG 使用左上角作为起始点。当我们从左上角开始设置0值时x,y我们就是从左上角开始的。
- 随着
x数值增加,我们向右移动➡️ - 随着
y数值增加,我们向下移动⬇️
使用x、y、height和 的值,width我们可以绘制游戏窗口的矩形。
type alias Window =
{ backgroundColor : String
, x : Float
, y : Float
, width : Float
, height : Float
}
我们将设定一些初始值:
initialWindow : Window
initialWindow =
{ backgroundColor = "black"
, x = 0.0
, y = 0.0
, width = 800.0
, height = 600.0
}
这些值将对应于 SVG rect元素,而 Elm 有一个Svg 包,其中包含了我们需要的所有相同的元素和属性。
这是对应的视图函数,它会调用该函数Svg.rect。我们可以将我们的对象传递initialWindow给这个函数,它会生成我们需要的矩形。
viewGameWindow : Window -> Svg.Svg msg
viewGameWindow window =
Svg.rect
[ Svg.Attributes.fill <| window.backgroundColor
, Svg.Attributes.x <| String.fromFloat window.x
, Svg.Attributes.y <| String.fromFloat window.y
, Svg.Attributes.width <| String.fromFloat window.width
, Svg.Attributes.height <| String.fromFloat window.height
]
[]
我们甚至可以为屏幕中间的“网”添加另一个视图功能。一番搜索后,我找到了MDN 上的stroke-dasharray文档,它能很好地用于在游戏窗口中间放置虚线。
viewNet : Window -> Svg msg
viewNet window =
Svg.line
[ Svg.Attributes.stroke "white"
, Svg.Attributes.strokeDasharray "14, 14"
, Svg.Attributes.strokeWidth "4"
, Svg.Attributes.x1 <| String.fromFloat <| (window.width / 2)
, Svg.Attributes.x2 <| String.fromFloat <| (window.width / 2)
, Svg.Attributes.y1 <| String.fromFloat <| window.y
, Svg.Attributes.y2 <| String.fromFloat <| window.height
]
[]
目前看来并不起眼,但它能用!
🎾 球
球就像窗口一样,因为我们仍然在使用Svg.rect,但这次它会小得多。我们添加一个vx和vy,用来表示水平和垂直速度。我们可以直接用x和来控制球的位置随时间变化y。但这些速度值允许我们做一些事情,比如改变速度,或者取反,让球在击中球拍时朝相反的方向运动。
type alias Ball =
{ color : String
, x : Float
, y : Float
, vx : Float
, vy : Float
, width : Float
, height : Float
}
以下是我最终得到的初始值initialBall。基本上就是不断调整,直到它位于屏幕中间附近。
initialBall : Ball
initialBall =
{ color = "white"
, x = 395.0
, y = 310.0
, vx = 350.0
, vy = 350.0
, width = 10.0
, height = 10.0
}
我们也可以用一个position值来表示相同的数据,分别存储x两个不同的值y。在这个游戏中,我希望坚持使用一个“扁平”模型,不涉及任何嵌套,以便于更新数值。但我可能会velocity在下vx一个vy游戏中改变这种方法。
我还应该提一下,我一开始用的是数值。后来为了避免转换和整数舍入,Int我改用了数值。我不确定,但我感觉数值的精确性也让动画看起来更流畅一些。FloatFloat
以下是用于在页面上渲染球的视图函数:
viewBall : Ball -> Svg msg
viewBall ball =
Svg.rect
[ Svg.Attributes.fill <| ball.color
, Svg.Attributes.x <| String.fromFloat ball.x
, Svg.Attributes.y <| String.fromFloat ball.y
, Svg.Attributes.width <| String.fromFloat ball.width
, Svg.Attributes.height <| String.fromFloat ball.height
]
[]
🏓 桨
球拍和窗口、球一样都是矩形。两个球拍的字段相同,但我们可以添加PaddleId类型来区分它们。
我们为每个桨叶添加一个score递增的参数。另外需要注意的是,我们只需要vy垂直速度的参数,因为桨叶不需要水平移动。
type PaddleId
= Left
| Right
type alias Paddle =
{ color : String
, id : PaddleId
, score : Int
, x : Float
, y : Float
, vy : Float
, width : Float
, height : Float
}
然后,我们可以设置初始值,将球拍放置在游戏窗口左右边缘附近:
initialLeftPaddle : Paddle
initialLeftPaddle =
{ color = "lightblue"
, id = Left
, score = 0
, x = 48.0
, y = 200.0
, vy = 600.0
, width = 10.0
, height = 60.0
}
initialRightPaddle : Paddle
initialRightPaddle =
{ color = "lightpink"
, id = Right
, score = 0
, x = 740.0
, y = 300.0
, vy = 475.0
, width = 10.0
, height = 60.0
}
以下是球拍和球拍计分的视图功能:
viewPaddle : Paddle -> Svg msg
viewPaddle paddle =
Svg.rect
[ Svg.Attributes.fill <| paddle.color
, Svg.Attributes.x <| String.fromFloat paddle.x
, Svg.Attributes.y <| String.fromFloat paddle.y
, Svg.Attributes.width <| String.fromFloat paddle.width
, Svg.Attributes.height <| String.fromFloat paddle.height
]
[]
viewPaddleScore : Int -> Window -> Float -> Svg msg
viewPaddleScore score window positionOffset =
Svg.text_
[ Svg.Attributes.fill "white"
, Svg.Attributes.fontFamily "monospace"
, Svg.Attributes.fontSize "80"
, Svg.Attributes.fontWeight "bold"
, Svg.Attributes.x <| String.fromFloat <| (window.width / 2) + positionOffset
, Svg.Attributes.y "100"
]
[ Svg.text <| String.fromInt score ]
一开始,为了简化操作,我把一些值硬编码到了代码里。例如,positionOffset分数的显示方式是我后来添加的,目的是用同一个函数来显示两个分数,并传入一个数值来控制分数在 SVG 窗口中的移动。
我们将所有这些元素整合到模型中,这样我们就拥有了游戏的所有基本要素!
type alias Model =
{ ball : Ball
, leftPaddle : Paddle
, rightPaddle : Paddle
, window : Window
}
initialModel : Model
initialModel =
{ ball = initialBall
, leftPaddle = initialLeftPaddle
, rightPaddle = initialRightPaddle
, window = initialWindow
}
游戏尚未“正式上线”,但我们拥有所需的一切,而且所有东西都大致位于正确的位置。
🔁 更新
这个update函数是我们游戏中许多神奇功能实现的地方。我们通过它来更新球的位置、移动球拍、更新玩家得分等等。同时,它也让事情变得稍微复杂一些。本项目的源代码位于本文末尾,因此我将尽量保持这一部分的概括性,避免深入细节。
⏱ 订阅
这个subscriptions函数是一个很好的起点,我们将在后续代码中处理这些问题update。我们使用订阅来实现两个主要功能:
- ⌨️ 处理键盘输入
- ✨ 添加动画
在下面的代码片段中,我们使用Browser.EventsElmBrowser包中的模块来处理浏览器动画和播放器的按键事件。
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch
[ Browser.Events.onAnimationFrameDelta BrowserAdvancedAnimationFrame
, Browser.Events.onKeyDown <| Json.Decode.map PlayerPressedKeyDown <| keyDecoder
, Browser.Events.onKeyUp <| Json.Decode.map PlayerPressedKeyUp <| keyDecoder
]
这样就能生成稳定的数据流,供我们的游戏使用。而Msg类型则允许我们指定在函数中如何处理这些数据update。
type Msg
= BrowserAdvancedAnimationFrame Time
| PlayerPressedKeyDown String
| PlayerReleasedKey String
承认我省略了很多细节,因为这篇博文比我预想的要长得多。但重点是,我们将用它BrowserAdvancedAnimationFrame来制作球和球拍的动画,使它们随时间移动。PlayerPressedKeyDown这将使我们能够知道玩家何时按下方向键来移动左侧球拍。
✏️ 使用伪代码绘制更新草图
当我开始编写这个update函数时,我用下面的伪代码快速规划了一下。
这种使用条件语句的思考方式最终被证明是一个巨大的错误👎。每次我想添加一个功能,就意味着要添加另一个条件。而且,条件的顺序也至关重要。
if nothing is happening
move ball
move right AI paddle
if player input
move left paddle
if ball hits right paddle
negate ball.vx
update ball position
if ball hits left paddle
negate ball.vx
update ball position
if ball hits left window edge
increment right player score
reset ball to initial position
set ball in motion
if ball hits right window edge
increment left player score
reset ball to initial position
set ball in motion
if ball hits top or bottom window edge
negate ball.vy
至少它让我对Pong游戏的事件有了大致的了解。但下次开发游戏时,我会在编写代码之前先在纸上画出草图。而且,我不会再考虑if-this-then-that语句,而是会尝试为不同的状态创建联合类型。
🚀 让球动起来
此时,让球运动起来,看看会发生什么,这很有趣。我们可以用类似的方法,根据从time得到的数据来调整球的位置BrowserAdvancedAnimationFrame。换句话说,随着时间的推移,球的x和y位置都会增加,这意味着球会向下 ⬇️ 并向右 ➡️ 运动。
{ ball
| x = ball.x + ball.vx * time
, y = ball.y + ball.vy * time
}
看到球开始运动很有趣,但我们很快意识到它不会在窗口底部停下来。
你需要查看本文末尾的源代码才能看到完整的示例。但目前的基本思路是,当小球撞击窗口底部时,反转其垂直方向。小球vy向下运动时,其值为正。撞击窗口底部边缘(我们知道这一点是因为小球的值为正window.height)意味着我们将小球的vy值为负。
-- ball hits bottom edge of window
case edge of
Window.Bottom ->
{ ball | vy = negate ball.vy }
从这里开始,游戏机制逐渐展开。当球撞击到窗口顶部或底部时,数值取反vy。当球撞击到窗口左侧或右侧边缘时,玩家得分加1,并将球重置回初始位置。
💥桨碰撞
处理球与窗口边缘的碰撞比较简单,因为我们可以使用窗口的 `x` x、y`y`、width`z` 和height`xy` 值。球与球拍的碰撞处理则稍微复杂一些,因为我们需要比较球的位置和球拍的位置,判断它们是否相交。以下是我用来判断球是否与球拍发生碰撞的布尔值检查:
ballHitLeftPaddle : Ball -> Paddle -> Bool
ballHitLeftPaddle ball paddle =
(paddle.y <= ball.y && ball.y <= paddle.y + paddle.height)
&& (paddle.x <= ball.x && ball.x <= paddle.x + paddle.width)
&& (ball.vx < 0)
ballHitRightPaddle : Ball -> Paddle -> Bool
ballHitRightPaddle ball paddle =
(paddle.y <= ball.y && ball.y <= paddle.y + paddle.height)
&& (paddle.x <= ball.x + ball.width && ball.x <= paddle.x + paddle.width)
&& (ball.vx > 0)
🚦 游戏状态
在完成一些初始功能并使其运行后,我们意识到游戏并非总是处于“可玩”状态。《超级马里奥兄弟》就是一个令人印象深刻的例子,游戏开始时会显示一个初始画面,玩家必须按下开始按钮才能开始游戏。
因此,我们可以从一种状态开始StartingScreen,并PlayingScreen在玩家准备就绪后过渡到另一种状态。对于Pong 游戏,我在游戏窗口下方添加了简短的说明(在本帖底部可见),并使用空格键开始游戏。
《超级马里奥兄弟》还有一个令人印象深刻的“游戏结束”画面。当其中一名玩家达到获胜分数时,画面会切换PlayingScreen到EndingScreen显示获胜者的状态。
type GameState
= StartingScreen
| PlayingScreen
| EndingScreen
我们可以创建类似这样的自定义类型来处理不同的游戏状态以及它们之间的转换。这也能确保玩家一次只能处于一种状态,因为我们不应该在“游戏结束”界面继续进行游戏。我们可以在开始和结束状态下禁用球和球拍的移动。此外,我们还能处理键盘输入,以便从这些状态开始或重新开始游戏。
🦉 画猫头鹰
请查看源代码,因为该update函数实际上包含很多内容。
我不想在这里说“画猫头鹰”这种话。但我希望确保我能涵盖到这款Pong游戏与其他版本相比更有趣的方面。
😍 有趣的部分
一旦游戏的可运行版本启动并运行,就该弄清楚是什么让它变得有趣、引人入胜、充满乐趣了。
⚡️改变球的角度
如果非要我选一个对游戏影响最大的功能,那就是这个。当球与球拍碰撞时,最初的想法是反转球的vx数值,然后让它反方向运动。效果大概是这样的:
-- ball hits paddle
{ ball | vx = negate ball.vx }
这样可以让球击中球拍并改变水平方向。事实证明,如果球的运动轨迹缓慢且可预测,比赛就会变得非常无聊。
解决方案是让球员能够控制球拍击球的角度。
用图示解释会更清楚,所以这里画几个简单的示意图。球的运动轨迹不再是固定的,而是球击中球拍的位置决定了击球角度。
这看起来似乎差别不大,但结果是,一个球拍可以让另一个球拍更难回击球。

我们还可以在球击中球拍时略微增加球速,使游戏节奏更快。每当球与球拍碰撞时,都增加该vx值以提升水平速度。Elm 的clamp函数可用于设置速度上限,因为球速过快会导致游戏难以进行。
调整球的角度和速度等因素对游戏体验有着巨大的影响。它也为游戏增添了策略性,为玩家提供了更多选择:
- 保守策略:采取保守策略,用球拍中间击球。球速会随着每次击球而增加,最终右拍将难以跟上。
- 冒险选择:或者,冒险将球击向球拍边缘附近,这样AI球拍就能以更刁钻的角度回击球。
🤖 右桨的“AI”
我最初尝试让电脑控制的球拍正常工作,只是让paddle.y球拍和球的位置保持一致ball.y。这作为一个起点效果不错,因为球和右球拍最终会同步,从而使右球拍能够稳定地回击球。
唯一的问题是,这样一来,左球拍就不可能得分了,因为右球拍总是会碰到球。
我最终找到了一种似乎效果不错的解决方案,即让右球拍尝试“追赶”球的位置:
updateRightPaddle : Ball -> Float -> Paddle -> Paddle
updateRightPaddle ball time paddle =
if ball.y > paddle.y then
{ paddle | y = paddle.y + paddle.vy * time }
else if ball.y < paddle.y then
{ paddle | y = paddle.y - paddle.vy * time }
else
paddle
如果球在屏幕上的位置较高,球拍就会向上“追击”;如果球的位置较低,球拍就会向下“追击”。然后,你可以调整右侧球拍的vy数值来增加或降低难度。我最终找到了一个合适的数值,既不容易得分,又不会完全无法得分。这样,用“AI”球拍得分会很有成就感。
🏆 获胜
说到得分,令人惊讶的是,要弄清楚这款街机游戏的获胜分数竟然如此困难。是10分?11分?15分?还是在规定时间内得分最高者获胜?
然后我找到了这张贴纸:
先得15分者获胜
但我很好奇,为什么这款街机游戏机的分数是贴在贴纸上的,而不是和说明书放在一起。
原来街机厅老板可以用一个物理开关来调整获胜分数。
这感觉像是唾手可得的成果,所以我把它作为游戏的一个选项添加了进去。
💯 球路历史
这感觉像是这版《Pong》的“杀手级应用” 。几年前我看过布雷特·维克多关于“基于原则的发明”的演讲,很喜欢游戏元素既有过去又有未来这个观点。但我从未想过这究竟是如何实现的。
使用 Elm 时,这一点就更明显一些,因为 Elm 调试器可以让人一窥值流随时间的变化情况。
为了可视化球的运动轨迹,我们可以先创建一个空列表。然后,每次球的位置发生变化时,我们就将球添加到列表的开头。
updateBallPath : Ball -> BallPath -> Maybe WindowEdge -> Model -> Model
updateBallPath ball ballPath maybeWindowEdge model =
case model.showBallPath of
Pong.Ball.Off ->
model
Pong.Ball.On ->
case maybeWindowEdge of
Just Pong.Window.Left ->
{ model | ballPath = [] }
Just Pong.Window.Right ->
{ model | ballPath = [] }
_ ->
{ model | ballPath = List.take 99 <| ball :: ballPath }
当球击中游戏窗口的左侧或右侧边缘时,我们会通过设置 `isClear` 来清除历史记录ballPath = []。其余时间,我们会通过在` ballisClear` 前面加上 `isClear` 来收集新的位置信息。在 LISP 和 Elm 等语言中,这个操作符通常被称为 `cons`,ballPath而不是 `prepend` 。::
最后,我们可以通过管道传输数据来List.take 99限制历史记录的数量,避免潜在的性能问题。话虽如此,我很好奇它在性能下降之前能够处理多少历史记录,你甚至可以围绕这个想法开发一个全新的游戏。
📈 演出
游戏开发相当棘手。如果我为大多数应用程序编写一个低效的函数,它只会偶尔运行一次,可能根本不会有人注意到。但如果我为游戏编写一个低效的函数,它每秒会运行大约 60 次。一直运行下去😅。
随着我不断开发更多游戏,我希望找到更好的性能分析和基准测试方法。我使用浏览器的开发者工具记录了一些性能分析数据,但很难看出效率低下的地方在哪里。
尝试不同的电脑和浏览器也很有意思。这篇文章是用我比较新的MacBook Pro笔记本电脑写的,但它在我的旧笔记本电脑上运行可能不太流畅。
目前,我viewFps在Util模块中添加了一个函数,该函数接受一个时间列表,并允许您使用切换按钮来显示和隐藏当前的每秒帧数。
🎨 波兰和声音
最后,我给背景添加了一些颜色,以致敬原版游戏机柜。我还用 SVG 格式重现了屏幕顶部的Pong字样。我还有一些其他有趣的想法,想在用户界面中加入更多游戏机柜的元素,但要实现这些想法需要一些时间。
还有一个名为howler.js的很棒的 JavaScript 库,可以为游戏添加音频。howler 的一些演示非常精彩。Elm 提供了“端口”来安全地集成 JavaScript 库。所以我添加了简单的音效beep.wav,boop.wav用于球击中球拍和边缘时发出声音。当碰撞发生时,它会触发端口并在浏览器中播放声音。如果您想查看示例,可以查看源代码。
💡 创意
游戏开发中最有趣的部分之一就是偶然发现一些很有意思、值得实现的小想法。
有时候你脑海中会冒出一个想法,想把它变成现实;有时候你无意中写出了一些糟糕的代码,却产生了有趣的结果。例如,我犯了一个错误,导致除非我移动挡板,否则球的运动就会停止。但这让我想到了像《Superhot》这样的游戏,在《Superhot》中,“时间只在你移动时才会流逝”。
我上面提到的球路历史记录功能就是一个很好的例子,它属于这种“+1”功能,虽然不是游戏的核心功能,但却为游戏增添了很多乐趣。
以下是我还有一些不同复杂程度的想法,如果有时间的话,我很想把它们付诸实践:
- 改变球的颜色。
- 添加碰撞粒子特效。
- 添加暂停状态。
- 动态改变球的宽度和高度。
- 多球模式。
- 多桨模式。
- “超热”模式,在该模式下,球只有在你移动时才会移动。
- 在结束画面状态下重播制胜一击。
- 本地多人游戏,其中一个玩家使用键盘,另一个玩家使用鼠标。
- 在线多人游戏(如果你开发了类似的功能,我很想看看!)。
🎓 经验教训
- 细节决定成败。调整球速和路径等设置对球的操控性影响巨大。精益求精!
- 用状态(使用联合类型)来思考,而不是用条件语句。你可以从小处着手,先从使用值过渡
Bool到使用 `and`On和 `Offswitch` 语句,然后再添加其他自定义状态。 - 重构有利有弊。它能清理代码,提高清晰度,这固然很好。但我把应用程序代码拆分成单独的文件和模块,却并没有改善开发体验。
- 找到平衡点。
- 轻轻地克制住想要放弃、去做其他新鲜事物的冲动。完成最初的90%很容易,但真正精彩的部分往往在最后的冲刺阶段。精雕细琢能让它从一次玩笑变成一个真正有趣的项目。
- 话虽如此,还是要找到一个合适的停顿点。我可能可以花一年时间不断开发各种带有炫酷功能的Pong版本。而且,实现上面列表中那些有趣的想法确实很诱人。但如果我们想要继续前进和发展,就必须找到一个合适的节点,及时发布。🚀
📈接下来是什么?
在重制Pong的过程中,有好几次我都想放弃,转而去做一些更复杂、更令人印象深刻的项目。还有的时候,我只想“快速”开发其他游戏,比如俄罗斯方块和贪吃蛇。但我还是会坚持我在本系列文章引言中概述的方法。
小时候,打砖块游戏一直是我最喜欢的雅达利游戏,所以能把它重新做一遍肯定很有意思。而且我已经为这个项目搭建好了所有基本机制,这让我有机会尝试不同的数据结构,并添加一些更有趣的功能,比如我在Pong游戏中没能实现的粒子特效。
🏫 资源
如果你正在学习函数式编程和游戏开发,欢迎随时联系我,我很乐意与你交流。你也可以在推特上找到我。
正如我承诺的那样,这里是游戏和源代码的链接!
文章来源:https://dev.to/bijanbwb/recreating-pong-for-the-web-with-elm-2bi8



















