用Defold做遊戲, 大家一起來推箱子吧…

序言

繼上次 Defold 初體驗之後,相信大家已經掌握了基礎操作——新增物件、製作動畫、加入音樂、處理輸入等核心功能應該都難不倒你了。

本專案將帶領你使用 Defold 引擎,從零開始打造一款經典的 倉庫番(Sokoban) 遊戲。我們將從專案建立、基礎設定與素材匯入一步步實作,讓你在完整遊戲開發流程中,進一步鞏固並深化 Defold 的應用能力。

作業環境

項目 版本
macOS Tahoe 26.5.2
Defold 1.13.1

相關素材

名稱 下載
圖片素材 kenney.nl
中文字型 jf open 粉圓體
背景音樂 Internet Archive
過關音效 OpenGameArt.org

倉庫番(Sokoban)

專案設定

建立新專案

  1. 開啟 Defold IDE
  2. 選擇空白專案模板(Empty Project)。
  3. 建立一個名為 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

也可以直接使用以下連結:

defold-utils 0.1.0

設定畫面尺寸

接著,在 game.projectDisplay 區段設定遊戲畫面大小:

Width:  384
Height: 384

本遊戲的關卡尺寸規劃為 6 x 6 格,每個格子的尺寸為 64 x 64 像素,因此總畫面尺寸為:384 × 384 像素。

下圖為 6 x 6 格關卡的基本配置概念:

下載圖片素材

本專案使用 Kenney 提供的 Sokoban 素材包。Kenney 的素材非常適合用於遊戲原型、教學與獨立遊戲開發。

可以從下列頁面下載:

Kenney — Sokoban Asset Pack

下載完成後,將需要使用的圖片素材整理並放入專案的 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 sheettile sheet

在倉庫番遊戲中,我們會從這張圖片切出地板、牆壁與其他地圖元件,並將它們組合成關卡地圖。

  1. 在 Defold 的 Assets 面板中按右鍵。
  2. 選擇 New… → Tile Source
  3. 建立一個名為 tiles.tilesource 的 Tile Source。
  4. tiles.tilesource 的屬性面板中,將 tile_sheet.png 指定為來源圖片。
  5. 將 Tile 的尺寸設定為:
Tile Width:  64
Tile Height: 64

由於圖片素材中的每個元件都是 64 x 64 像素,設定完成後,Defold 就會自動將 tile_sheet.png 切割為多個獨立的 Tile。

這些 Tile 可以理解成遊戲地圖的「零件」,也常被稱為:

  • Tile
  • 瓦片
  • 地圖格
  • 拼圖元件

建立 Tile Map

接著建立真正用來繪製關卡的 Tile Map。

  1. Assets 面板中按右鍵。
  2. 選擇 New… → Tile Map
  3. 建立一個名為 level1.tilemap 的 Tile Map。
  4. 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,才能在執行時顯示出來。

  1. 開啟 main.collection
  2. 新增一個 Game Object
  3. 將 Game Object 的 Id 設定為:
level1_map
  1. 在這個 Game Object 上按右鍵,選擇 Add Component File
  2. 選擇剛才建立的 Tile Map:
level1.tilemap

加入完成後,可以先執行 Project → Build,再按下執行按鈕測試成果。若設定正確,遊戲畫面中應該會出現剛才編輯完成的地圖。

建立可複用物件

接下來會建立遊戲中的主角物件。這個流程包含建立 Game Object、Sprite、Atlas、Script 與輸入綁定,是 Defold 開發中非常常見的基礎操作。

後續要建立箱子、牆壁、目標點或其他可互動角色時,也可以使用相同的方式,因此建議熟悉這個工作流程。

建立主角物件

首先建立主角所需的三個檔案:

檔案 類型 用途
player.go Game Object 主角物件本體,用來組合 Sprite、Script 與其他元件
player.script Script 主角行為邏輯,例如輸入、移動與碰撞判斷
player.atlas Atlas 管理主角的圖片資源與動畫影格

建立完成後,依序進行以下設定:

  1. 開啟 player.atlas
  2. 在 Atlas 中新增一個 Image
  3. 將主角圖片 player.png 加入 Atlas。
  4. 開啟 player.go
  5. player.go 上新增一個 Sprite 元件。
  6. 將 Sprite 的 Image 屬性設定為:
player.atlas
  1. 再於 player.go 上新增一個 Component File
  2. 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 加入主場景。

  1. 開啟 main.collection
  2. 新增一個 Game Object,或直接將 player.go 拖曳到 Collection 中。
  3. 確認主角的位置位於可行走的地板上。
  4. 將主角的 Position.z 設定為大於地圖的值,例如:
Position.z = 1

地圖通常位於 z = 0。將主角設定為 z > 0,可以確保它會繪製在地圖上方,而不會被地板或牆壁遮住。

加入移動程式

最後,將主角移動程式加入 player.script,再執行 Project → Build 測試結果。

以下是一個最小可運作的移動範例。它會取得輸入焦點,並在按下方向鍵或 WASD 時移動主角。

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 相同:

  1. 建立 box.atlas
  2. box.atlas 中新增 Image。
  3. 將箱子圖片 box.png 加入 Atlas。
  4. 建立 box.go
  5. box.go 中新增 Sprite 元件。
  6. 將 Sprite 的 Image 屬性指定為 box.atlas

完成後,box.go 會成為可重複使用的箱子預製物件。

box.go
└── sprite
    └── 使用 box.atlas 顯示箱子圖片

建立 target.go

目標點 target.go 的建立方式也相同:

  1. 建立 target.atlas
  2. 將目標點圖片 target.png 加入 Atlas。
  3. 建立 target.go
  4. 新增 Sprite 元件。
  5. 將 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.golevel_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.projectCustom Resources

在 Defold 中,圖片、Atlas、Tile Source、Game Object 等資源通常會因為被其他資源直接引用而自動打包;但 JSON、TXT、CSV 等以程式動態讀取的檔案,Defold 無法自動判斷是否需要包含在遊戲建置檔中。

因此,必須手動設定 Custom Resources。

  1. 開啟 game.project
  2. 找到 Project 區段。
  3. 找到 Custom Resources 欄位。
  4. 輸入要包含的檔案或資料夾路徑。

如果只需要包含單一 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

程式主要做了三件事:

  1. 透過 sys.load_resource() 讀取 /assets/file/levels.json
  2. 使用 json.decode() 將 JSON 字串轉成 Lua table。
  3. 提供 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 時,可以依照以下步驟進行:

  1. 在專案面板中新增 GUI
  2. 將檔案命名為 hud.gui
  3. 為 GUI 指定 hud.gui_script
  4. 在 GUI 編輯器中建立完成提示面板。
  5. 新增一個文字節點,顯示關卡完成訊息。

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

這段程式會在遊戲開始時:

  1. 取得 complete_panel 節點。
  2. 取得 complete_text 節點。
  3. 預設隱藏完成面板。

接著加入顯示與隱藏函式:

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

接著:

  1. 新增一個 Game Object。
  2. 將 Id 設定為:
hud
  1. 在這個 Game Object 上新增 Component File
  2. 選擇:
hud.gui

完成後,主 Collection 的結構會類似:

main.collection
├── game
├── level1_map
├── player
└── hud
    └── hud.gui

GUI 是獨立於遊戲世界的畫面層,通常會固定顯示在螢幕上,不會隨著遊戲世界中的 Game Object 移動。
因此,HUD 很適合用來顯示關卡資訊、步數、計時器、暫停按鈕與完成提示。

進入下一關

完成 HUD 畫面後,接著將 hud.guihud.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_scriptinit() 中取得輸入焦點。

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

這段程式有兩個重要限制:

  1. 只有 complete_panel 顯示時,按鍵才會觸發下一關。
  2. 使用 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#hudmain:/game#game 是常見的 URL 範例。
實際使用時,請確認 main.collection 中的 Game Object Id 與元件 Id 是否分別為 hudgame,並依專案中的實際名稱調整。

以下是hud.gui_scriptgame.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 頁面下載:

jf open-huninn Font Releases

此字型採用 SIL Open Font License 1.1,可自由使用、分享與修改;若用於商業專案,請仍自行確認專案實際使用的字型版本與授權內容。

下載後,將字型檔放入專案內的字型資料夾,例如:

assets/
└── fonts/
    └── jf-openhuninn-2.1.ttf

建立 Font 資源

接著在 Defold 中新增一個 Font 資源:

  1. Assets 面板中,於 assets/fonts/ 資料夾按右鍵。
  2. 選擇 New… → Font
  3. 將檔案命名為:
jf-openhuninn.font
  1. 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 紋理。

panel_round 圓角背景圖片

2. 加入 hud.gui

開啟 hud.gui,在右側 Outline 的 Textures 區塊中加入:

panel_round - /assets/ui/panel_round.atlas

將 panel_round 加入 Textures

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 的寬度與高度則可以依照介面需求調整。

Slice 9 設定

4. 建置測試

完成後執行 Build,確認 HUD 是否呈現完整的圓角背景:

Slice 9 完成效果

只要背景節點使用 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_game Action,或在 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> 時會:

  1. 隱藏 title_panel
  2. 釋放標題頁的輸入焦點。
  3. 傳送 game_started 訊息給 game.script
  4. 將目前的輸入事件視為已處理,避免同一次按鍵直接操作主角。

在 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

  1. 開啟 main.collection
  2. 新增一個 Game Object。
  3. 將 Id 設定為:
title
  1. 在 Game Object 上選擇 Add Component File
  2. 加入:
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.guihud.guiplayer.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.scripton_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")

其中:

  • #spriteplayer.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 中目前只有 walkplayer 兩個 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")

因為 Walkwalkingplayer_walk 都不是目前建立的 walk

完成後,主角在移動時會播放 walk 動畫,移動結束後則可以切換回待機動畫,讓遊戲畫面更自然。

範例程式碼下載

總結

這篇教學完成了一個功能完整的倉庫番遊戲——支援自由新增關卡、背景音樂、過關判定與動畫效果。雖然是个小品遊戲,但涵蓋了 Defold 開發的核心流程,讓你能夠實實在在地掌握遊戲製作的完整技能樹。

這也是筆者首次撰寫如此長篇的教學文章。由於遊戲開發偏重視覺呈現,為了完整記錄每個步驟,最終累積了 18 部影片25 張圖片。整個過程花費了不少心思,但能將開發歷程詳實地呈現給大家,一切都值得。