PalForge
API 参考

Audio

用自己的代码播放 Palworld 自带的音乐和音效

读完本页你可以做到

  • 随时播放游戏自带的音乐和音效
  • 让玩家捡起道具时响一段提示音
  • 让放置好的建筑物在被交互时发出声音
  • 在世界加载完成的那一刻开始播放主题曲
  • 给一个声音起一次名字,在整个内容包里反复使用

播放声音

Palworld 自带将近两千种声音:脚步声、爆炸声、菜单点击声、Boss 战音乐。Audio 让你给其中 一个起名字,然后在自己的 Lua 代码里播放它。

local Theme = Audio.bgm{
    id        = "AKE_BGM_Title",
    soundId   = "AKE_BGM_Title",
    soundPath = "/Game/Pal/Sound/Events/SE/UI/BGM_Title/AKE_BGM_Title.AKE_BGM_Title",
}

Theme:play()          -- on the local player pawn
Theme:stop()

声音总是在世界里的某个东西播放——一个角色、一只帕鲁、一个放置好的建筑物。这个东西 叫做 actor。不传 actor 时,声音在玩家正在操控的角色上播放。

游戏不会告诉你“有声音响了”,所以播放的时机由你自己决定:在道具的处理函数里、在建筑物的 处理函数里,或者在世界事件里调用 :play()。本页剩下的部分讲怎么给声音起名字,以及播放 调用可能在哪里出问题。

定义声音

Audio{ ... } 就能定义一个声音。你传的表会被逐个字段检查,声音以 id 为键登记下来, 返回值是一个 Audio.Handle——带有 :play():stop() 的小对象。

content/sounds.lua
local Explosion = Audio{
    id          = "example:Blast",
    name        = "Blast",
    description = "Played when the reactor overloads.",
    kind        = "se",
    soundId     = "AKE_General_Explosion",
    soundPath   = "/Game/Pal/Sound/Events/SE/Common/Explosion/AKE_General_Explosion.AKE_General_Explosion",
}

Audio.bgmAudio.se 做的事情和 Audio{ ... } 完全一样,只是 kind 已经替你填好了。 它们会复制你的表而不是改写它;表里的 kind 如果和它们设定的值不一致,调用会直接报错,而 不是被悄悄覆盖。

Audio.bgm{ id = "example:Theme", kind = "se" }
-- PalForge: Audio.bgm: kind is fixed to "bgm" here, but got "se" - use Audio{ ... } to set it

可以设置的字段

大多数声音只需要一个字段:id。想指定游戏自带的事件名就再加 soundId,而 soundPath 只在 那个名称不在目录里、或者你想覆盖目录时才需要。同一份列表随时可以在游戏里打印出来:

local schema = require("palforge.core.schema")
print(schema.help("Audio.Spec"))          -- every field, type, default and meaning
schema.get("Audio.Spec").fields           -- the same as a table, for tooling

Prop

Type

写了没有声明过的字段,在定义声音的那一刻就会报错,而且消息里会给出你可能想写的字段名, 所以拼写错误不会被悄悄忽略。

声音是怎么传到游戏里的

AkAudioEvent 是 Palworld 给“一个可播放的声音”起的名字。每一个都有一个名称(比如 AKE_GrabItem)和一个资源路径(这个名称所在的文件)。你的声明会被转换成一张小表, 记录你给出了其中的哪些,这张表决定走哪条路径。

soundFile 根本走不到这张图里:写上它,定义调用当场停下,消息里会点名两个真的能发声的字段。 完全没有指定声音的定义也不会走进死胡同,因为 Audio{ ... } 会先用 id 把 soundId 填上——见 下面的“省略声音字段”。

只有名称、只有路径、或者两个都有,游戏都会被要求先加载资源,再在你的 actor 上播放:

PlayAkEventSoundByActor 不是猜出来的。它是 UPalSoundUtility 上被反射出来的函数之一,已安装 二进制的 CXX 转储把它声明为 bool PlayAkEventSoundByActor(AActor*, UAkAudioEvent*),而且一次录制 到的会话里,游戏自己就用这两个参数、按这个顺序调用了它六次。这棵代码树里缺的是你这边这次调用 是否真的响了,所以 :play() 只报告有没有发出调用、别的什么都不报告。

加载资源之前,路径会先被补全。UE 的对象路径是 <package>.<object>,所以只写了 package 一半的 soundPath/Game/.../AKE_BGM_Title)会被 core/assetpath 补成 /Game/.../AKE_BGM_Title.AKE_BGM_Title,而已经带了 object 一半的路径原样通过。已加载的资源按补全 后的路径保存下来,所以第一次播放要付加载的代价,之后每次都复用。如果 LoadAsset 拿不到资源, 会用 StaticFindObject(path) 再试一次;拿回来的东西在传出去之前还要做类型检查——不是 AkAudioEvent 的对象会被拒绝,并在日志里写明它到底是什么类,而不是被塞进 UAkAudioEvent* 参数里。

soundId 和 soundPath 各自做什么

这两个字段不是二选一。它们服务于同一次调用的两个阶段,而且任何一个单独存在都是完整的 定义:只要有非空的 soundId 或非空的 soundPath,声音就能解析成功。

  • 发出声音的是 soundPath。播放时优先用它:加载资源,送出声音,到此为止。只带 soundPath 的定义走的就是这个分支。
  • soundId 是 AkAudioEvent 名称,转换时会替你去查。Class:source() 先解析带命名空间的 id ("pack:Theme" 变成 "pack_Theme"),然后在你没有传 soundPath 时,把这个名称拿到 native/audio.lua 的目录里去查——1957 个 AkAudioEvent 名称与资源路径的对应表——并把真正的路径 补上。所以目录里有的名称,只写它就走会发声的那一支。
  • 目录里没有的名称拿不到路径,调用会落到 PlaySoundByActor(actor, { Key = FName(id) }, ...),它查的是 SoundID 表——另一个命名空间,对 AkAudioEvent 名称来说是无声的。
  • 你自己写的 soundPath 永远不会被目录覆盖。

:play() 返回 true 只表示发出了一次原生调用,绝不表示你听到了什么。到现在还会返回 true 却什么都不播的情况,是目录里没有的 soundId:补不上路径,于是落到无声的 PlaySoundByActor 分支。声音不响的时候,把 :source().path 打出来——那里是 nil,诊断就到此为止。

-- plays: the asset path is a complete definition on its own
Audio.se{ id = "example:Quiet", soundPath = "/Game/Pal/Sound/Events/SE/UI/Item/AKE_GrabItem.AKE_GrabItem" }

-- plays too: AKE_GrabItem is in the catalog, so lowering attaches its path
Audio.se{ id = "example:NameOnly", soundId = "AKE_GrabItem" }

-- silent: no AkAudioEvent of that name, so there is no path to attach
Audio.se{ id = "example:Missing", soundId = "AKE_NotAnEvent" }

省略声音字段

完全没有指定声音的定义——没有 soundId、没有 soundPath、也没有 source——会拿自己的 id 当作 AkAudioEvent 名称。一行写法能用,就是因为这个:

local Theme = Audio.bgm{ id = "AKE_BGM_Title" }
Theme:source()
--> { kind = "native", id = "AKE_BGM_Title",
-->   path = "/Game/Pal/Sound/Events/SE/UI/BGM_Title/AKE_BGM_Title.AKE_BGM_Title" }

这份声明里没有任何地方写了路径,是目录查询把它放进去的。所以一行写法对 1957 个事件名里的任何 一个都走会发声的那一支,只有目录从没听说过的 id 才会落到无声的 PlaySoundByActor 分支。

只有在三个指定声音的字段全都不存在时,id 才会被当作声音名称。其中任何一个(包括 source) 只要设置了,这个行为就关掉。soundFile 不参与这个判断——它在判断之前就被拒绝了。

bgm 和 se

kind"se""bgm",默认是 "se",对播放没有任何影响——音乐和音效走的是同一个 PlayAkEventSoundByActor 调用。它存在是为了让你自己的代码和工具能区分两者:

for _, sound in ipairs(Audio.get_all()) do
    if sound:kind() == "bgm" then
        sound:stop()
    end
end

因为 kind 是标签而不是路径,把同一个 id 既定义成音乐又定义成音效,只会保留最后一次调用的 结果。native/audio.lua 里的 bgm(name) / se(name) 辅助函数也是一样。

句柄

Audio{ ... }Audio.bgmAudio.seAudio.get 都返回 Audio.Handle

动作

Handle:play(actor)        --> boolean
Handle:stop(actor)        --> boolean
Handle:setVolume(volume, actor)  --> boolean

actor 可以省略。省略时,playstop 都会用 FindFirstOf("PalPlayerCharacter") 找到 玩家正在操控的角色。没有加载世界时就没有这个角色,两者都会在到达声音调用之前返回 false

local Blast = Audio.get("AKE_General_Explosion")

Blast:play()                       -- on the player pawn
Blast:play(somePalActor)           -- on any actor you already hold
Blast:stop(somePalActor)           -- stops EVERYTHING on that actor

这个布尔值说的是调用,不是声音。只有真的发出了原生调用,playstop 才返回 true。 无效的 actor、找不到 PalSoundUtility、调用内部抛出异常,以及解析不出任何东西的定义,全都 是 falsefalse 值得你处理:它意味着连尝试都没有发生。

content/boot_sound.lua
local log = require("palforge.utils.log").scope("audio")

-- a path-only definition: core.sound.resolve accepts it, so this reaches the engine
local Theme = Audio.bgm{
    id        = "example:BootTheme",
    soundPath = "/Game/Pal/Sound/Events/SE/UI/BGM_Title/AKE_BGM_Title.AKE_BGM_Title",
}

if not Theme:play() then
    log.warn("no player pawn yet, or the definition resolved to nothing")
end

:stop 不是按声音来的。它调用的是 PalSoundUtility:StopSoundByActor(actor),会让那个 actor 上正在播放的所有声音全部停下,不只是这一个。在玩家角色上停掉你的 BGM,会把那个角色的脚步声 和语音一起停掉。想停一个、让其他继续响,需要 Wwise 的 PlayingID,而声音本身没有保存它。

:setVolume:stop 一样是按 actor 生效的。它调整的是那个 actor 正在发出的一切 —— 你的声音和 游戏的声音都算 —— 而不是你调用它的那一个声音,所以用哪个句柄调用都没有区别。actor 可以省略, 省略时和 :play:stop 一样用玩家自己的角色。

volume 就是一个倍数:1.0 保持原样,0.5 是一半,0.0 是静音。调用发出去了它就返回 true, 而“发出去了”就是它的全部含义:还没有人听到过它真的改变音量。这个测量需要加载好的世界和一双耳朵, 所以它作为一个声明好的钩子留着——pf_hook audio-setvolume-audible 会播一个目录里的事件、设成 0.2、再播一次,由你来判断第二次是不是更轻。

想让同一个 actor 上的某一个声音更轻,就从目录里挑一个更轻的事件。把它们放到不同的 actor 上播 则是另一条路:那样音量就只作用于其中一个。

查询

Handle:source()       --> { kind = "native"|"file", ... } | nil
Handle:kind()         --> "bgm" | "se"
Handle:name()         --> the declared name, or the id
Handle:description()  --> string | nil
Handle.id             --> the sound's id

要确认一个定义到底能不能到达游戏,最快的办法就是 :source()——它返回的正是播放调用会拿到 的那张表。

local s = Audio.get("AKE_BGM_Title"):source()
print(s.kind, s.id, s.path)

取用自己没有定义过的声音

Audio.get(id)     --> Audio.Handle, never nil
Audio.get_all()   --> Audio.Handle[]

Audio.get 会返回你之前定义过的声音,前提是有。没有的话,它会当场造一个很薄的定义—— { id = id, soundId = id }——这样任何声音名称至少都能拿到手。这个临时定义本身不带资源路径, 但转换时会跑同一套目录查询,所以 Audio.get("AKE_General_Explosion"):play() 对目录里的任何事件 名称都走会发声的那一支,哪怕这个声音从没被定义过。

启动时 PalForge 会加载 palforge.native.audio,它把六个现成的声音登记在框架自己的包名下。 Audio.get 会用真正的定义(而不是临时的薄定义)回答的,就是这些 id:

辅助函数Id
MainThemeAKE_BGM_Title
BattleThemeAKE_LegendDeer_State_Strong_Strong
VictoryThemeAKE_Arena_Victory_01
ExplosionAKE_General_Explosion
LaserAKE_Weapon_ChargeLaserRifle_Fire
FootstepAKE_Pal_Footstep
local audio = require("palforge.native.audio")

audio.MainTheme:play()
audio.Explosion:play()

目录里的任意名称

native/audio.lua 还带着 M.CATALOG,里面是游戏里每一个声音名称和它配对的资源路径。 bgm(name) / se(name) 会在你第一次索取时,把目录里的一条变成真正的定义——名称和路径都 带上——并保留下来供下次使用。get(name)se(name) 相同。不在目录里的名称,三者都返回 nil

local audio = require("palforge.native.audio")

local levelUp = audio.se("AKE_CampLevelUp")
if levelUp then levelUp:play() end

audio.CATALOG["AKE_CampLevelUp"]
--> "/Game/Pal/Sound/Events/SE/UI/CampLevelUp/AKE_CampLevelUp.AKE_CampLevelUp"

native.audio.se(name) / native.audio.bgm(name)Audio.get(name) 都能查到目录,所以游戏 原有的声音用哪一个都能播。但 native.audio 这一对能告诉你一件 Audio.get 做不到的事:这个版本上 不存在的 AkAudioEvent 名称,它们返回 nil;而 Audio.get 永远不返回 nil,交给你的是一个 :play() 无声的句柄。另外它们造出来的东西并不登记,要登记请显式调用 native.audio.publish(name)

定义一次,播放多次

Audio{ ... } 会创建一个声音。Audio.get(id) 和你存在变量里的句柄只是把它查出来。处理函数 每次事件都会运行,所以把定义放在加载时执行,处理函数里只放播放。

content/sounds.lua
-- module scope: runs once when the pack loads
local Pickup = Audio.se{
    id        = "example:Pickup",
    soundId   = "AKE_GrabItem",
    soundPath = "/Game/Pal/Sound/Events/SE/UI/Item/AKE_GrabItem.AKE_GrabItem",
}

return { Pickup = Pickup }
content/items.lua
local sounds = require("content.sounds")

Item{
    id = "Wood",
    events = {
        onObtain = function(item, ctx)
            sounds.Pickup:play()          -- a play, not a declaration
        end,
    },
}

绝对不要在处理函数里调用 Audio.bgm{ ... }Audio.se{ ... }。每次调用都会重新检查这张 表,造一个新的定义,并替换掉同一个 id 下已经登记的内容——每一次事件都会这样。把句柄放在 处理函数外面的局部变量里,或者在处理函数里调用 Audio.get(id)

-- wrong: re-declares and re-registers the sound on every pickup
onObtain = function(item, ctx)
    Audio.se{ id = "AKE_GrabItem" }:play()
end

-- fine: a lookup
onObtain = function(item, ctx)
    Audio.get("AKE_GrabItem"):play()
end

还不能用的功能

这里有三样东西做的事比你预期的少:一样被直接拒绝,两样管到的范围比你要求的大。

自定义音频文件不会播放,而且 soundFile 是硬错误。 写上它,定义调用就会停下,消息里会点名 待解决项 audio-custom-file-loader,以及两个真的能发声的字段。报错而不是忽略正是重点: soundFile 的转换排在 soundId / soundPath 之前,所以在一个能正常发声的 soundId 旁边加上它, 会把本来在响的声音弄哑;而且在这棵代码树量过的任何版本上,它从来没有真的播放过。

Wwise 那一半已经有了定论,而且是看出来的:2026-08-02,在一次加载好的存档里, AkExternalMediaAssetAkMediaAsset 都作为类默认对象解析成功,却都声明了个函数,进程里 也没有它们的任何实例。这个构建上根本不存在外部媒体或 SetMedia 这条路,谁再翻 AkAudio 也翻 不出来。

引擎原生那一半不是封死,而是收窄了。 USoundWave 没有声明导入器,所以没有东西能自己去读磁盘 上的 .wav;但 UGameplayStatics 的整套音频接口都留在了正式版里(137 个函数里就有 PlaySound2DCreateSound2DSpawnSoundAttached),进程里有真正加载着的 SoundWaveSoundBase 实例,UE4SS 也提供了 StaticConstructObject。还没有人弄清楚的是:能不能用这种方式造出 一个 USoundWave,并从 Lua 把它的采样缓冲填上。在有人弄清楚之前,请从目录里挑一个最接近的声音。

:stop 会停掉那个 actor 上的一切。 StopSoundByActor 只接受一个 actor,别的什么都不 接受,所以不管你是从哪个声音上调用的,那个 actor 都会彻底安静下来。如果需要某个声音继续 响,就把它放到另一个 actor 上播——比如音乐放在放置好的建筑物上,音效放在玩家身上。

第三样是上面说过的 :setVolume,它和 :stop 的作用范围一样,而且这是结构决定的,不是选择的 结果。它调用的原生函数是 UAkGameplayStatics::SetOutputBusVolume(float BusVolume, AActor* Actor),里面根本没有总线名称—— 第二个参数是 actor,也就是 Wwise 的游戏对象——所以它缩放的是「一个发声体送往它的输出总线的量」。 这正是 :play 送出、:stop 清空的那个范围。

这个版本上没有更细的控制,而且这是查清楚的结论,不是没试过。真正可能做到按声音的是 RTPC 这条路, 它被一组数字堵死了:整个版本一共声明了三个 AkRtpc 资源——Supply_AltitudeOverHeatRifleChargeLaserRifle_01——AkAuxBus零个AkAudioBank 也是零个。这三个没有一个是音量, 所以根本不存在可以调的 RTPC 音量参数,也没有名字可以喂给那个按总线名的重载。真要做按声音的音量 滑块,还是得靠分开的 actor 或分开的事件。

实用示例

捡起道具时响一段提示音

item.obtain 是真的会触发的。它的 ctx 带着 ctx.itemIdctx.count,处理函数的第一个 参数是道具自己的句柄。

content/pickup_jingle.lua
local api = require("palforge.api")
local audio = require("palforge.native.audio")

local Jingle = audio.se("AKE_CampLevelUp")   -- catalog entry, name + path

api.Item{
    id          = "Berries",
    name        = "Berries",
    description = "Plays a jingle when you pick some up.",
    events = {
        onObtain = function(item, ctx)
            if (ctx.count or 0) >= 10 then
                Jingle:play()
            end
        end,
    },
}

ctx.count 是从游戏自己的消息里尽力读出来的,可能是 nil,所以要做判断。

世界准备好时播放音乐

音乐什么时候开始由你决定。world.ready 会在玩家角色连续几次检查都在场之后触发一次,那也 正是 :play() 第一次有默认角色可用的时刻。

content/theme.lua
local event = require("palforge.core.event")
local audio = require("palforge.native.audio")

event.on("world.ready", function(ctx)
    audio.MainTheme:play()
end)

想自己写定义、不用现成的辅助函数,就把两个字段都传上:

content/theme.lua
local event = require("palforge.core.event")

local Theme = Audio.bgm{
    id          = "example:WorldTheme",
    name        = "World Theme",
    description = "Plays once the world finishes loading.",
    soundId     = "AKE_BGM_Title",
    soundPath   = "/Game/Pal/Sound/Events/SE/UI/BGM_Title/AKE_BGM_Title.AKE_BGM_Title",
}

event.on("world.ready", function(ctx)
    Theme:play()
end)

event.on("world.left", function(ctx)
    Theme:stop()      -- stops every sound on the pawn, not just this one
end)

与建筑物交互时发出声音

建筑物上的 onRightClick 是真的会触发的。它的第一个参数就是放置好的建筑物本身,所以 self.actor 就是立在世界里的那个结构——在它上面播放,声音就从建筑物发出,而不是从玩家 身上发出。

content/palbox_sound.lua
local api = require("palforge.api")
local audio = require("palforge.native.audio")

local Chime = audio.se("AKE_Build_PalBox")

api.Building{
    id          = "PalBoxV2",
    name        = "Pal Box",
    description = "Chimes when you interact with it.",
    gridCm      = 100,
    state       = { uses = 0 },
    events = {
        onRightClick = function(self, ctx)
            self.state.uses = self.state.uses + 1
            self:save()
            Chime:play(self.actor)        -- ctx.actor is the same building actor
        end,
    },
}

building.interactctx 带着 ctx.actor(建筑物)、ctx.player(交互的那个角色)和 ctx.buildId。改成在 ctx.player 上播放,声音就跟着按下按键的人。

在播放时挑选声音

source 让你在播放的那一刻决定声音,而不是在字段里写死。这个函数是在定义上调用的,所以 self 带着你声明的那些字段;它返回播放调用要用的表——想要无声就返回 nil

content/dynamic_sound.lua
local audio = require("palforge.native.audio")

local Footstep = Audio.se{
    id   = "example:Footstep",
    data = { wet = false },
    source = function(self)
        local name = self.data.wet and "AKE_Pal_Footstep_Water" or "AKE_Pal_Footstep"
        return { kind = "native", id = name, path = audio.CATALOG[name] }
    end,
}

Footstep:play()

形状要守住:{ kind = "native", id = <AkAudioEvent name>, path = <asset path> }(只要其中 一个是非空字符串,另一个就可以省略),或者 { kind = "file", path = ... }。除此之外的东西 都会解析成 nil,而 nil 源什么都不播,并让 :play() 返回 false

错误

每一项检查失败,都会以 PalForge: 开头的错误中止调用。调用不会只成功一半,所以你不会拿到 一个造了一半的声音。

PalForge: Audio: field "id" is required (audio id: the AkAudioEvent name, or "pack:name")
PalForge: Audio: field "id" is invalid: must be a non-empty string
PalForge: Audio: unknown field "path" (did you mean "soundPath"?). Valid fields: id, name, description, kind, soundId, soundPath, soundFile, source, data
PalForge: Audio: field "kind" must be one of { "se", "bgm" }, got "music"
PalForge: Audio.bgm: kind is fixed to "bgm" here, but got "se" - use Audio{ ... } to set it

soundFile 有自己专属的消息。因为它要说清楚一个已声明的字段为什么会被拒绝,所以它是这个模块里最长 的一条。以下是节选:

PalForge: Audio: soundFile is not accepted (id "example:Blast", soundFile "C:/mods/example/blast.wav").
Custom audio files do not play on this build: [...] This is the open item audio-custom-file-loader.
It is an ERROR rather than a no-op because soundFile used to OUTRANK soundId/soundPath, so setting it
beside a working soundId silenced a sound that was playing. Name a game sound instead:
soundId = "AKE_UI_Common_Menu_Close", or soundPath = the asset path from PalForge.native.audio.CATALOG.

Audio.get 接受的是一个裸字符串,所以它报的消息不一样:

Audio.get: id (string) is required

把声音登记下来是尽力而为的:即使这一步失败,你仍然会拿到一个能用的句柄。

相关页面

小结

  • Audio{ ... }Audio.bgm{ ... }Audio.se{ ... } 定义声音。只有 id 是必填的。
  • 目录里有的 soundId 只写它就够了:转换时会从 native/audio.lua 把资源路径补上。目录里没有 那个名称、或者想覆盖时,才传 soundPath
  • soundFile 是定义时的硬错误。这个版本上自定义音频文件不会播放。
  • handle:play(actor) 播放。省略 actor 就在玩家角色上播放。
  • :play() 返回 true 表示调用发出去了,不表示你听到了。声音不响时检查 :source().path
  • :stop(actor) 会停掉那个 actor 上的一切,:setVolume(volume, actor) 会调整那个 actor 上的一切。 两者都是按 actor 生效,从来不是按单个声音。
  • 声音在内容包加载时定义一次,处理函数里只调用 :play()

接下来读 Item,给玩家捡起或使用的东西加上声音。

On this page