This translation is community contributed and may not be up to date. We only maintain the English version of the documentation. Read this tutorial in English
Beginner
本教程会带您创建一个最常见、也最适合练习复刻的经典游戏。这个游戏有很多变体,本教程中的蛇会吃“食物”,并且只在吃到食物时变长。蛇还会在包含障碍物的游戏场地中爬行。
![]()
在本教程中,您将学习如何:
本教程面向初学者,但如果您完全没有 Defold 和游戏开发经验,建议先阅读一些入门手册,尤其是关于 Defold 构建块和术语表的内容。如果还没有下载 Defold,请查看安装手册。也建议查看编辑器概览,以便快速熟悉编辑器本身;本教程也会在每一步提供截图。
启动 Defold,然后:

完成!
我们先定义游戏分辨率。
game.project 文件。双击打开。game.project 文件的 Display 部分。Width 和 Height)设置为 768⨉768,或其他 16 的倍数。
这样做的原因是游戏会绘制在网格上,每个片段都是 16x16 像素,这样游戏画面不会裁掉任何不完整的片段。game.project 文件包含项目的所有重要设置,您可以在项目设置手册中阅读全部说明。
完成!
极简贪吃蛇克隆所需的图形很少:一个 16⨉16 的绿色蛇身片段,一个白色障碍物方块,以及一个表示食物的较小红色方块。
首先,在 Defold Editor 中为资源创建一个目录:
main 文件夹New Folder。assets 并点击 Create Folder。
完成!
下面这张图片是您唯一需要的资源:


您也可以在这里阅读更多导入资源的细节。
完成!
Defold 提供内置的 Tile Map 组件,您将用它创建由网格中对齐的瓦片组成的游戏场地。瓦片地图允许设置和读取单个瓦片,非常适合这个游戏。由于瓦片地图从 Tile Source 获取图形,因此需要先创建一个:
assets 文件夹。New ▸ Tile Source。snake.tilesource)。
Tile source 会在该文件类型专用的 Tile Source Editor 中打开,并会要求您为它提供一张图片才能使用。在右侧可以找到 Properties 面板:
将 Image 属性设置为刚刚导入的图形文件。

Width 和 Height 属性应保持为 16(默认值)。这会将 32⨉32 像素图片拆分为 4 个瓦片,编号为 1–4。

请注意,Extrude Borders 属性设置为 2 像素。这是为了防止图形一直延伸到边缘的瓦片周围出现视觉伪影。
如果您修改了文件,其标签页名称旁边会出现星号 *。选择 File ▸ Save All,或使用快捷键 Ctrl+S(Mac 上为 ⌘Cmd + S)保存所有文件。
完成!
现在 Tile Source 已可使用,是时候创建游戏场地的瓦片地图组件了:
Right click main 文件夹,并在 “Components” 部分选择 New ▸ Tile Map。将新文件命名为 “grid”(编辑器会将文件保存为 “grid.tilemap”)。

它会在 Tile Map Editor 中打开,并提示需要 Tile Source,因此将 Tile Source 属性设置为之前创建的 “snake.tilesource”。

完成!
Defold 只会存储瓦片地图中实际使用的区域,因此您需要添加足够的瓦片来填满屏幕边界。
Outline 面板中选择 layer1 图层。选择菜单项 Edit ▸ Select Tile...,或使用快捷键 Space 显示瓦片调色板,然后点击绘制时要使用的瓦片。


您需要 48x48 个瓦片大小的瓦片地图(因为显示尺寸是 768,瓦片是 16px,所以 768/16 = 48)来填满游戏屏幕。
完成后保存瓦片地图。
完成!
现在需要把瓦片地图添加到游戏中。如果您熟悉 Defold 构建块,会知道组件属于 Game Objects,而游戏对象可以定义在 Collections 中。
在 Assets 面板中双击 main.collection 打开它。在 Empty Project 模板中,它默认是引擎启动时加载的 bootstrap collection。
Right click Outline 中的根节点,并选择 Add Game Object,这会在游戏启动时加载的集合中创建一个新游戏对象。

Right click 新游戏对象,并选择 Add Component File。选择刚刚创建的 “grid.tilemap” 文件。

现在游戏集合中已经有瓦片地图了。从编辑器运行游戏时,它应当可见。
Project ▸ Build,或使用快捷键 Ctrl + B(Mac 上为 ⌘Cmd + B)。
完成!
在 Assets 浏览器中 Right click main 文件夹,并在 Scripts 部分选择 New ▸ Script。将新脚本文件命名为 “snake”(它会保存为 “snake.script”)。这个文件将包含游戏的所有逻辑。

回到 main.collection,并 right click 持有瓦片地图的游戏对象。选择 Add Component File,并选择 “snake.script” 文件。

现在瓦片地图组件和脚本都已经就位。
完成!
接下来编写的脚本将驱动整个游戏。我们会逐个添加功能。
工作方式的想法如下:
打开 snake.script 并找到 init() 函数。游戏启动时,当脚本初始化时,引擎会调用此函数。将代码修改为:
function init(self)
self.segments = { -- <1>
{x = 7, y = 24},
{x = 8, y = 24},
{x = 9, y = 24},
{x = 10, y = 24}
}
self.dir = {x = 1, y = 0} -- <2>
self.speed = 7.0 -- <3>
self.time = 0 -- <4>
end
在这段代码中:
self.segments 的 Lua 表,其中包含一组表,每个表保存一个片段的 X 和 Y 位置。self.dir 的表,其中保存 X 和 Y 方向。self.speed 中,以每秒移动多少个瓦片表示。self.time 中,用于跟踪移动速度。上面的脚本代码使用 Lua 语言编写。关于这段代码有几点值得注意,但如果暂时看不懂下面的内容也不用担心。先跟着做、实验并给自己一点时间,最终会理解。现在只需要记住:在 init() 中,我们初始化了接下来要使用的变量。
self 传入当前脚本组件实例的引用。self 引用用于存储实例数据。self 引用可以当作 Lua 表使用,您可以在其中存储数据。像使用任何其他表一样使用点号写法:self.data = "value"。该引用在脚本生命周期内有效,在本例中从游戏开始直到退出。{} 包围。{x = 10, y = 20})、嵌套 Lua 表({ {a = 1}, {b = 2} })或其他数据类型。完成!
init() 函数在脚本组件实例化到运行中的游戏时只调用一次。而 update() 函数会在每一帧调用一次。这使它非常适合实时游戏逻辑。
更新逻辑的想法是:按某个固定间隔执行以下操作:

:::sidenote 请记住,蛇头在表的末尾,蛇尾在表的开头。 :::
update() 函数,并将代码修改为:function update(self, dt)
self.time = self.time + dt -- <1>
if self.time >= 1.0 / self.speed then -- <2>
local head = self.segments[#self.segments] -- <3>
local newhead = {
x = head.x + self.dir.x,
y = head.y + self.dir.y
} -- <4>
table.insert(self.segments, newhead) -- <5>
local tail = table.remove(self.segments, 1) -- <6>
tilemap.set_tile("#grid", "layer1", tail.x, tail.y, 0) -- <7>
for i, s in ipairs(self.segments) do -- <8>
tilemap.set_tile("#grid", "layer1", s.x, s.y, 2) -- <9>
end
self.time = 0 -- <10>
end
end
在这段代码中:
update() 以来的时间差(秒)推进计时器,也就是所谓的 “delta time” 或 dt。# 是获取表长度的操作符,前提是该表按数组使用,本例中正是如此,所有片段都是没有指定键的表值。self.dir)创建新的蛇头片段。#grid 只有一个名为 layer1 的图层。i 是表中的位置(从 1 开始),s 是当前片段。现在运行游戏,您应该会看到 4 个片段长的蛇从左向右爬过游戏场地。

完成!
在添加响应玩家输入的代码之前,需要先设置输入连接。
input 文件夹中找到 game.input_binding 文件并 double click 打开。
输入绑定文件会将实际用户输入(按键、鼠标移动等)映射到动作名称,再传给请求输入的脚本。
完成!
绑定就绪后,打开 snake.script,在 init() 函数开头添加以下行:
function init(self)
msg.post(".", "acquire_input_focus") -- <1>
self.segments = {
{x = 7, y = 24},
{x = 8, y = 24},
{x = 9, y = 24},
{x = 10, y = 24}
}
self.dir = {x = 1, y = 0}
self.speed = 7.0
self.time = 0
end
新增的这一行:
然后找到 on_input 函数并输入以下代码:
function on_input(self, action_id, action)
if action_id == hash("up") and action.pressed then -- <1>
self.dir.x = 0 -- <2>
self.dir.y = 1
elseif action_id == hash("down") and action.pressed then
self.dir.x = 0
self.dir.y = -1
elseif action_id == hash("left") and action.pressed then
self.dir.x = -1
self.dir.y = 0
elseif action_id == hash("right") and action.pressed then
self.dir.x = 1
self.dir.y = 0
end
end
这些 if...elseif... 分支执行以下操作:
action 表的 pressed 字段为 true(玩家按下按键),那么:再次运行游戏,确认可以控制蛇。
完成!
现在请注意,如果同时按下两个键,会导致 on_input() 被调用两次,每个按键一次。按上面的写法,只有最后一次调用会影响蛇的方向,因为后续对 on_input() 的调用会覆盖 self.dir 中的值。
另外,如果蛇正在向左移动,而您按下 right 键,蛇会转向撞到自己。这个问题看似显而易见的修复方法,是在 on_input() 的 if 子句中增加额外条件:
if action_id == hash("up") and self.dir.y ~= -1 and action.pressed then
...
elseif action_id == hash("down") and self.dir.y ~= 1 and action.pressed then
...
然而,如果蛇正在向左移动,玩家在下一次移动步骤发生前快速先按 up,再按 right,只有 right 会生效,蛇会撞向自己。即使给上面的 if 子句添加条件,这个输入也会被忽略。这不好!
这个问题的正确解决方案是把输入存入队列,并在蛇移动时从队列取出条目:
function init(self)
msg.post(".", "acquire_input_focus")
self.segments = {
{x = 7, y = 24},
{x = 8, y = 24},
{x = 9, y = 24},
{x = 10, y = 24}
}
self.dir = {x = 1, y = 0}
self.speed = 7.0
self.time = 0
self.dirqueue = {} -- <1>
end
这一次,我们:
self.dirqueue,并将其初始化为空表。在 update() 函数中添加:
function update(self, dt)
self.time = self.time + dt
if self.time >= 1.0 / self.speed then
local newdir = table.remove(self.dirqueue, 1) -- <1>
if newdir then
local opposite = newdir.x == -self.dir.x or newdir.y == -self.dir.y -- <2>
if not opposite then
self.dir = newdir -- <3>
end
end
local head = self.segments[#self.segments]
local newhead = {x = head.x + self.dir.x, y = head.y + self.dir.y}
table.insert(self.segments, newhead)
local tail = table.remove(self.segments, 1)
tilemap.set_tile("#grid", "layer1", tail.x, tail.y, 0)
for i, s in ipairs(self.segments) do
tilemap.set_tile("#grid", "layer1", s.x, s.y, 2)
end
self.time = 0
end
end
newdir 不是 nil),则检查 newdir 是否指向 self.dir 的相反方向。并修改 on_input,改为将当前输入存入队列:
function on_input(self, action_id, action)
if action_id == hash("up") and action.pressed then
table.insert(self.dirqueue, {x = 0, y = 1}) -- <1>
elseif action_id == hash("down") and action.pressed then
table.insert(self.dirqueue, {x = 0, y = -1})
elseif action_id == hash("left") and action.pressed then
table.insert(self.dirqueue, {x = -1, y = 0})
elseif action_id == hash("right") and action.pressed then
table.insert(self.dirqueue, {x = 1, y = 0})
end
end
self.dir。启动游戏并确认它按预期运行。
完成!
蛇需要地图上的食物,才能变得更长、更快。来添加食物吧!
在 init() 函数上方添加一个新函数:
local function put_food(self) -- <1>
self.food = {x = math.random(2, 47), y = math.random(2, 47)} -- <2>
tilemap.set_tile("#grid", "layer1", self.food.x, self.food.y, 3) -- <3>
end
在此函数中:
put_food() 的新函数,用于在地图上放置一份食物。self.food 的变量中存储随机 X 和 Y 位置。然后在 init() 函数末尾调用它:
function init(self)
msg.post(".", "acquire_input_focus")
self.segments = {
{x = 7, y = 24},
{x = 8, y = 24},
{x = 9, y = 24},
{x = 10, y = 24}
}
self.dir = {x = 1, y = 0}
self.dirqueue = {}
self.speed = 7.0
self.time = 0
math.randomseed(socket.gettime()) -- <1>
put_food(self) -- <2>
end
math.random() 取随机值之前设置随机种子,否则会生成相同的随机值序列。这个种子只应设置一次。put_food() 函数,让玩家一开始就能在地图上看到一个食物。完成!
现在,检测蛇是否与某物碰撞,只需要查看蛇即将前往的瓦片地图位置上有什么,并做出反应。
添加一个变量,用于跟踪蛇是否还活着:
function init(self)
msg.post(".", "acquire_input_focus")
self.segments = {
{x = 7, y = 24},
{x = 8, y = 24},
{x = 9, y = 24},
{x = 10, y = 24}
}
self.dir = {x = 1, y = 0}
self.dirqueue = {}
self.speed = 7.0
self.time = 0
self.alive = true -- <1>
math.randomseed(socket.gettime())
put_food(self)
end
然后添加检测墙/障碍物和食物碰撞的逻辑:
function update(self, dt)
self.time = self.time + dt
if self.time >= 1.0 / self.speed and self.alive then -- <1>
local newdir = table.remove(self.dirqueue, 1)
if newdir then
local opposite = newdir.x == -self.dir.x or newdir.y == -self.dir.y
if not opposite then
self.dir = newdir
end
end
local head = self.segments[#self.segments]
local newhead = {x = head.x + self.dir.x, y = head.y + self.dir.y}
table.insert(self.segments, newhead)
local tile = tilemap.get_tile("#grid", "layer1", newhead.x, newhead.y) -- <2>
if tile == 2 or tile == 4 then
self.alive = false -- <3>
elseif tile == 3 then
self.speed = self.speed + 1 -- <4>
put_food(self)
else
local tail = table.remove(self.segments, 1) -- <5>
tilemap.set_tile("#grid", "layer1", tail.x, tail.y, 1)
end
for i, s in ipairs(self.segments) do
tilemap.set_tile("#grid", "layer1", s.x, s.y, 2)
end
self.time = 0
end
end
现在试试游戏,确认它玩起来正常!
本教程到此结束,但请继续尝试修改游戏,并完成下面的一些练习!
完成!
以下是完整脚本代码,供参考:
local function put_food(self)
self.food = {x = math.random(2, 47), y = math.random(2, 47)}
tilemap.set_tile("#grid", "layer1", self.food.x, self.food.y, 3)
end
function init(self)
msg.post(".", "acquire_input_focus")
self.segments = {
{x = 7, y = 24},
{x = 8, y = 24},
{x = 9, y = 24},
{x = 10, y = 24}
}
self.dir = {x = 1, y = 0}
self.dirqueue = {}
self.speed = 7.0
self.time = 0
self.alive = true
math.randomseed(socket.gettime())
put_food(self)
end
function update(self, dt)
self.time = self.time + dt
if self.time >= 1.0 / self.speed and self.alive then
local newdir = table.remove(self.dirqueue, 1)
if newdir then
local opposite = newdir.x == -self.dir.x or newdir.y == -self.dir.y
if not opposite then
self.dir = newdir
end
end
local head = self.segments[#self.segments]
local newhead = {x = head.x + self.dir.x, y = head.y + self.dir.y}
table.insert(self.segments, newhead)
local tile = tilemap.get_tile("#grid", "layer1", newhead.x, newhead.y)
if tile == 2 or tile == 4 then
self.alive = false
elseif tile == 3 then
self.speed = self.speed + 1
put_food(self)
else
local tail = table.remove(self.segments, 1)
tilemap.set_tile("#grid", "layer1", tail.x, tail.y, 1)
end
for i, s in ipairs(self.segments) do
tilemap.set_tile("#grid", "layer1", s.x, s.y, 2)
end
self.time = 0
end
end
function on_input(self, action_id, action)
if action_id == hash("up") and action.pressed then
table.insert(self.dirqueue, {x = 0, y = 1})
elseif action_id == hash("down") and action.pressed then
table.insert(self.dirqueue, {x = 0, y = -1})
elseif action_id == hash("left") and action.pressed then
table.insert(self.dirqueue, {x = -1, y = 0})
elseif action_id == hash("right") and action.pressed then
table.insert(self.dirqueue, {x = 1, y = 0})
end
end
尝试实现这些改进会是很好的练习: