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() 的小对象。
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.bgm 和 Audio.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 toolingProp
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.bgm、Audio.se 和 Audio.get 都返回 Audio.Handle。
动作
Handle:play(actor) --> boolean
Handle:stop(actor) --> boolean
Handle:setVolume(volume, actor) --> booleanactor 可以省略。省略时,play 和 stop 都会用 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这个布尔值说的是调用,不是声音。只有真的发出了原生调用,play 和 stop 才返回 true。
无效的 actor、找不到 PalSoundUtility、调用内部抛出异常,以及解析不出任何东西的定义,全都
是 false。false 值得你处理:它意味着连尝试都没有发生。
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 |
|---|---|
MainTheme | AKE_BGM_Title |
BattleTheme | AKE_LegendDeer_State_Strong_Strong |
VictoryTheme | AKE_Arena_Victory_01 |
Explosion | AKE_General_Explosion |
Laser | AKE_Weapon_ChargeLaserRifle_Fire |
Footstep | AKE_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) 和你存在变量里的句柄只是把它查出来。处理函数
每次事件都会运行,所以把定义放在加载时执行,处理函数里只放播放。
-- 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 }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,在一次加载好的存档里,
AkExternalMediaAsset 和 AkMediaAsset 都作为类默认对象解析成功,却都声明了零个函数,进程里
也没有它们的任何实例。这个构建上根本不存在外部媒体或 SetMedia 这条路,谁再翻 AkAudio 也翻
不出来。
引擎原生那一半不是封死,而是收窄了。 USoundWave 没有声明导入器,所以没有东西能自己去读磁盘
上的 .wav;但 UGameplayStatics 的整套音频接口都留在了正式版里(137 个函数里就有
PlaySound2D、CreateSound2D、SpawnSoundAttached),进程里有真正加载着的 SoundWave 和
SoundBase 实例,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_Altitude、OverHeatRifle
和 ChargeLaserRifle_01——AkAuxBus 是零个,AkAudioBank 也是零个。这三个没有一个是音量,
所以根本不存在可以调的 RTPC 音量参数,也没有名字可以喂给那个按总线名的重载。真要做按声音的音量
滑块,还是得靠分开的 actor 或分开的事件。
实用示例
捡起道具时响一段提示音
item.obtain 是真的会触发的。它的 ctx 带着 ctx.itemId 和 ctx.count,处理函数的第一个
参数是道具自己的句柄。
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() 第一次有默认角色可用的时刻。
local event = require("palforge.core.event")
local audio = require("palforge.native.audio")
event.on("world.ready", function(ctx)
audio.MainTheme:play()
end)想自己写定义、不用现成的辅助函数,就把两个字段都传上:
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 就是立在世界里的那个结构——在它上面播放,声音就从建筑物发出,而不是从玩家
身上发出。
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.interact 的 ctx 带着 ctx.actor(建筑物)、ctx.player(交互的那个角色)和
ctx.buildId。改成在 ctx.player 上播放,声音就跟着按下按键的人。
在播放时挑选声音
source 让你在播放的那一刻决定声音,而不是在字段里写死。这个函数是在定义上调用的,所以
self 带着你声明的那些字段;它返回播放调用要用的表——想要无声就返回 nil。
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 itsoundFile 有自己专属的消息。因为它要说清楚一个已声明的字段为什么会被拒绝,所以它是这个模块里最长
的一条。以下是节选:
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把声音登记下来是尽力而为的:即使这一步失败,你仍然会拿到一个能用的句柄。
相关页面
Item
item.obtain 和 item.use 钩子。Building
onRightClick,以及 self.actor。生命周期
world.ready,以及哪些钩子真的会触发。Schema
小结
- 用
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,给玩家捡起或使用的东西加上声音。