用Defold做遊戲, 大家一起來推箱子吧…
序言
繼上次 Defold 初體驗之後,相信大家已經掌握了基礎操作——新增物件、製作動畫、加入音樂、處理輸入等核心功能應該都難不倒你了。
本專案將帶領你使用 Defold 引擎,從零開始打造一款經典的 倉庫番(Sokoban) 遊戲。我們將從專案建立、基礎設定與素材匯入一步步實作,讓你在完整遊戲開發流程中,進一步鞏固並深化 Defold 的應用能力。
作業環境
| 項目 | 版本 |
|---|---|
| macOS | Tahoe 26.5.2 |
| Defold | 1.13.1 |
相關素材
| 名稱 | 下載 |
|---|---|
| 圖片素材 | kenney.nl |
| 中文字型 | jf open 粉圓體 |
| 背景音樂 | Internet Archive |
| 過關音效 | OpenGameArt.org |
倉庫番(Sokoban)
專案設定
建立新專案
- 開啟 Defold IDE。
- 選擇空白專案模板(Empty Project)。
- 建立一個名為
defold-sokoban的新專案。
Defold IDE 提供簡體中文介面選項;不過為了讓後續教學中的選單名稱、設定欄位與官方文件一致,本教學將以英文介面為主。
設定 game.project
建立專案後,開啟根目錄中的 game.project。這個檔案集中管理專案的重要設定,例如專案名稱、版本、相依套件、顯示解析度與輸入設定等。
首先,在 Project 區段設定下列欄位:
Title:遊戲顯示名稱Version:專案版本Dependencies:外部套件與函式庫
其中,Dependencies 用於安裝 Defold 的外部依賴套件。本專案會先加入筆者製作的工具集 defold-utils。
https://github.com/William-Weng/defold-utils/archive/refs/tags/0.1.0.zip
也可以直接使用以下連結:
設定畫面尺寸
接著,在 game.project 的 Display 區段設定遊戲畫面大小:
Width: 384
Height: 384
本遊戲的關卡尺寸規劃為 6 x 6 格,每個格子的尺寸為 64 x 64 像素,因此總畫面尺寸為:384 × 384 像素。
下圖為 6 x 6 格關卡的基本配置概念:
下載圖片素材
本專案使用 Kenney 提供的 Sokoban 素材包。Kenney 的素材非常適合用於遊戲原型、教學與獨立遊戲開發。
可以從下列頁面下載:
下載完成後,將需要使用的圖片素材整理並放入專案的 assets 資料夾中,例如:
assets/
├── image/
│ ├── box.png
│ ├── floor.png
│ ├── player.png
│ ├── target.png
│ └── wall.png
└── ...
匯入圖片後,Defold 會自動將素材顯示在 Assets 面板中。後續可以將圖片加入 Atlas,並用於 GUI、Sprite 或 Tile Source。
建立地圖
建立 Tile Source
在匯入素材後,可以看到一張名為 tile_sheet.png 的圖片。它看起來像是將多個遊戲元件排列在同一張圖上的零件表,這類圖片通常稱為 sprite sheet 或 tile sheet。
在倉庫番遊戲中,我們會從這張圖片切出地板、牆壁與其他地圖元件,並將它們組合成關卡地圖。
- 在 Defold 的 Assets 面板中按右鍵。
- 選擇 New… → Tile Source。
- 建立一個名為
tiles.tilesource的 Tile Source。 - 在
tiles.tilesource的屬性面板中,將tile_sheet.png指定為來源圖片。 - 將 Tile 的尺寸設定為:
Tile Width: 64
Tile Height: 64
由於圖片素材中的每個元件都是 64 x 64 像素,設定完成後,Defold 就會自動將 tile_sheet.png 切割為多個獨立的 Tile。
這些 Tile 可以理解成遊戲地圖的「零件」,也常被稱為:
- Tile
- 瓦片
- 地圖格
- 拼圖元件
建立 Tile Map
接著建立真正用來繪製關卡的 Tile Map。
- 在 Assets 面板中按右鍵。
- 選擇 New… → Tile Map。
- 建立一個名為
level1.tilemap的 Tile Map。 - 在
level1.tilemap的屬性面板中,將 Tile Source 設定為剛才建立的:
tiles.tilesource
Tile Map 可以包含多個 Layer。將地圖元件拆分到不同 Layer,有助於後續管理繪製順序與碰撞邏輯。
本關卡建立兩個 Layer:
| Layer 名稱 | 用途 |
|---|---|
floor |
放置地板 Tile |
wall |
放置牆壁 Tile |
先選取 floor Layer,將地板 Tile 鋪滿整個關卡範圍;接著選取 wall Layer,再將牆壁 Tile 放置在關卡邊界與需要阻擋角色移動的位置。
使用 Tile Map 編輯器時:
- 按下
Space可以切換目前選取的 Tile。 - 選取空白 Tile 可以清除已經放置的 Tile。
- 切換 Layer 後,可以分別編輯地板與牆壁,而不會誤修改其他圖層。
這個操作方式很像在格狀畫布上貼貼紙:選取需要的 Tile,然後逐格放到地圖中。
加入主 Collection
完成 level1.tilemap 後,還需要將它加入遊戲的主 Collection,才能在執行時顯示出來。
- 開啟
main.collection。 - 新增一個 Game Object。
- 將 Game Object 的 Id 設定為:
level1_map
- 在這個 Game Object 上按右鍵,選擇 Add Component File。
- 選擇剛才建立的 Tile Map:
level1.tilemap
加入完成後,可以先執行 Project → Build,再按下執行按鈕測試成果。若設定正確,遊戲畫面中應該會出現剛才編輯完成的地圖。
建立可複用物件
接下來會建立遊戲中的主角物件。這個流程包含建立 Game Object、Sprite、Atlas、Script 與輸入綁定,是 Defold 開發中非常常見的基礎操作。
後續要建立箱子、牆壁、目標點或其他可互動角色時,也可以使用相同的方式,因此建議熟悉這個工作流程。
建立主角物件
首先建立主角所需的三個檔案:
| 檔案 | 類型 | 用途 |
|---|---|---|
player.go |
Game Object | 主角物件本體,用來組合 Sprite、Script 與其他元件 |
player.script |
Script | 主角行為邏輯,例如輸入、移動與碰撞判斷 |
player.atlas |
Atlas | 管理主角的圖片資源與動畫影格 |
建立完成後,依序進行以下設定:
- 開啟
player.atlas。 - 在 Atlas 中新增一個 Image。
- 將主角圖片
player.png加入 Atlas。 - 開啟
player.go。 - 在
player.go上新增一個 Sprite 元件。 - 將 Sprite 的
Image屬性設定為:
player.atlas
- 再於
player.go上新增一個 Component File。 - 將
player.script加入 Game Object。
完成後,player.go 就會包含主角的外觀與行為邏輯。可以將它理解成:
player.go
├── sprite
│ └── 使用 player.atlas 顯示主角圖片
└── player.script
└── 處理輸入與移動邏輯
設定輸入綁定
接著開啟 game.input_binding,設定玩家可以使用的鍵盤或手把操作。
在 Defold 中,輸入綁定會將實體按鍵對應為程式可辨識的 Action 名稱。程式不需要直接判斷使用者按下了哪一個鍵,而是根據你設定的 Action 名稱處理遊戲行為。
例如可以建立下列輸入動作:
| Action 名稱 | 建議按鍵 | 用途 |
|---|---|---|
left |
←、A |
主角向左移動 |
right |
→、D |
主角向右移動 |
up |
↑、W |
主角向上移動 |
down |
↓、S |
主角向下移動 |
注意:
game.input_binding中設定的 Action 名稱,必須與 Lua 程式中的名稱完全一致。
例如輸入設定使用left,程式碼就要使用hash("left")。
加入主場景
完成主角物件後,開啟 main.collection,將 player.go 加入主場景。
- 開啟
main.collection。 - 新增一個 Game Object,或直接將
player.go拖曳到 Collection 中。 - 確認主角的位置位於可行走的地板上。
- 將主角的
Position.z設定為大於地圖的值,例如:
Position.z = 1
地圖通常位於 z = 0。將主角設定為 z > 0,可以確保它會繪製在地圖上方,而不會被地板或牆壁遮住。
加入移動程式
最後,將主角移動程式加入 player.script,再執行 Project → Build 測試結果。
以下是一個最小可運作的移動範例。它會取得輸入焦點,並在按下方向鍵或 W、A、S、D 時移動主角。
local dprint = require("utility.debug_print")
-- 方向映射表:將輸入 action 的 hash 映射到對應的移動方向向量
local DIRECTIONS = {
[hash("up")] = vmath.vector3(0, 1, 0),
[hash("down")] = vmath.vector3(0, -1, 0),
[hash("left")] = vmath.vector3(-1, 0, 0),
[hash("right")] = vmath.vector3(1, 0, 0),
}
-- 地圖原點偏移量(世界座標)
local MAP_ORIGIN = vmath.vector3(32, 32, 0)
-- 每個 tile 的像素大小
local TILE_SIZE = 64
-- 玩家起始網格座標 (1, 1) 表示從原點偏移 1 個 tile
local START_POSITION = vmath.vector3(1, 1, 0)
function init(self)
-- 取得此 game object 的輸入焦點,才能接收 on_input 事件
msg.post(".", "acquire_input_focus")
-- 取得 Scene 編輯器中設定的初始位置
local editor_position = go.get_position(".")
-- 計算最終初始位置:編輯器位置 + 網格起始偏移 + 地圖原點偏移
-- 這樣可以保持與編輯器佈局兼容,同時支援網格座標系統
self.logical_position = editor_position + START_POSITION * TILE_SIZE + MAP_ORIGIN
-- 設定玩家的初始世界座標
go.set_position(self.logical_position)
-- 除錯輸出:確認初始化位置正確
dprint.print("Player initialized at: " .. tostring(self.logical_position))
end
function on_input(self, action_id, action)
-- 只在按鍵剛被按下的那一幀觸發(避免持續按住時重複移動)
if not action.pressed then
return false
end
-- 根據 action_id 查詢對應的方向向量
local direction = DIRECTIONS[action_id]
if direction then
dprint.print("Moving direction: " .. tostring(direction))
-- 更新邏輯位置:加上方向向量 × tile 大小
self.logical_position = self.logical_position + direction * TILE_SIZE
-- 使用動畫插值移動到目標位置
-- 參數:目標 game object, 屬性, 播放模式, 目標值, easing 函數, 持續時間 (秒), 延遲 (秒)
go.animate(".", "position", go.PLAYBACK_ONCE_FORWARD, self.logical_position, go.EASING_OUTQUAD, 0.25, 0)
return true
end
return false
end
這個版本只是用來驗證輸入設定與主角顯示是否正確,因此每次按鍵會直接移動 200 像素。後續製作倉庫番移動邏輯時,會改成每次移動一格,例如每格 64 像素,並加入牆壁、箱子與目標點的判斷。
Lua 函式宣告順序
Lua 是直譯式語言,程式通常會依照檔案由上到下的順序執行。因此,若你在某段程式中直接呼叫一個區域函式,該函式通常必須先宣告,否則可能會出現找不到函式的錯誤。
例如以下寫法是正確的:
local function move_player(self, x, y)
local position = go.get_position()
position.x = position.x + x
position.y = position.y + y
go.set_position(position)
end
function on_input(self, action_id, action)
if action_id == hash("move_left") and action.pressed then
move_player(self, -64, 0)
end
end
如果先呼叫 move_player(),卻在後面才以 local function move_player() 宣告,在 Lua 中可能會無法正確取得該區域函式。
建議維持以下程式排列方式:
1. local 常數與模組 require
2. local 輔助函式
3. init()
4. update()
5. on_input()
6. on_message()
7. final()
這樣不僅能避免函式宣告順序造成的問題,也能讓後續閱讀與維護程式更加清楚。
建立箱子與目標物件
接著建立倉庫番關卡中的箱子與目標點。這兩種物件的建立方式與主角相同,都是由 Game Object、Atlas 與 Sprite 組成。
本章先以手動放置物件的方式確認素材與顯示層級正確,接著再改用 Factory 動態建立關卡物件。
建立 box.go
首先建立箱子物件 box.go。
建立流程與 player.go 相同:
- 建立
box.atlas。 - 在
box.atlas中新增 Image。 - 將箱子圖片
box.png加入 Atlas。 - 建立
box.go。 - 在
box.go中新增 Sprite 元件。 - 將 Sprite 的
Image屬性指定為box.atlas。
完成後,box.go 會成為可重複使用的箱子預製物件。
box.go
└── sprite
└── 使用 box.atlas 顯示箱子圖片
建立 target.go
目標點 target.go 的建立方式也相同:
- 建立
target.atlas。 - 將目標點圖片
target.png加入 Atlas。 - 建立
target.go。 - 新增 Sprite 元件。
- 將 Sprite 的
Image屬性指定為target.atlas。
target.go
└── sprite
└── 使用 target.atlas 顯示目標點圖片
一般來說,目標點應位於箱子下方,因此可以將其 Position.z 設定為比箱子更低的值,例如:
target.go: z = 1
box.go: z = 2
player.go: z = 3
如此一來,即使箱子移動到目標點上,目標圖案仍可以在箱子下方正確呈現。
手動測試箱子
完成 box.go 後,可以先開啟 main.collection,手動將兩個 box.go 加入主場景,並設定它們的位置。
這個步驟主要是確認:
box.go是否能正常顯示。- 箱子的圖片尺寸是否符合 (64 \times 64) 格子。
- 箱子的 Z 軸層級是否位於地圖上方。
- 箱子的位置是否與 Tile Map 對齊。
確認畫面正確後,可以先執行一次遊戲測試。
使用 Factory 動態建立物件
手動將主角、箱子與目標點拖曳到 main.collection,適合用於初步測試;但當關卡數量增加、箱子位置不同,或需要重新開始關卡時,手動管理物件會變得非常繁瑣。
Defold 提供 Factory 元件,可以在遊戲執行期間動態建立 Game Object。可以將它理解成「預製物件產生器」:
Factory
↓
factory.create()
↓
建立一個新的 Game Object 實例
這種做法特別適合倉庫番,因為每個關卡都會有不同數量與位置的:
- 主角
- 箱子
- 目標點
- 其他互動物件
建立 game.go
首先建立一個負責載入關卡與建立遊戲物件的 game.go。
在 game.go 中加入 game.script,並新增三個 Factory 元件:
| Factory Id | Prototype | 用途 |
|---|---|---|
player_factory |
player.go |
動態建立主角 |
box_factory |
box.go |
動態建立箱子 |
target_factory |
target.go |
動態建立目標點 |
Factory 的 Prototype 屬性必須指定對應的 Game Object 檔案:
player_factory → player.go
box_factory → box.go
target_factory → target.go
完成後,game.go 的結構會類似:
game.go
├── game.script
├── player_factory
│ └── Prototype: player.go
├── box_factory
│ └── Prototype: box.go
└── target_factory
└── Prototype: target.go
複用物件
接下來會使用程式建立關卡中的主角、箱子與目標點,而不是預先將它們逐一放入 main.collection。
這樣做的好處是:
- 同一個
player.go可以套用在所有關卡。 - 每一關可以有不同數量的箱子與目標點。
- 關卡重置時,可以刪除舊物件後重新建立。
- 關卡資料與視覺物件分離,更容易維護與除錯。
- 新增關卡時,只需要修改關卡資料,不需要手動編輯 Collection。
以下是本章會使用到的檔案:
| 檔案 | 職責 |
|---|---|
player.script |
處理主角輸入、格子移動與推箱子行為 |
levels.lua |
保存各關卡的地圖、牆壁、主角、箱子與目標點資料 |
level_manager.lua |
載入關卡、建立物件、管理箱子位置與判斷通關 |
game.script |
呼叫 Factory,根據關卡資料動態建立主角、箱子與目標點 |
Factory 建立物件概念
以下範例示範如何在 game.script 中建立一個箱子:
local TILE_SIZE = 64
local function grid_to_world_position(x, y, z)
return vmath.vector3(
(x - 1) * TILE_SIZE,
(y - 1) * TILE_SIZE,
z or 0
)
end
local function create_box(self, x, y)
local position = grid_to_world_position(x, y, 2)
return factory.create(
"#box_factory",
position
)
end
function init(self)
create_box(self, 3, 2)
create_box(self, 4, 3)
end
factory.create() 會回傳新建立物件的 URL。建議將回傳值保存起來,後續才能移動、傳送訊息或在重新開始關卡時刪除物件:
self.boxes = {}
local box_id = factory.create(
"#box_factory",
grid_to_world_position(3, 2, 2)
)
table.insert(self.boxes, {
id = box_id,
x = 3,
y = 2
})
factory.create()產生的是新的 Game Object 實例。
每一個箱子都有自己的位置、URL 與狀態;不要只保存一個box_id,否則後建立的箱子會覆蓋前一個參考。
程式碼配置
下一節將依照下列職責分配程式碼:
main/
├── game.go
├── game.script
└── levels/
├── levels.lua
└── level_manager.lua
objects/
├── player.go
├── player.script
├── box.go
└── target.go
levels.lua:定義關卡靜態資料,例如牆壁、箱子、目標點與主角起始位置。level_manager.lua:將關卡資料轉換成遊戲狀態,管理座標、箱子、牆壁、重置與通關判斷。game.script:使用 Factory 根據關卡資料建立視覺物件。player.script:接收玩家輸入,向game.go或level_manager.lua請求移動,避免主角 script 自己直接管理整張地圖。
以下是完整程式碼
- 主角:player.script
-- 載入自訂除錯輸出工具
-- 之後可使用 dprint.print(...) 在 Console 顯示訊息
local dprint = require("utility.debug_print")
-- 載入關卡管理模組
-- 負責玩家、箱子、目標點、移動、碰撞與過關判斷
local level_manager = require "main.levels.level_manager"
-- 載入關卡資料模組
-- 用於取得關卡總數 levels.count()
local levels = require "main.levels.levels"
-- 當此 Game Object / Script 初始化時呼叫一次
function init(self)
-- 將場景內的 Factory 元件 URL 集中保存。
-- 這些 URL 會傳給 level_manager.load(),
-- 由關卡管理器動態建立玩家、箱子與目標點。
self.factories = {
player = "#player_factory", -- 建立玩家 Game Object 的 factory
box = "#box_factory", -- 建立箱子 Game Object 的 factory
target = "#target_factory", -- 建立目標點 Game Object 的 factory
}
-- 目前關卡索引,從第 1 關開始
self.level_index = 1
-- 設定畫面投影模式:
-- 固定遊戲畫面的寬高比,畫面會盡可能填滿視窗。
-- 若視窗比例不同,剩餘區域會以黑邊顯示,
-- 避免遊戲畫面被拉伸變形。
msg.post("@render:", "use_fixed_fit_projection", {
near = -1, -- 投影近平面 Z 值
far = 1, -- 投影遠平面 Z 值
})
-- 載入第一關
-- level_manager 會依據關卡資料建立玩家、箱子與目標點。
level_manager.load(self.level_index, self.factories)
end
-- 當目前 Game Object 收到訊息時呼叫
--
-- message_id: 訊息名稱的 hash,例如 hash("try_move")
-- message: 訊息攜帶的資料,例如 { x = 1, y = 0 }
function on_message(self, message_id, message)
-- 只處理 "try_move" 訊息。
-- 其他訊息直接忽略,避免後續程式不必要地執行。
if message_id ~= hash("try_move") then
return
end
-- 嘗試移動玩家。
--
-- message.x 和 message.y 是移動方向,例如:
-- { x = 1, y = 0 }:往右
-- { x = -1, y = 0 }:往左
-- { x = 0, y = 1 }:往上
-- { x = 0, y = -1 }:往下
--
-- try_move() 成功移動時回傳 true;
-- 撞牆、無法推動箱子時則回傳 false。
local moved = level_manager.try_move(message.x, message.y)
-- 只有本次確實成功移動後,才檢查是否完成關卡
-- 這能避免玩家撞牆或推不動箱子時,重複執行過關判斷
if moved and level_manager.is_complete() then
-- 在 Console 輸出目前關卡完成訊息
dprint.print("Level " .. self.level_index .. " complete!")
-- 將關卡索引加一,準備前往下一關
self.level_index = self.level_index + 1
-- 若下一關仍存在,就載入下一關
-- 沒有更多關卡時,輸出全部完成訊息
if self.level_index <= levels.count() then
level_manager.load(self.level_index, self.factories)
else
dprint.print("All levels complete!")
end
end
end
- 關卡資料:levels.lua
-- levels.lua
local M = {}
M.all = {
{
id = "level-1",
width = 6,
height = 6,
player = { x = 2, y = 2 },
boxes = {
{ x = 4, y = 3 },
{ x = 3, y = 3 },
},
targets = {
{ x = 4, y = 2 },
{ x = 5, y = 3 },
},
walls = {
["1,1"] = true,
["2,1"] = true,
["3,1"] = true,
["4,1"] = true,
["5,1"] = true,
["6,1"] = true,
["1,2"] = true,
["6,2"] = true,
["1,3"] = true,
["6,3"] = true,
["1,4"] = true,
["6,4"] = true,
["1,5"] = true,
["6,5"] = true,
["1,6"] = true,
["2,6"] = true,
["3,6"] = true,
["4,6"] = true,
["5,6"] = true,
["6,6"] = true,
},
},
}
function M.get(index)
return M.all[index]
end
function M.count()
return #M.all
end
return M
- 關卡動作管理器:level_manager.lua
-- level_manager.lua
-- 載入關卡資料模組
local levels = require "main.levels.levels"
-- MARK: 建立模組表格 M
local M = {}
-- 常數設定
M.TIME_INTERVAL = 0.25 -- 移動動畫時間(秒)
M.TILE_SIZE = 64 -- 每個格子的像素大小
M.MAP_ORIGIN = vmath.vector3(32, 32, 0) -- 地圖原點偏移(讓格子對齊)
-- MRAK: 遊戲狀態
M.current_index = 1 -- 當前關卡索引
M.current = nil -- 當前關卡資料(從 levels 載入)
M.player = nil -- 玩家邏輯位置 {x, y}
M.player_id = nil -- 玩家遊戲物件 ID
M.boxes = {} -- 所有箱子的列表 {id, x, y}
M.targets = {} -- 所有目標點的列表 {id, x, y}
-- MARK: 工具函式
-- 將網格座標轉成字串 key,用於 walls 表格的快速查詢
function M.key(x, y)
return x .. "," .. y
end
-- 將網格座標 (x, y, z) 轉換成世界座標
-- x, y 從 1 開始,z 預設為 1(玩家/箱子層),目標點在 0.5
function M.grid_to_world(x, y, z)
local real_x = (x - 1) * M.TILE_SIZE
local real_y = (y - 1) * M.TILE_SIZE
local real_z = z or 1
return M.MAP_ORIGIN + vmath.vector3(real_x, real_y, real_z)
end
-- 檢查 (x, y) 是否在當前關卡範圍內
function M.is_inside_map(x, y)
if x < 1 then return false end
if y < 1 then return false end
if x > M.current.width then return false end
if y > M.current.height then return false end
return true
end
-- 檢查 (x, y) 是否是牆壁
-- 超出地圖範圍也視為牆壁
function M.is_wall(x, y)
if not M.is_inside_map(x, y) then return true end
return M.current.walls[M.key(x, y)] == true
end
-- 檢查 (x, y) 是否有箱子,有則回傳箱子物件
function M.box_at(x, y)
for _, box in ipairs(M.boxes) do
if box.x == x and box.y == y then return box end
end
return nil
end
-- 檢查 (x, y) 是否是目標點
function M.is_target(x, y)
for _, target in ipairs(M.targets) do
if target.x == x and target.y == y then return true end
end
return false
end
-- 檢查關卡是否完成:所有箱子都在目標點上
function M.is_complete()
-- 箱子數量必須等於目標點數量
if #M.boxes ~= #M.targets then
return false
end
-- 每個箱子都必須在目標點上
for _, box in ipairs(M.boxes) do
if not M.is_target(box.x, box.y) then return false end
end
return true
end
-- MARK: 關卡管理
-- 清除當前關卡的所有遊戲物件
function M.clear()
-- 刪除玩家物件
if M.player_id then
go.delete(M.player_id)
M.player_id = nil
end
-- 刪除所有箱子物件
for _, box in ipairs(M.boxes) do
go.delete(box.id)
end
-- 刪除所有目標點物件
for _, target in ipairs(M.targets) do
go.delete(target.id)
end
-- 重置狀態
M.boxes = {}
M.targets = {}
M.player = nil
end
-- 載入指定索引的關卡
-- factories: {player, box, target} 對應的 factory 路徑
function M.load(index, factories)
-- 先清除舊關卡
M.clear()
-- 設定當前關卡索引並載入關卡資料
M.current_index = index
M.current = assert(levels.get(index), "Missing level at index: " .. index)
-- 初始化玩家位置
M.player = { x = M.current.player.x, y = M.current.player.y }
-- 建立玩家遊戲物件
M.player_id = factory.create(factories.player, M.grid_to_world(M.player.x, M.player.y, 1))
-- 建立所有目標點物件
for _, data in ipairs(M.current.targets) do
local id = factory.create(factories.target, M.grid_to_world(data.x, data.y, 0.5))
local target_info = { id = id, x = data.x, y = data.y }
table.insert(M.targets, target_info)
end
-- 建立所有箱子物件
for _, data in ipairs(M.current.boxes) do
local id = factory.create(factories.box, M.grid_to_world(data.x, data.y, 1))
local box_info = { id = id, x = data.x, y = data.y }
table.insert(M.boxes, box_info)
end
end
-- MARK: 移動邏輯
-- 播放移動動畫
local function move_animation(id, position)
go.animate(
id,
"position",
go.PLAYBACK_ONCE_FORWARD,
position,
go.EASING_OUTQUAD,
M.TIME_INTERVAL,
0
)
end
-- 嘗試移動玩家 (dx, dy) 是方向增量
function M.try_move(dx, dy)
-- 計算下一步位置
local next_x = M.player.x + dx
local next_y = M.player.y + dy
-- 如果下一步是牆壁,不能移動
if M.is_wall(next_x, next_y) then
return false
end
-- 檢查下一步是否有箱子
local box = M.box_at(next_x, next_y)
-- 沒有箱子:直接移動玩家
if not box then
M.player.x = next_x
M.player.y = next_y
move_animation(M.player_id, M.grid_to_world(M.player.x, M.player.y, 1))
return true
end
-- 有箱子:嘗試推動箱子
local box_next_x = box.x + dx
local box_next_y = box.y + dy
-- 箱子下一步是牆壁,不能推
if M.is_wall(box_next_x, box_next_y) then
return false
end
-- 箱子下一步有其他箱子,不能推
if M.box_at(box_next_x, box_next_y) then
return false
end
-- 可以推:更新箱子和玩家位置
box.x = box_next_x
box.y = box_next_y
M.player.x = next_x
M.player.y = next_y
-- 播放箱子和玩家的移動動畫
move_animation(box.id, M.grid_to_world(box.x, box.y, 1))
move_animation(M.player_id, M.grid_to_world(M.player.x, M.player.y, 1))
return true
end
-- 回傳模組
return M
- 程式碼補上之後,馬上就可以來試試看了,到這裡已經算是完成了,接下來就是優化的部分了。
使用 JSON 檔案儲存關卡資訊
前面的範例可以直接使用 Lua table 建立關卡資料,但當關卡數量增加後,若將所有資料都寫在 levels.lua 裡,會逐漸變得不容易閱讀與維護。
改用 JSON 檔案儲存關卡資訊,可以將「關卡資料」與「遊戲程式」分開管理。即使不熟悉 Lua,也能透過編輯 JSON 檔案新增關卡、調整主角位置、修改箱子位置或重新安排目標點。
本章的核心流程如下:
levels.json
↓
sys.load_resource()
↓
JSON 字串
↓
json.decode()
↓
Lua table
↓
level_data.lua / level_manager.lua
Defold 的 sys.load_resource() 會將自訂資源讀取為字串,而 json.decode() 可以將 JSON 字串轉換成 Lua table。轉換後的資料就可以像原本的 levels.lua 一樣使用。Defold 要求透過 sys.load_resource() 動態讀取的檔案,必須先設定為 Custom Resources,才能被包含在最終建置檔中。[45][46]
建立 levels.json
先在專案中建立關卡資料夾與 JSON 檔案:
assets/
└── file/
└── levels.json
以下是一個簡單的 levels.json 範例:
{
"level_1_1": {
"id": "level_1_1",
"width": 6,
"height": 6,
"player": {
"x": 2,
"y": 2
},
"boxes": [
{ "x": 3, "y": 2 },
{ "x": 3, "y": 3 }
],
"targets": [
{ "x": 4, "y": 2 },
{ "x": 5, "y": 3 }
],
"walls": [
{ "x": 1, "y": 1 },
{ "x": 2, "y": 1 },
{ "x": 3, "y": 1 },
{ "x": 4, "y": 1 },
{ "x": 5, "y": 1 },
{ "x": 6, "y": 1 },
{ "x": 1, "y": 2 },
{ "x": 6, "y": 2 },
{ "x": 1, "y": 3 },
{ "x": 6, "y": 3 },
{ "x": 1, "y": 4 },
{ "x": 6, "y": 4 },
{ "x": 1, "y": 5 },
{ "x": 6, "y": 5 },
{ "x": 1, "y": 6 },
{ "x": 2, "y": 6 },
{ "x": 3, "y": 6 },
{ "x": 4, "y": 6 },
{ "x": 5, "y": 6 },
{ "x": 6, "y": 6 }
]
}
}
關卡資料中包含:
| 欄位 | 用途 |
|---|---|
id |
關卡識別名稱 |
width |
關卡寬度,以格子為單位 |
height |
關卡高度,以格子為單位 |
player |
主角的起始格子位置 |
boxes |
所有箱子的初始位置 |
targets |
所有目標點的位置 |
walls |
所有牆壁的位置 |
注意:JSON 不支援註解,也不允許最後一個陣列元素或物件欄位後方保留逗號。
若 JSON 格式錯誤,json.decode()將無法正確轉換資料。
設定 Custom Resources
建立 levels.json 後,最重要的步驟是將檔案或資料夾加入 game.project 的 Custom Resources。
在 Defold 中,圖片、Atlas、Tile Source、Game Object 等資源通常會因為被其他資源直接引用而自動打包;但 JSON、TXT、CSV 等以程式動態讀取的檔案,Defold 無法自動判斷是否需要包含在遊戲建置檔中。
因此,必須手動設定 Custom Resources。
- 開啟
game.project。 - 找到 Project 區段。
- 找到 Custom Resources 欄位。
- 輸入要包含的檔案或資料夾路徑。
如果只需要包含單一 JSON 檔案:
assets/file/levels.json
若未來會增加多個 JSON 關卡檔案,建議直接加入整個資料夾:
assets/file/
加入資料夾後,該資料夾及其子資料夾中的檔案都會被遞迴包含進遊戲建置檔中。
注意:Custom Resources 設定的路徑不需要以
/開頭;但在程式中使用sys.load_resource()載入時,必須使用從專案根目錄開始的完整資源路徑,例如/assets/file/levels.json。
讀取與轉換 JSON
接著建立 level_data.lua,負責讀取 levels.json 並轉換成 Lua table。
檔案位置可以安排為:
main/
└── levels/
└── level_data.lua
程式碼如下:
local M = {}
local RESOURCE_PATH = "/assets/file/levels.json"
local cached_levels = nil
local function wall_key(x, y)
return x .. "," .. y
end
local function normalize_level(level)
local walls = {}
for _, wall in ipairs(level.walls or {}) do
walls[wall_key(wall.x, wall.y)] = true
end
assert(
#(level.boxes or {}) == #(level.targets or {}),
"Level '" .. level.id .. "' must have the same number of boxes and targets"
)
return {
id = level.id,
width = level.width,
height = level.height,
player = {
x = level.player.x,
y = level.player.y,
},
boxes = level.boxes or {},
targets = level.targets or {},
-- JSON 用陣列儲存座標;
-- 遊戲執行時轉成查詢快的 key-value table。
walls = walls,
}
end
function M.load_all()
if cached_levels then
return cached_levels
end
local raw_json, error_message = sys.load_resource(RESOURCE_PATH)
assert(
raw_json,
"Cannot load level JSON: " .. tostring(error_message)
)
local decoded = json.decode(raw_json)
assert(decoded and decoded.levels, "Invalid levels JSON: missing 'levels'")
cached_levels = {}
for _, level in ipairs(decoded.levels) do
table.insert(cached_levels, normalize_level(level))
end
return cached_levels
end
function M.get(index)
return M.load_all()[index]
end
function M.count()
return #M.load_all()
end
return M
程式主要做了三件事:
- 透過
sys.load_resource()讀取/assets/file/levels.json。 - 使用
json.decode()將 JSON 字串轉成 Lua table。 - 提供
load_all()與get(level_id)兩個函式,供其他程式取得全部關卡或單一關卡資料。
在 level_manager.lua 中使用
在 level_manager.lua 中引入 level_data.lua:
-- 載入關卡資料模組
local levels = require "main.levels.level_data"
-- MARK: 建立模組表格 M
local M = {}
-- 常數設定
M.TIME_INTERVAL = 0.25 -- 移動動畫時間(秒)
M.TILE_SIZE = 64 -- 每個格子的像素大小
M.MAP_ORIGIN = vmath.vector3(32, 32, 0) -- 地圖原點偏移(讓格子對齊)
-- MRAK: 遊戲狀態
M.current_index = 1 -- 當前關卡索引
M.current = nil -- 當前關卡資料(從 levels 載入)
M.player = nil -- 玩家邏輯位置 {x, y}
M.player_id = nil -- 玩家遊戲物件 ID
M.boxes = {} -- 所有箱子的列表 {id, x, y}
M.targets = {} -- 所有目標點的列表 {id, x, y}
-- MARK: 工具函式
-- 將網格座標轉成字串 key,用於 walls 表格的快速查詢
function M.key(x, y)
return x .. "," .. y
end
-- 取得所有關卡的總數量
function M.level_count()
return levels.count()
end
-- 將網格座標 (x, y, z) 轉換成世界座標
-- x, y 從 1 開始,z 預設為 1(玩家/箱子層),目標點在 0.5
function M.grid_to_world(x, y, z)
local x = (x - 1) * M.TILE_SIZE
local y = (y - 1) * M.TILE_SIZE
local z = z or 1
return M.MAP_ORIGIN + vmath.vector3(x, y, z)
end
-- 檢查 (x, y) 是否在當前關卡範圍內
function M.is_inside_map(x, y)
if x < 1 then return false end
if y < 1 then return false end
if x > M.current.width then return false end
if y > M.current.height then return false end
return true
end
-- 檢查 (x, y) 是否是牆壁
-- 超出地圖範圍也視為牆壁
function M.is_wall(x, y)
if not M.is_inside_map(x, y) then return true end
return M.current.walls[M.key(x, y)] == true
end
-- 檢查 (x, y) 是否有箱子,有則回傳箱子物件
function M.box_at(x, y)
for _, box in ipairs(M.boxes) do
if box.x == x and box.y == y then return box end
end
return nil
end
-- 檢查 (x, y) 是否是目標點
function M.is_target(x, y)
for _, target in ipairs(M.targets) do
if target.x == x and target.y == y then return true end
end
return false
end
-- 檢查關卡是否完成:所有箱子都在目標點上
function M.is_complete()
-- 箱子數量必須等於目標點數量
if #M.boxes ~= #M.targets then
return false
end
-- 每個箱子都必須在目標點上
for _, box in ipairs(M.boxes) do
if not M.is_target(box.x, box.y) then return false end
end
return true
end
-- MARK: 關卡管理
-- 清除當前關卡的所有遊戲物件
function M.clear()
-- 刪除玩家物件
if M.player_id then
go.delete(M.player_id)
M.player_id = nil
end
-- 刪除所有箱子物件
for _, box in ipairs(M.boxes) do
go.delete(box.id)
end
-- 刪除所有目標點物件
for _, target in ipairs(M.targets) do
go.delete(target.id)
end
-- 重置狀態
M.boxes = {}
M.targets = {}
M.player = nil
end
-- 載入指定索引的關卡
-- factories: {player, box, target} 對應的 factory 路徑
function M.load(index, factories)
-- 先清除舊關卡
M.clear()
-- 設定當前關卡索引並載入關卡資料
M.current_index = index
M.current = assert(levels.get(index), "Missing level at index: " .. index)
-- 初始化玩家位置
M.player = { x = M.current.player.x, y = M.current.player.y }
-- 建立玩家遊戲物件
M.player_id = factory.create(factories.player, M.grid_to_world(M.player.x, M.player.y, 1))
-- 建立所有目標點物件
for _, data in ipairs(M.current.targets) do
local id = factory.create(factories.target, M.grid_to_world(data.x, data.y, 0.5))
local target_info = { id = id, x = data.x, y = data.y }
table.insert(M.targets, target_info)
end
-- 建立所有箱子物件
for _, data in ipairs(M.current.boxes) do
local id = factory.create(factories.box, M.grid_to_world(data.x, data.y, 1))
local box_info = { id = id, x = data.x, y = data.y }
table.insert(M.boxes, box_info)
end
end
-- MARK: 移動邏輯
-- 播放移動動畫
local function move_animation(id, position)
go.animate(
id,
"position",
go.PLAYBACK_ONCE_FORWARD,
position,
go.EASING_OUTQUAD,
M.TIME_INTERVAL,
0
)
end
-- 嘗試移動玩家 (dx, dy) 是方向增量
function M.try_move(dx, dy)
-- 計算下一步位置
local next_x = M.player.x + dx
local next_y = M.player.y + dy
-- 如果下一步是牆壁,不能移動
if M.is_wall(next_x, next_y) then
return false
end
-- 檢查下一步是否有箱子
local box = M.box_at(next_x, next_y)
-- 沒有箱子:直接移動玩家
if not box then
M.player.x = next_x
M.player.y = next_y
move_animation(M.player_id, M.grid_to_world(M.player.x, M.player.y, 1))
return true
end
-- 有箱子:嘗試推動箱子
local box_next_x = box.x + dx
local box_next_y = box.y + dy
-- 箱子下一步是牆壁,不能推
if M.is_wall(box_next_x, box_next_y) then
return false
end
-- 箱子下一步有其他箱子,不能推
if M.box_at(box_next_x, box_next_y) then
return false
end
-- 可以推:更新箱子和玩家位置
box.x = box_next_x
box.y = box_next_y
M.player.x = next_x
M.player.y = next_y
-- 播放箱子和玩家的移動動畫
move_animation(box.id, M.grid_to_world(box.x, box.y, 1))
move_animation(M.player_id, M.grid_to_world(M.player.x, M.player.y, 1))
return true
end
-- 回傳模組
return M
執行後,level 就是一個 Lua table,使用方式與原本直接寫在 levels.lua 的資料相同:
local player_x = level.player.x
local player_y = level.player.y
for _, box in ipairs(level.boxes) do
print("箱子位置:", box.x, box.y)
end
因此,後續的 level_manager.lua 不需要關心資料原本來自 Lua 檔案或 JSON 檔案;它只需要接收並處理標準化的 Lua table。
載入時機建議
目前的 M.get() 每次呼叫都會重新讀取並解析 levels.json。關卡數量少時沒有問題,但通常可以在第一次載入後快取資料,避免每次切換關卡都重複讀取與解析。
local json = require "json"
local M = {}
local LEVELS_PATH = "/assets/file/levels.json"
local cached_levels = nil
function M.load()
if cached_levels then
return cached_levels
end
local content, error = sys.load_resource(LEVELS_PATH)
assert(content,
"無法讀取關卡檔案: "
.. LEVELS_PATH
.. "\n錯誤訊息: "
.. tostring(error)
)
local levels, decode_error = json.decode(content)
assert(levels,
"無法解析 JSON 關卡資料: "
.. tostring(decode_error)
)
cached_levels = levels
return cached_levels
end
function M.get(level_id)
local level = M.load()[level_id]
assert(level,
"找不到關卡: "
.. tostring(level_id)
)
return level
end
return M
這樣 levels.json 只會在第一次使用時讀取一次。當玩家重新開始關卡時,請使用原始關卡資料建立新的遊戲狀態,避免直接修改快取中的原始關卡資料。
sys.load_resource()讀取的是已經打包進遊戲資料中的自訂資源,內容以字串形式回傳。若讀取失敗,函式會回傳nil與錯誤訊息;最常見原因是漏設 Custom Resources,或程式使用的資源路徑與實際檔案位置不一致。
game.script使用
- 其實在
game.script程式碼沒有什麼大變動,只差在level_manager.level_count(),關卡總數量的取得。
-- 載入自訂除錯輸出工具
-- 之後可使用 dprint.print(...) 在 Console 顯示訊息
local dprint = require("utility.debug_print")
-- 載入關卡管理模組
-- 負責玩家、箱子、目標點、移動、碰撞與過關判斷
local level_manager = require "main.levels.level_manager"
-- 當此 Game Object / Script 初始化時呼叫一次
function init(self)
-- 將場景內的 Factory 元件 URL 集中保存。
-- 這些 URL 會傳給 level_manager.load(),
-- 由關卡管理器動態建立玩家、箱子與目標點。
self.factories = {
player = "#player_factory", -- 建立玩家 Game Object 的 factory
box = "#box_factory", -- 建立箱子 Game Object 的 factory
target = "#target_factory", -- 建立目標點 Game Object 的 factory
}
-- 目前關卡索引,從第 1 關開始
self.level_index = 1
-- 設定畫面投影模式:
-- 固定遊戲畫面的寬高比,畫面會盡可能填滿視窗。
-- 若視窗比例不同,剩餘區域會以黑邊顯示,
-- 避免遊戲畫面被拉伸變形。
msg.post("@render:", "use_fixed_fit_projection", {
near = -1, -- 投影近平面 Z 值
far = 1, -- 投影遠平面 Z 值
})
-- 載入第一關
-- level_manager 會依據關卡資料建立玩家、箱子與目標點。
level_manager.load(self.level_index, self.factories)
end
-- 當目前 Game Object 收到訊息時呼叫
--
-- message_id: 訊息名稱的 hash,例如 hash("try_move")
-- message: 訊息攜帶的資料,例如 { x = 1, y = 0 }
function on_message(self, message_id, message)
-- 只處理 "try_move" 訊息。
-- 其他訊息直接忽略,避免後續程式不必要地執行。
if message_id ~= hash("try_move") then
return
end
-- 嘗試移動玩家。
--
-- message.x 和 message.y 是移動方向,例如:
-- { x = 1, y = 0 }:往右
-- { x = -1, y = 0 }:往左
-- { x = 0, y = 1 }:往上
-- { x = 0, y = -1 }:往下
--
-- try_move() 成功移動時回傳 true;
-- 撞牆、無法推動箱子時則回傳 false。
local moved = level_manager.try_move(message.x, message.y)
-- 只有本次確實成功移動後,才檢查是否完成關卡
-- 這能避免玩家撞牆或推不動箱子時,重複執行過關判斷
if moved and level_manager.is_complete() then
-- 在 Console 輸出目前關卡完成訊息
dprint.print("Level " .. self.level_index .. " complete!")
-- 將關卡索引加一,準備前往下一關
self.level_index = self.level_index + 1
-- 若下一關仍存在,就載入下一關
-- 沒有更多關卡時,輸出全部完成訊息
if self.level_index <= level_manager.level_count() then
level_manager.load(self.level_index, self.factories)
else
dprint.print("All levels complete!")
end
end
end
GUI 設計
建立 HUD
在 Defold 中,GUI 通常由兩個檔案組成:
.gui:負責畫面元件、文字、圖片與版面配置。.gui_script:負責處理 GUI 的顯示、隱藏與互動邏輯。
因此,建立 HUD 時通常會成對建立:
hud.gui
hud.gui_script
本章會建立一個簡單的完成提示視窗,當玩家完成關卡時顯示「關卡完成」訊息。
Defold GUI 的概念與 Xcode Storyboard 有些相似:可以先在編輯器中安排元件的位置、尺寸、字型與顏色,再透過程式控制它們的狀態。
建立 GUI
在專案中建立下列檔案:
main/
└── hud/
├── hud.gui
└── hud.gui_script
建立 hud.gui 時,可以依照以下步驟進行:
- 在專案面板中新增 GUI。
- 將檔案命名為
hud.gui。 - 為 GUI 指定
hud.gui_script。 - 在 GUI 編輯器中建立完成提示面板。
- 新增一個文字節點,顯示關卡完成訊息。
GUI 的基本結構如下:
GUI
└── Nodes
└── complete_panel
└── complete_text
其中:
complete_panel:完成提示視窗的背景面板。complete_text:顯示完成訊息的文字節點。
請務必使用清楚且容易理解的 Id,因為稍後會在 hud.gui_script 中透過 Id 取得這些節點:
complete_panel
complete_text
設定完成面板
選取 complete_panel 後,可以依照遊戲畫面設定下列屬性:
Position: X = 192, Y = 192
Size: X = 320, Y = 192
Alpha: 0.6
如果遊戲解析度是 384 × 384,將面板位置設在:
X = 192
Y = 192
就能讓面板位於畫面中央。
可以將半透明背景用來遮住遊戲畫面,讓完成訊息更加清楚。面板顏色、透明度與尺寸可以依照遊戲風格自行調整。
例如:
Color: #FFFFFF
Alpha: 0.6
也可以使用深色背景搭配白色文字:
Panel Color: #202020
Panel Alpha: 0.85
Text Color: #FFFFFF
設定完成文字
選取 complete_text,設定文字內容、字型、大小、顏色與對齊方式。
例如:
Text: 關卡完成!
建議將文字設定為:
- 水平置中。
- 垂直置中。
- 使用容易閱讀的字型。
- 放置在
complete_panel的中央。 - 將文字節點 Id 設定為
complete_text。
如果使用英文介面,也可以設定為:
Level Complete!
後續若要支援多國語系,可以將文字內容移到語系資料檔中,不要直接寫死在 GUI 裡。
設定 hud.gui_script
建立 hud.gui_script 後,將它指定給 hud.gui。
可以先加入以下基本程式:
function init(self)
self.complete_panel = gui.get_node("complete_panel")
self.complete_text = gui.get_node("complete_text")
gui.set_enabled(self.complete_panel, false)
end
這段程式會在遊戲開始時:
- 取得
complete_panel節點。 - 取得
complete_text節點。 - 預設隱藏完成面板。
接著加入顯示與隱藏函式:
local function show_complete_panel(self)
gui.set_enabled(self.complete_panel, true)
end
local function hide_complete_panel(self)
gui.set_enabled(self.complete_panel, false)
end
若要讓其他 Game Object 通知 HUD 顯示完成視窗,可以在 hud.gui_script 中接收訊息:
function on_message(self, message_id, message, sender)
if message_id == hash("level_complete") then
show_complete_panel(self)
end
end
當關卡完成時,其他程式只要傳送:
msg.post("main:/hud#hud", "level_complete")
即可顯示完成面板。
實際 URL 必須依照你在 main.collection 中建立的 Game Object Id 與 GUI component Id 調整。
將 HUD 加入主 Collection
完成 GUI 設計後,開啟:
main.collection
接著:
- 新增一個 Game Object。
- 將 Id 設定為:
hud
- 在這個 Game Object 上新增 Component File。
- 選擇:
hud.gui
完成後,主 Collection 的結構會類似:
main.collection
├── game
├── level1_map
├── player
└── hud
└── hud.gui
GUI 是獨立於遊戲世界的畫面層,通常會固定顯示在螢幕上,不會隨著遊戲世界中的 Game Object 移動。
因此,HUD 很適合用來顯示關卡資訊、步數、計時器、暫停按鈕與完成提示。
進入下一關
完成 HUD 畫面後,接著將 hud.gui 與 hud.gui_script 正確連動,並把 HUD 加入 main.collection。
本節會加入「進入下一關」的功能:當玩家完成目前關卡後,按下 <ENTER> 或 <SPACE>,遊戲會發送 next_level 訊息,通知遊戲管理器載入下一個關卡。
整體流程如下:
玩家完成關卡
↓
level_manager.lua 判斷通關
↓
通知 HUD 顯示完成面板
↓
玩家按下 ENTER / SPACE
↓
hud.gui_script 發送 next_level 訊息
↓
game.script 或 level_manager.lua 載入下一關
next_level是一個自訂的訊息名稱。
Defold 的 Game Object、GUI 與 Script 可以透過msg.post()傳送訊息,讓不同元件各自負責不同工作,而不需要直接互相呼叫函式。
設定輸入綁定
先開啟:
game.input_binding
新增一個 Action,名稱設定為:
next_level
將下列按鍵綁定到 next_level:
| 輸入裝置 | 按鍵 |
|---|---|
| Keyboard | ENTER |
| Keyboard | SPACE |
設定完成後,使用者按下 <ENTER> 或 <SPACE> 時,程式會收到:
action_id == hash("next_level")
請確認
game.input_binding中的 Action 名稱為next_level。
名稱必須與 Lua 程式中的hash("next_level")完全一致,包含底線、大小寫與拼字。
讓 HUD 接收輸入
GUI 預設不會自動接收鍵盤輸入,因此需要在 hud.gui_script 的 init() 中取得輸入焦點。
function init(self)
-- 取得完成提示面板與文字節點。
self.complete_panel = gui.get_node("complete_panel")
self.complete_text = gui.get_node("complete_text")
-- 遊戲剛開始時,不顯示完成提示。
gui.set_enabled(self.complete_panel, false)
-- 讓此 GUI 可以接收 game.input_binding 定義的輸入事件。
msg.post(".", "acquire_input_focus")
end
取得輸入焦點後,hud.gui_script 就可以透過 on_input() 監聽 <ENTER> 與 <SPACE> 對應的 next_level Action。
接收 next_level 輸入
在 hud.gui_script 加入以下程式:
function on_input(self, action_id, action)
-- 只在按鍵剛被按下的瞬間處理。
-- 避免玩家長按按鍵時,連續跳過多個關卡。
if action_id ~= hash("next_level") or not action.pressed then
return false
end
-- 若完成面板未顯示,代表關卡尚未完成,
-- 此時按下 ENTER 或 SPACE 不進入下一關。
if not gui.is_enabled(self.complete_panel) then
return false
end
-- 通知 game.go:玩家要求進入下一關。
-- 請依照 main.collection 裡 game.go 的實際 Id 調整 URL。
msg.post("main:/game#game", "next_level")
return true
end
這段程式有兩個重要限制:
- 只有
complete_panel顯示時,按鍵才會觸發下一關。 - 使用
action.pressed,避免按住按鍵時重複切換關卡。
顯示通關面板
當 level_manager.lua 判斷所有箱子都已經放到目標點後,可以傳送 level_complete 訊息給 HUD:
msg.post("main:/hud#hud", "level_complete")
接著由 hud.gui_script 接收並顯示完成面板:
function on_message(self, message_id, message, sender)
if message_id == hash("level_complete") then
-- 顯示通關提示面板。
gui.set_enabled(self.complete_panel, true)
-- 更新顯示文字。
gui.set_text(self.complete_text, "關卡完成!\n按 ENTER 或 SPACE 繼續")
end
end
若你想將顯示與隱藏的程式集中管理,可以改寫成:
local function show_complete_panel(self)
gui.set_text(
self.complete_text,
"關卡完成!\n按 ENTER 或 SPACE 繼續"
)
gui.set_enabled(self.complete_panel, true)
end
local function hide_complete_panel(self)
gui.set_enabled(self.complete_panel, false)
end
function on_message(self, message_id, message, sender)
if message_id == hash("level_complete") then
show_complete_panel(self)
elseif message_id == hash("level_started") then
-- 新關卡開始時,再次隱藏完成畫面。
hide_complete_panel(self)
end
end
接收下一關訊息
在 game.script 中接收 HUD 發送的 next_level 訊息:
function on_message(self, message_id, message, sender)
if message_id == hash("next_level") then
-- 切換到下一個關卡。
-- 實際函式名稱請依照你的 level_manager.lua 調整。
self.level_index = self.level_index + 1
-- 載入新關卡前,先清除舊關卡建立的主角、箱子與目標點。
level_manager.clear_level(self)
-- 依照新的 level_index 讀取資料並透過 Factory 建立物件。
level_manager.load_level(self, self.level_index)
-- 通知 HUD:新關卡已開始,可以隱藏完成面板。
msg.post("main:/hud#hud", "level_started")
end
end
這裡的 level_manager.clear_level() 與 level_manager.load_level() 是示意名稱。若你的專案函式名稱不同,請替換成實際使用的關卡清除與載入函式。
訊息傳遞關係
完成後,各元件的責任可以整理如下:
| 元件 | 責任 |
|---|---|
level_manager.lua |
判斷箱子是否全部到達目標點 |
game.script |
載入、清除與切換關卡 |
hud.gui_script |
顯示通關畫面、接收玩家的下一關輸入 |
game.input_binding |
將 <ENTER> 與 <SPACE> 對應為 next_level |
訊息傳遞順序如下:
level_manager.lua
└── msg.post("main:/hud#hud", "level_complete")
hud.gui_script
└── 顯示 complete_panel
└── 玩家按 ENTER / SPACE
└── msg.post("main:/game#game", "next_level")
game.script
└── 載入下一關
└── msg.post("main:/hud#hud", "level_started")
hud.gui_script
└── 隱藏 complete_panel
注意:
main:/hud#hud與main:/game#game是常見的 URL 範例。
實際使用時,請確認main.collection中的 Game Object Id 與元件 Id 是否分別為hud、game,並依專案中的實際名稱調整。
以下是hud.gui_script跟game.go使用的完整程式碼
- 提示視窗:
hud.gui_script
local function show_panel(self, message)
local panel = gui.get_node("complete_panel")
local text = gui.get_node("complete_text")
gui.set_text(
text,
string.format("%d-%d,你過關了", message.world, message.level)
)
gui.set_enabled(panel, true)
gui.set_visible(panel, true)
self.visible = true
-- 讓 HUD 開啟後接收 Enter / Space
msg.post(".", "acquire_input_focus")
end
local function hide_panel(self)
local panel = gui.get_node("complete_panel")
gui.set_enabled(panel, false)
gui.set_visible(panel, false)
self.visible = false
msg.post(".", "release_input_focus")
end
function init(self)
self.visible = false
local panel = gui.get_node("complete_panel")
gui.set_enabled(panel, false)
gui.set_visible(panel, false)
end
function on_message(self, message_id, message)
if message_id == hash("show_level_complete") then
show_panel(self, message)
end
end
function on_input(self, action_id, action)
if not self.visible or not action.pressed then
return false
end
if action_id == hash("next_level") or action_id == hash("confirm") then
hide_panel(self)
msg.post("/game#game", "next_level")
return true
end
return false
end
- 遊戲主程式:
game.gui_script
-- 載入自訂除錯輸出工具
-- 之後可使用 dprint.print(...) 在 Console 顯示訊息
local dprint = require("utility.debug_print")
-- 載入關卡管理模組
-- 負責玩家、箱子、目標點、移動、碰撞與過關判斷
local level_manager = require "main.levels.level_manager"
-- 載入當前關卡
-- 將關卡完成狀態重設為 false,並呼叫 level_manager 以當前關卡索引與工廠表載入關卡資料
local function load_current_level(self)
-- 重設關卡完成旗標,避免舊狀態影響新關卡
self.is_level_complete = false
-- 呼叫 level_manager 模組載入指定關卡;self.level_index 為關卡編號,self.factories 為建立關卡物件所需的工廠函式表
level_manager.load(self.level_index, self.factories)
end
-- 嘗試移動玩家
--
-- message.x 和 message.y 是移動方向,例如:
-- { x = 1, y = 0 }:往右
-- { x = -1, y = 0 }:往左
-- { x = 0, y = 1 }:往上
-- { x = 0, y = -1 }:往下
local function try_move_action(self, message)
-- 若關卡已完成的狀態為 true,則直接忽略移動並回傳 true(表示此訊息已處理)
if self.is_level_complete then
return true
end
-- 呼叫 level_manager 嘗試以給定方向移動玩家;moved 為布林值,表示是否成功移動
local moved = level_manager.try_move(message.x, message.y)
-- 若移動成功且 level_manager 判斷關卡已完成,則標記關卡完成、印出訊息,並通知 HUD 顯示完成提示
if moved and level_manager.is_complete() then
-- 標記當前關卡為已完成,防止後續移動或重複觸發完成邏輯
self.is_level_complete = true
-- 印出完成訊息,方便除錯或記錄
dprint.print("Level " .. self.level_index .. " complete!")
-- 透過 Defold 的 msg.post 發送訊息到 "/hud#hud" 遊戲物件,讓 HUD 顯示關卡完成 UI
-- world: 世界編號(此處固定為 1),level: 當前關卡編號
msg.post("/hud#hud", "show_level_complete", { world = 1, level = self.level_index })
end
-- 無論是否移動成功,都回傳 true 表示此動作已處理完畢
return true
end
-- 進入下一關的動作
--
-- 只在當前關卡已完成時才允許切換;若已超過最大關卡數則印出提示並停止
local function next_level_action(self, message)
-- 若關卡尚未完成,則不進行任何操作,直接回傳 true
if not self.is_level_complete then
return true
end
-- 檢查是否已達或超過總關卡數;若是,表示所有關卡都已破完,印出提示並停止
if self.level_index >= level_manager.level_count() then
dprint.print("All levels complete!")
return true
end
-- 將關卡索引加 1,準備載入下一關
self.level_index = self.level_index + 1
-- 印出即將載入的關卡編號,方便除錯
dprint.print("Loading level " .. self.level_index)
-- 呼叫 load_current_level 重新載入新關卡,並重設相關狀態。
load_current_level(self)
-- 回傳 true 表示此動作已處理
return true
end
-- Defold 的初始化回呼函式
-- 在腳本所屬遊戲物件建立時自動呼叫一次,用來設定初始狀態與載入關卡
function init(self)
-- 設定關卡物件工廠的映射表
-- key 為邏輯名稱(player / box / target),value 為對應的 factory 元件路徑字串
-- level_manager 會用這些路徑在載入關卡時動態生成對應的遊戲物件
self.factories = {
player = "#player_factory",
box = "#box_factory",
target = "#target_factory",
}
-- 設定初始關卡索引為 1(第一關)
self.level_index = 1
-- 設定關卡完成旗標為 false,表示尚未完成當前關卡
self.is_level_complete = false
-- 呼叫 load_current_level 載入第一關的關卡資料與生成物件
load_current_level(self)
end
-- 當目前 Game Object 收到訊息時呼叫
--
-- message_id: 訊息名稱的 hash,例如 hash("try_move")
-- message: 訊息攜帶的資料,例如 { x = 1, y = 0 }
function on_message(self, message_id, message)
if message_id == hash("try_move") then
return try_move_action(self, message)
end
if message_id == hash("next_level") then
return next_level_action(self, message)
end
return nil
end
初步測試
完成設定後,執行 Project → Build,再啟動遊戲確認:
hud.gui是否能正常載入。complete_panel是否預設隱藏。complete_text是否位於面板中央。- 字型是否能正確顯示中文。
hud.gui_script是否成功綁定。- 傳送
level_complete訊息後,完成面板是否會顯示。
目前先建立 GUI 與節點結構;等 level_manager.lua 完成通關判斷後,再由它通知 HUD 顯示完成畫面。
自訂中文字型
為什麼中文沒有顯示?
完成 HUD 後執行遊戲,可能會發現英文、數字與符號都能正常顯示,但中文文字卻消失或顯示為空白。
這是因為 Defold 預設字型通常只包含基本 ASCII 字元,例如:
ABCDEFGHIJKLMNOPQRSTUVWXYZ
abcdefghijklmnopqrstuvwxyz
0123456789
!@#$%^&*()
中文字並不包含在預設字型的字元範圍中,因此像下面的訊息:
恭喜!全部完成了
若使用預設字型,可能只會顯示標點符號,或完全沒有文字。
解決方式是匯入支援繁體中文的 .ttf 字型檔,並在 Defold 中建立對應的 .font 資源。Defold 的 Font 資源會將指定字元轉換為遊戲可用的字型資料;預設的 Characters 欄位只包含 ASCII 可列印字元,因此需要手動加入遊戲會顯示的中文字。
下載 Open Huninn 字型
本專案使用 jf open-huninn(jf open 粉圓)字型。這是一款適合繁體中文顯示的開源圓體字型,包含台灣常用漢字、注音與部分台語、閩南語相關字元。
可從 GitHub Releases 頁面下載:
此字型採用 SIL Open Font License 1.1,可自由使用、分享與修改;若用於商業專案,請仍自行確認專案實際使用的字型版本與授權內容。
下載後,將字型檔放入專案內的字型資料夾,例如:
assets/
└── fonts/
└── jf-openhuninn-2.1.ttf
建立 Font 資源
接著在 Defold 中新增一個 Font 資源:
- 在 Assets 面板中,於
assets/fonts/資料夾按右鍵。 - 選擇 New… → Font。
- 將檔案命名為:
jf-openhuninn.font
- 在
jf-openhuninn.font的屬性中,將 Font 指定為:
jf-openhuninn-2.1.ttf
完成後,專案結構會類似:
assets/
└── fonts/
├── jf-openhuninn-2.1.ttf
└── jf-openhuninn.font
Font 設定
Font 的設定方式與一般文字排版軟體有些相似,可以調整字型、大小、陰影、外框與輸出格式。
本專案可先使用以下設定:
| 屬性 | 建議值 | 說明 |
|---|---|---|
Font |
jf-openhuninn-2.1.ttf |
字型來源檔案 |
Output Format |
Bitmap |
將字型轉為點陣圖字型資料 |
Size |
96 |
字型輸出大小 |
All Chars |
關閉 | 只產生實際需要的字元 |
Characters |
遊戲中會使用的字元 | 指定要產生的中文字、英文與符號 |
本專案採用像素風格的遊戲畫面,因此可以先將 Output Format 設定為:
Bitmap
Bitmap 會將 TTF 字型轉換為字型圖集(font sheet),再由 GUI Text Node 或 Label 顯示。Defold 也支援 Distance Field 輸出格式;它更適合需要大幅縮放、平滑邊緣或多種尺寸的文字,但像素風格 HUD 使用 Bitmap 通常較容易取得可預期的效果。
字型大小可先設定為:
Size: 96
後續在 GUI 的 Text Node 中再調整節點尺寸或 Scale,以符合畫面設計。
設定 Characters
最需要注意的欄位是 Characters。
Defold 不會自動將整個中文字型的所有字元都轉換進遊戲。這是因為中文字型常常包含數千甚至數萬個字元,若全部轉換,會增加建置時間、字型貼圖尺寸與遊戲檔案大小。
因此,不要勾選 All Chars。請只在 Characters 欄位中填入遊戲實際需要顯示的字元。Defold 文件指出,啟用 All Chars 會將來源字型中的所有 glyphs 納入輸出;而 Characters 欄位則可精確控制輸出的字元集合。
例如,本專案可填入:
倉庫番恭喜!全部完成了過關按或繼續
ABCDEFGHIJKLMNOPQRSTUVWXYZ
abcdefghijklmnopqrstuvwxyz
0123456789
-!,。!?:
也可以整理成單行後貼入:
倉庫番恭喜!全部完成了過關按或繼續ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-!,。!?:
若 HUD 會顯示以下文字:
關卡完成!
按 ENTER 或 SPACE 繼續
請確認 Characters 至少包含:
關卡完成按繼續或
ENTERSPACE
!
注意:Defold 只會產生
Characters欄位中出現的字元。
如果程式日後顯示了尚未加入的中文字,例如「重新開始」,它仍可能無法顯示;此時只要將重、新、開、始加入Characters,再重新 Build 即可。
在 GUI 中引用字型
完成 jf-openhuninn.font 設定後,開啟:
main/hud/hud.gui
接著在 GUI 編輯器右側的 Fonts 區段加入字型資源:
jf-openhuninn.font
加入後,選取 complete_text 文字節點,將它的 Font 屬性設定為剛才加入的字型,例如:
jf-openhuninn
GUI 結構會類似:
hud.gui
├── Fonts
│ └── jf-openhuninn → jf-openhuninn.font
└── Nodes
└── complete_panel
└── complete_text
└── Font: jf-openhuninn
最後執行 Project → Build,確認 HUD 是否能正確顯示中文字。
常見問題
| 問題 | 常見原因 | 解決方式 |
|---|---|---|
| 中文完全沒有顯示 | Text Node 使用預設字型 | 在 hud.gui 的 Fonts 加入 jf-openhuninn.font,並指定給 Text Node |
| 部分中文字消失 | 該字不在 Characters 欄位 |
將遺漏的中文字加入 Characters,再重新 Build |
| 字型建置很久或遊戲檔很大 | 勾選了 All Chars |
關閉 All Chars,只保留實際會使用的字元 |
| 文字邊緣過於模糊 | 字型縮放過大或 Bitmap 尺寸太小 | 增加 Size,或依遊戲需求改用 Distance Field |
| 顯示成方框或亂碼 | 使用的字型不包含該字元 | 確認 TTF 本身支援該文字,並確認字元已加入 Characters |
美化 HUD
可以使用 Defold 的 Slice 9,讓圓角背景在調整 HUD 尺寸時保持邊角比例,不會被拉伸變形。
1. 建立背景圖片
先新增:
/assets/ui/panel_round.atlas
在 Atlas 中加入一張半透明圓角正方形圖片。
這張圖片會作為 HUD 背景的 Texture 紋理。
2. 加入 hud.gui
開啟 hud.gui,在右側 Outline 的 Textures 區塊中加入:
panel_round - /assets/ui/panel_round.atlas
3. 設定 Slice 9
選取 HUD 背景的 Box Node,確認其使用 panel_round Texture,並在 Properties 的 Material 中選擇:
Slice 9
Slice 9 的概念類似 Android 的 9-patch:圖片會被分成九個區域。四個角落維持原始尺寸,邊緣只沿單一方向延伸,中間區域則可以自由拉伸,因此圓角不會變形。
將四個邊界值設定為:
Left: 32
Top: 32
Right: 32
Bottom: 32
HUD 的寬度與高度則可以依照介面需求調整。
4. 建置測試
完成後執行 Build,確認 HUD 是否呈現完整的圓角背景:
只要背景節點使用 Slice 9,之後即使修改 HUD 的尺寸,四個角落仍會維持正確比例。
細節可以參考 Defold Slice-9 範例
加入遊戲標題頁
完成 HUD、關卡切換與通關提示後,最後可以加入遊戲標題頁(Title Screen),讓玩家進入遊戲時先看到遊戲名稱與開始提示。
標題頁的做法很簡單:建立一個覆蓋整個畫面的 title.gui,並在玩家第一次按下按鍵後將它隱藏,讓遊戲正式開始。
整體流程如下:
遊戲啟動
↓
顯示 title.gui
↓
玩家按下任意開始按鍵
↓
title.gui_script 接收輸入
↓
隱藏標題畫面
↓
開始操作倉庫番關卡
Defold 的 GUI 需要取得輸入焦點後,才能在 on_input() 接收按鍵事件;取得焦點時可使用 msg.post(".", "acquire_input_focus")。[73]
建立標題 GUI
在專案中新增以下檔案:
main/
└── title/
├── title.gui
└── title.gui_script
title.gui 的設定方式與前面的 hud.gui 相同,主要使用 GUI Node 排版遊戲標題、開始提示與背景面板。
可以建立以下節點結構:
title.gui
└── Nodes
└── title_panel
├── title_text
└── start_text
各節點用途如下:
| Node Id | 類型 | 用途 |
|---|---|---|
title_panel |
Box Node | 作為標題頁的背景或半透明遮罩 |
title_text |
Text Node | 顯示遊戲名稱 |
start_text |
Text Node | 顯示開始提示文字 |
例如可以設定:
title_text: 倉庫番
start_text: 按 ENTER 或 SPACE 開始遊戲
如果遊戲畫面尺寸是 384 × 384,可將標題面板放在畫面中央:
Position: X = 192, Y = 192
Size: X = 384, Y = 384
title_panel 可以設為不透明背景,或使用半透明顏色,讓玩家隱約看到背景中的關卡地圖。
設定輸入綁定
標題頁通常只需要一個開始遊戲的 Action。可以直接沿用先前建立的 next_level,或新增更清楚的 start_game Action。
建議在 game.input_binding 新增:
start_game
並綁定以下按鍵:
| 輸入裝置 | 按鍵 |
|---|---|
| Keyboard | ENTER |
| Keyboard | SPACE |
如此一來,玩家按下 <ENTER> 或 <SPACE> 時,會收到:
action_id == hash("start_game")
如果你想讓標題頁支援方向鍵、滑鼠點擊或觸控開始,也可以將這些輸入綁定到同一個
start_gameAction,或在on_input()中額外處理。
撰寫 title.gui_script
將以下程式加入 title.gui_script:
local function hide_title(self)
-- 隱藏標題頁的根節點。
gui.set_enabled(self.title_panel, false)
-- 釋放輸入焦點,避免標題頁繼續攔截遊戲輸入。
msg.post(".", "release_input_focus")
-- 通知遊戲管理器:玩家已經開始遊戲。
-- 請依照 main.collection 中實際的 Id 調整 URL。
msg.post("main:/game#game", "game_started")
end
function init(self)
-- 取得 GUI 節點。
self.title_panel = gui.get_node("title_panel")
self.title_text = gui.get_node("title_text")
self.start_text = gui.get_node("start_text")
-- 遊戲剛啟動時,標題頁必須顯示。
gui.set_enabled(self.title_panel, true)
-- 讓 title.gui_script 可以接收鍵盤輸入。
msg.post(".", "acquire_input_focus")
end
function on_input(self, action_id, action)
-- 只在按鍵剛按下時處理。
-- 避免長按 ENTER 或 SPACE 重複觸發。
if action_id ~= hash("start_game") or not action.pressed then
return false
end
-- 標題頁已隱藏時,不再重複執行。
if not gui.is_enabled(self.title_panel) then
return false
end
hide_title(self)
-- 回傳 true,表示此輸入已由標題頁使用。
-- 可以避免同一次按鍵立刻被玩家移動邏輯處理。
return true
end
這段程式完成後,玩家第一次按下 <ENTER> 或 <SPACE> 時會:
- 隱藏
title_panel。 - 釋放標題頁的輸入焦點。
- 傳送
game_started訊息給game.script。 - 將目前的輸入事件視為已處理,避免同一次按鍵直接操作主角。
在 GUI 中,節點的 Enabled 屬性可透過 gui.set_enabled() 控制;節點被停用時不會被繪製、動畫或 GUI 點擊偵測處理。[74]
在 game.script 接收開始訊息
如果你的遊戲一啟動就已經載入第一關,game_started 可以暫時只用來記錄遊戲狀態:
function init(self)
self.game_started = false
-- 原本的關卡載入程式。
-- level_manager.load_level(self, 1)
end
function on_message(self, message_id, message, sender)
if message_id == hash("game_started") then
self.game_started = true
print("Game started")
end
end
如果你希望在標題頁顯示期間不要建立關卡,而是在玩家開始後才載入第一關,則可以改成:
function init(self)
self.game_started = false
end
function on_message(self, message_id, message, sender)
if message_id == hash("game_started") then
if self.game_started then
return
end
self.game_started = true
-- 玩家按下開始鍵後才建立第一關。
level_manager.load_level(self, 1)
end
end
這兩種方式都可行:
| 方式 | 適合情況 |
|---|---|
| 啟動時先載入第一關 | 標題頁使用半透明效果,可看到後方的遊戲地圖 |
| 按下開始鍵才載入第一關 | 想讓標題頁保持獨立,或第一關載入需要較多時間 |
加入 main.collection
最後,將 title.gui 加入 main.collection。
- 開啟
main.collection。 - 新增一個 Game Object。
- 將 Id 設定為:
title
- 在 Game Object 上選擇 Add Component File。
- 加入:
title.gui
完成後,主場景的結構可以整理為:
main.collection
├── level1_map
├── game
│ ├── game.script
│ ├── player_factory
│ ├── box_factory
│ └── target_factory
├── hud
│ └── hud.gui
└── title
└── title.gui
其中:
title.gui:遊戲啟動時顯示,玩家按下開始鍵後隱藏。hud.gui:遊戲進行中顯示,例如通關提示或步數資訊。game.go:負責關卡載入、Factory 建立物件與切換下一關。
注意輸入焦點順序
title.gui、hud.gui 與 player.script 都可能需要接收輸入。Defold 會將輸入事件傳送給已取得輸入焦點的元件;若有多個元件位於輸入堆疊中,最晚取得焦點的元件會優先處理輸入。
因此,標題頁顯示時應該取得輸入焦點;標題頁關閉後則應釋放輸入焦點:
msg.post(".", "acquire_input_focus")
msg.post(".", "release_input_focus")
這能避免主角在標題頁顯示期間,因為玩家按下 <ENTER>、SPACE 或方向鍵而意外移動。Defold 的輸入系統會把輸入送到已取得輸入焦點、且實作 on_input() 的 Script 或 GUI Script;使用 release_input_focus 後,元件將不再接收輸入。[73]
完成這個標題頁後,玩家開啟遊戲時會先看到遊戲名稱與開始提示;第一次按下開始鍵後,標題頁消失,遊戲便正式開始。至此,一個具備關卡讀取、箱子推動、通關提示、下一關與標題頁的倉庫番遊戲就完成了。
-- game.script
function init(self)
self.is_showing = true
print("Title GUI initialized")
msg.post(".", "acquire_input_focus")
end
function on_input(self, action_id, action)
if not self.is_showing then
return false
end
if not action.pressed then
return false
end
print("Title input: " .. tostring(action_id))
if action_id == hash("next_level") then
print("Starting game")
self.is_showing = false
local panel = gui.get_node("title_panel")
gui.set_enabled(panel, false)
gui.set_visible(panel, false)
msg.post(".", "release_input_focus")
msg.post("/game#game", "start_game")
return true
end
return false
end
– 補上HUD過關提示
-- hud.gui_script
-- 統一設定完成面板的可見性與啟用狀態,並更新腳本內部狀態
local function gui_visible(self, visible)
-- 取得 GUI 節點 "complete_panel"
local panel = gui.get_node("complete_panel")
-- 同時設定面板的啟用與可見狀態(true = 顯示且可互動;false = 隱藏且停用)
gui.set_enabled(panel, visible)
gui.set_visible(panel, visible)
-- 更新腳本內部的可見性標記,供 on_input 等邏輯判斷
self.visible = visible
end
-- 顯示完成面板的輔助函式
local function show_panel(self)
gui_visible(self, true)
-- 向當前腳本所屬遊戲物件發送訊息,取得輸入焦點(讓 on_input 能接收輸入)
msg.post(".", "acquire_input_focus")
end
-- 顯示「單關完成」訊息
local function show_level_complete(self, message)
-- 取得顯示文字的 GUI 節點
local text = gui.get_node("complete_text")
local content = string.format("%d-%d,你過關了\n按 Enter 或 Space 繼續", message.world, message.level)
-- 設定文字內容:顯示世界與關卡編號,並提示按 Enter 或 Space 繼續
gui.set_text(text, content)
-- 設定模式為「下一關」
self.mode = "next_level"
-- 顯示面板
show_panel(self)
end
-- 顯示「全部關卡完成」訊息
local function show_all_levels_complete(self, message)
-- 取得顯示文字的 GUI 節點
local text = gui.get_node("complete_text")
local content = string.format("恭喜!全部 %d 關完成!", message.total_levels)
-- 設定文字內容:恭喜完成全部關卡
gui.set_text(text, content)
-- 設定模式為「全部完成」
self.mode = "all_complete"
-- 顯示面板
show_panel(self)
end
-- 隱藏完成面板
local function hide_panel(self)
gui_visible(self, false)
-- 釋放輸入焦點(之後 on_input 不會再收到輸入)
msg.post(".", "release_input_focus")
end
-- 初始化函式:在腳本啟動時呼叫
function init(self)
gui_visible(self, false)
end
-- 訊息回呼:處理來自其他腳本的訊息
function on_message(self, message_id, message)
-- 若收到 "show_level_complete" 訊息,顯示單關完成畫面
if message_id == hash("show_level_complete") then
show_level_complete(self, message)
return
end
-- 若收到 "show_all_levels_complete" 訊息,顯示全部完成畫面
if message_id == hash("show_all_levels_complete") then
show_all_levels_complete(self, message)
return
end
end
-- 輸入回呼:處理玩家輸入(鍵盤、按鈕等)
function on_input(self, action_id, action)
-- 若面板不可見,或輸入不是「按下」狀態,則不處理
if not self.visible or not action.pressed then
return false
end
-- 若輸入是 "next_level" 或 "confirm"(例如 Enter / Space)
if action_id == hash("next_level") or action_id == hash("confirm") then
-- 隱藏面板
hide_panel(self)
-- 向遊戲腳本發送 "next_level" 訊息,觸發下一關
msg.post("/game#game", "next_level")
-- 表示此輸入已被處理
return true
end
-- 其他輸入不處理
return false
end
顯示通關面板與音效
當玩家完成關卡後,HUD 會顯示完成面板、停止背景音樂,並播放勝利音效。勝利音效播放結束後,再重新取得輸入焦點,讓玩家可以按下 <ENTER> 或 <SPACE> 進入下一關。
以下程式放在 hud.gui_script 中:
-- hud.gui_script
-- 顯示完成面板的輔助函式。
-- 此函式會在玩家完成關卡後呼叫。
local function show_panel(self)
-- 顯示 GUI 中的完成面板與完成文字。
-- gui_visible() 為自行建立的輔助函式,
-- 內部通常會呼叫 gui.set_enabled() 控制節點顯示或隱藏。
gui_visible(self, true)
-- 停止背景音樂。
-- "/music#bgm" 是 main.collection 中音樂 Game Object 的元件 URL。
sound.stop("/music#bgm")
-- 播放勝利音效。
-- 第三個參數是完成回呼函式,會在音效播放結束後執行。
sound.play("/music#victory", {}, function()
-- 勝利音效播放完成後,讓 HUD 取得輸入焦點。
-- 取得焦點後,hud.gui_script 的 on_input() 才能接收
-- ENTER、SPACE 或其他 game.input_binding 定義的按鍵事件。
msg.post(".", "acquire_input_focus")
-- 重新播放背景音樂,等待玩家進入下一關。
sound.play("/music#bgm")
end)
end
程式執行流程如下:
關卡完成
↓
顯示 complete_panel
↓
停止背景音樂
↓
播放 victory 音效
↓
victory 音效播放結束
↓
HUD 取得輸入焦點
↓
重新播放背景音樂
↓
玩家可按 ENTER / SPACE 進入下一關
sound.play()的完成回呼函式適合處理「音效播完後再執行下一步」的流程。
不過要注意:若勝利音效是 Looping Sound,完成回呼不會被呼叫,因為音效不會自然播放結束。
修正斜向移動問題
在格子移動遊戲中,若玩家連續快速按下方向鍵,而上一段移動動畫尚未播放完成,就可能同時啟動多個 go.animate()。
這可能造成主角或箱子看起來斜著移動、穿越格子,或移動位置與關卡邏輯不同步。
解決方式是在物件移動期間,使用 is_moving 變數鎖定新的移動操作;等動畫完成後,再解除鎖定。
-- 播放物件移動動畫。
--
-- self:目前 Script 的狀態 table。
-- id:要移動的 Game Object URL。
-- position:移動完成後的目標 world position。
local function move_animation(self, id, position)
-- 將移動狀態設為 true。
-- 在動畫尚未完成前,on_input() 應拒絕新的方向輸入。
self.is_moving = true
-- 對指定 Game Object 的 position 屬性播放移動動畫。
go.animate(
id, -- 要移動的 Game Object URL。
"position", -- 要動畫化的屬性。
go.PLAYBACK_ONCE_FORWARD, -- 只從起點播放到終點一次。
position, -- 動畫結束時的目標位置。
go.EASING_OUTQUAD, -- 緩動曲線:先快後慢。
M.TIME_INTERVAL, -- 動畫持續秒數。
0, -- 延遲時間:立即開始。
function()
-- 動畫完成後,解除移動鎖定。
-- 玩家現在可以進行下一次移動。
self.is_moving = false
end
)
end
Defold 的 go.animate() 支援完成回呼函式;當動畫正常播放到結尾時,回呼函式會被執行,因此很適合用來解除 is_moving 鎖定或串接後續動作。若動畫被 go.cancel_animations() 取消,完成回呼不會執行。[86][87]
在輸入事件中使用 is_moving
僅在 move_animation() 中設定 self.is_moving = true 還不夠;你也必須在處理按鍵的地方先檢查它。
例如在 player.script 的 on_input() 中:
function on_input(self, action_id, action)
-- 主角、箱子或其他物件仍在播放移動動畫時,
-- 忽略新的方向鍵輸入,避免同時執行多個移動。
if self.is_moving then
return true
end
-- 只在按鍵剛被按下時處理。
-- action.repeated 與長按造成的連續輸入不會直接觸發新的移動。
if not action.pressed then
return false
end
if action_id == hash("move_left") then
try_move(self, -1, 0)
elseif action_id == hash("move_right") then
try_move(self, 1, 0)
elseif action_id == hash("move_up") then
try_move(self, 0, 1)
elseif action_id == hash("move_down") then
try_move(self, 0, -1)
end
return true
end
初始化時,建議明確設定初始值:
function init(self)
self.is_moving = false
msg.post(".", "acquire_input_focus")
end
完整的移動流程會變成:
玩家按下方向鍵
↓
檢查 self.is_moving
↓
false:允許執行 try_move()
↓
更新關卡格子資料
↓
呼叫 move_animation()
↓
self.is_moving = true
↓
go.animate() 播放移動動畫
↓
動畫完成 callback
↓
self.is_moving = false
↓
允許下一次移動
推箱子時的注意事項
倉庫番中,推箱子會同時移動主角與箱子。因此不能只等待主角動畫完成,而要等兩個動畫都完成才解除輸入鎖定。
一個簡單的做法是讓 move_animation() 接受完成回呼:
local function move_animation(id, position, on_complete)
go.animate(
id,
"position",
go.PLAYBACK_ONCE_FORWARD,
position,
go.EASING_OUTQUAD,
M.TIME_INTERVAL,
0,
on_complete
)
end
接著使用計數器等待主角與箱子都移動完畢:
local function animate_player_and_box(self, player_id, player_position, box_id, box_position)
self.is_moving = true
local completed_count = 0
local expected_count = 2
local function on_move_complete()
completed_count = completed_count + 1
-- 主角與箱子的動畫都播完後,
-- 才允許下一次輸入。
if completed_count == expected_count then
self.is_moving = false
end
end
move_animation(player_id, player_position, on_move_complete)
move_animation(box_id, box_position, on_move_complete)
end
這樣主角與箱子會同時開始移動,但在兩者都抵達目標格子前,玩家無法輸入下一個方向,便能避免畫面出現斜向移動或邏輯不同步的狀況。
加入主角動畫
前面的 player.atlas 只有一張靜態圖片,因此主角雖然可以移動,但畫面看起來像是直接滑到下一格。
接下來加入行走動畫,讓主角移動時可以播放連續影格,增加遊戲的動態效果。
Defold 的 Sprite 動畫是使用 Flipbook Animation 實作的。只要將多張圖片加入同一個 Animation,Defold 就會按照指定的 FPS 依序播放這些圖片。
建立 walk 動畫
開啟主角使用的 Atlas:
player.atlas
在 Atlas 中新增一個 Animation:
Animation Id: walk
接著將行走動畫的圖片加入 walk:
player_walk_01.png
player_walk_02.png
動畫結構會類似:
player.atlas
└── walk
├── player_walk_01.png
└── player_walk_02.png
設定動畫屬性
可以先使用以下設定:
| 屬性 | 建議值 | 說明 |
|---|---|---|
Id |
walk |
程式中使用的動畫名稱 |
FPS |
5 |
每秒播放的影格數 |
Playback |
Loop Forward |
循環播放 |
Flip Horizontal |
關閉 | 預設朝向 |
Flip Vertical |
關閉 | 不垂直翻轉 |
FPS = 5 代表每秒播放 5 個影格。由於目前只有兩張圖片,完整播放一次約需要:
[ \frac{2}{5} = 0.4 \text{ 秒} ]
如果希望動畫更活潑,可以提高 FPS;如果希望動作慢一點,則可以降低 FPS。
對於會持續播放的行走動畫,建議使用:
Playback: Loop Forward
這樣動畫播放到最後一張圖片後,會重新從第一張開始播放。
在程式中播放動畫
建立好 walk 動畫後,可以在 player.script 中使用:
sprite.play_flipbook("#sprite", "walk")
其中:
#sprite:player.go中 Sprite 元件的 Id。"walk":player.atlas中建立的 Animation Id。
如果 Sprite 元件的 Id 不是 sprite,就必須改成實際使用的 Id:
sprite.play_flipbook("#player_sprite", "walk")
避免重複播放相同動畫
遊戲中的 update() 或輸入處理函式可能會被頻繁呼叫。如果每次呼叫都執行:
sprite.play_flipbook("#sprite", "walk")
動畫會不斷被重新設定,可能導致它一直停在第一幀,無法正常播放。
因此,可以使用 self.current_animation 記錄目前正在播放的動畫,只在動畫真的改變時才切換。
-- 播放指定動畫。
--
-- self:
-- 目前 Game Object 的狀態資料。
--
-- animation:
-- 要播放的動畫名稱,例如 "walk"、"idle" 或 "push"。
local function play_animation(self, animation)
-- 只有當新動畫與目前動畫不同時,才進行切換。
--
-- 這樣可以避免每次輸入或每一幀都重新播放同一個動畫,
-- 導致動畫一直被重設在第一張影格。
if self.current_animation ~= animation then
-- 記錄目前正在播放的動畫。
self.current_animation = animation
-- 播放 Sprite 的 Flipbook Animation。
sprite.play_flipbook("#sprite", animation)
end
end
在 init() 設定初始動畫
可以在 init() 中設定主角初始狀態:
function init(self)
-- 預設主角沒有移動。
self.is_moving = false
-- 記錄目前動畫。
self.current_animation = nil
-- 遊戲開始時播放靜態圖片或待機動畫。
play_animation(self, "player")
end
如果 Atlas 中目前只有 walk 和 player 兩個 Animation,player 可以代表靜態主角圖片。
若尚未建立 idle 動畫,也可以直接使用:
play_animation(self, "walk")
但這樣主角一進入遊戲就會持續播放行走動畫。更好的方式是另外建立:
idle
讓主角停止移動時顯示待機圖片。
移動時播放動畫
假設主角只有在移動動畫期間播放 walk,可以在開始移動時切換:
local function move_animation(self, id, position)
self.is_moving = true
-- 開始移動時播放行走動畫。
play_animation(self, "walk")
go.animate(
id,
"position",
go.PLAYBACK_ONCE_FORWARD,
position,
go.EASING_OUTQUAD,
M.TIME_INTERVAL,
0,
function()
-- 移動動畫完成。
self.is_moving = false
-- 若已建立 idle 動畫,移動完成後切回待機狀態。
play_animation(self, "idle")
end
)
end
如果目前沒有 idle 動畫,可以暫時將最後一行改成:
play_animation(self, "player")
完整流程如下:
玩家按下方向鍵
↓
確認目前沒有其他移動動畫
↓
播放 walk
↓
移動 Game Object
↓
移動動畫完成
↓
解除 is_moving
↓
切換回 idle 或 player
搭配方向翻轉
如果主角只製作一個朝向的行走動畫,可以透過水平翻轉來處理左右方向。
例如,主角原本朝右:
sprite.set_hflip("#sprite", false)
play_animation(self, "walk")
向左移動時:
sprite.set_hflip("#sprite", true)
play_animation(self, "walk")
可以將方向處理整理成函式:
local function set_direction(self, dx)
if dx < 0 then
-- 向左:水平翻轉。
sprite.set_hflip("#sprite", true)
elseif dx > 0 then
-- 向右:恢復原始方向。
sprite.set_hflip("#sprite", false)
end
end
在移動前呼叫:
set_direction(self, dx)
play_animation(self, "walk")
注意動畫名稱
程式中的動畫名稱必須與 Atlas 中的 Animation Id 完全一致:
sprite.play_flipbook("#sprite", "walk")
對應:
Animation Id: walk
以下寫法會造成錯誤或無法播放:
sprite.play_flipbook("#sprite", "Walk")
sprite.play_flipbook("#sprite", "walking")
sprite.play_flipbook("#sprite", "player_walk")
因為 Walk、walking 與 player_walk 都不是目前建立的 walk。
完成後,主角在移動時會播放 walk 動畫,移動結束後則可以切換回待機動畫,讓遊戲畫面更自然。
範例程式碼下載
總結
這篇教學完成了一個功能完整的倉庫番遊戲——支援自由新增關卡、背景音樂、過關判定與動畫效果。雖然是个小品遊戲,但涵蓋了 Defold 開發的核心流程,讓你能夠實實在在地掌握遊戲製作的完整技能樹。
這也是筆者首次撰寫如此長篇的教學文章。由於遊戲開發偏重視覺呈現,為了完整記錄每個步驟,最終累積了 18 部影片 與 25 張圖片。整個過程花費了不少心思,但能將開發歷程詳實地呈現給大家,一切都值得。