一、窗体事件
(1)一般事件
我们把游戏界面的一个元素,称为一个窗体,比如一个按钮、一个输入框等。我们可以通过如下代码,简单的创建一个窗体:
local mFrame = CreateFrame("Frame")
窗体一般用于展示信息,同时要与用户交互,界面交互基于窗体事件(比如点击、鼠标划入等,我们统一称为script)。这些操作会以事件的方式传给窗体的事件处理器(如果已经set),去执行我们希望的逻辑。
我们可以通过如下代码为窗体添加一个事件处理器:
mFrame:SetScript(script, func) --设置事件处理器
这里的script是窗体事件,不同类型的窗体可能会有不同的事件,比如Button有“OnClick”,其他窗体则没有;func是事件处理器,实际上就是一个函数,它的参数对应事件script的返回值。
另外几个与窗体事件相关的API:
mFrame:GetScript(script) --获取事件处理器 mFrame:HookScript(script, func) --添加而非覆盖原事件处理器 mFrame:HasScript(script) --判断窗体是否有该事件,切记不是判断是否有事件处理器
(2)一个例子
经常会有人在聊天频道发一些物品链接,我们希望鼠标移到这些链接上时,会在提示框中显示相应的物品信息。接下来我们就来写这么一个插件,其中会用到我们刚学的窗体事件。
首先,我们需要知道几个即将用到全局变量和API:
ChatFrameX --聊天窗口,不止一个,X可以为1,2... DEFAULT_CHAT_FRAME --默认聊天框 NUM_CHAT_WINDOWS --最大聊天框个数,默认7 getglobal(name) --获取全局变量 OnHyperLinkEnter --ChatFrame提供的超链接鼠标滑入事件 OnHyperLindLeave --ChatFrame提供的超链接鼠标滑出事件
接下来,我们为每一个聊天框添加超链接滑入/滑出事件
local function showTooltip(...) --打印超链接信息 print(...) end local function hideTooltip(...) --打印超链接信息 print(...) end local function setOrHookHandler(frame, script, func) --设置或添加事件处理器 if frame:GetScript(script) then --如果已经有 frame:HookScript(script func) --添加 else frame:SetScript(script, func) --否则设置 end end for i = 1, NUM_CHAT_WINDOW do --遍历聊天框 local frame = getglobal("ChatFrame"..i) if frame then --如果聊天框存在 setOrHookHandler(frame, "OnHyperLinkEnter", showTooltip) --将showTooltip设置为超链接滑入事件处理器 setOrHookHandler(frame, "OnHyperLinkLeave", hideTooltip) --将hideTooltip设置为超链接滑出事件处理器 end end
加载插件后,我们可能获得类似如下数据:
table:168BAC00 item:40449:3832:3487:3472:0:0:0:0:80 [信仰法袍] --其中第一个参数table是窗体;第二个参数item是物品信息,40449是物品id,其他暂时不管
到目前为止,我们已经为聊天框添加了事件处理器,并且能够打印超链接信息。但是仅仅打印信息是不够的,我们希望将物品信息优雅地展示在信息窗口中。
这里直接借助游戏默认的提示框GameTooltip,通过它的SetHyperlink(linkData)方法,将我们上面获取的item作为参数传入即可。
local function showTooltip(self, linkData) --显示提示框 local linkType = string.split(":", linkData) --提取物品类型,错误类型会导致SetHyperlink报错 if linkType == "item" --物品 or linkType == "spell" --法术 or linkType == "enchant" --附魔 or linkType == "quest" --任务 or linkType == "talent" --天赋 or linkType == "glyph" --雕纹 or linkType == "unit" --? or linkType == "achievement" then --成就 GameTooltip:SetOwner(self, "ANCHOR_CURSOR") --绑定到窗体 GameTooltip:SetHyperlink(linkData) --设置超链接 GameTooltip:Show() --显示提示框 end end local function hideTooltip() --隐藏提示框 GameTooltip:Hide() end
二、游戏事件
(1)单事件
窗体除了可以监听交互事件,还能监听游戏事件,比如密语、施法等。为了方便,系统将所有游戏事件统一为OnEvent,所以我们只需要为OnEvent注册一个处理器就行了,如下:
frame:SetScript("OnEvent", mEventHandler)
但仅仅这样是收不到任何事件的,还需要为窗体注册我们感兴趣的具体事件,完整的写法如下:
local frame = CreateFrame("Frame") --创建一个窗体 local function mEventHandler(self, event, msg, sender) --定义事件处理器 print(event, sender, msg) end frame:RegisterEvent("CHAT_MSG_WHISPER") --注册密语事件 frame:SetScript("OnEvent", mEventHandler) --设置密语事件处理器
这里需要注意的是CHAT_MSG_WHISPER事件会返回13个参数,我们处理器只接收了前四个,分别是窗体、事件名、密语内容、发送者。
(2)多事件
我们可以用一个事件处理器处理多个事件,通常用if-else语句区分事件类型
local function onWhisper(msg, sender) --处理whisper --处理密语 end local function onNewZone() --处理地域改变 --进入新的地域 end local function mEventHandler(self, event, ...) --事件处理器 if event == "CHAT_MSG_WHISPER" then onWhisper(...) elseif event == "ZONE_CHANGED_NEW_AREA" then onNewZone() elseif event == "..." then --etc end end
因为所有事件的前两个参数都是self和event,所以一般只把第三个参数开始的...可变参数传给对应的事件处理函数。我们可以使用select函数来选择接收可变函数的第几个参数:
local status = select(6, ...) --获取第6个参数,在CHAT_MSG_WHISPER中它代表发送者的状态
(3)更好的写法
利用table可以更简洁的编程:
local eventHandlers = {} --table,存储所有事件处理函数 function eventHandlers.CHAT_MSG_WHISPER(msg, sender) --whisper处理函数并存入table --... end function eventHandlers.ZONE_CHANGED_NEW_AREA() --地域改变处理函数并存入table --... end local function mEventHandler(self, event, ...) --事件处理器 return eventHandlers[event](...) --直接从table提取对应函数 end
如上,将事件作为key,函数作为value,可以很方便地通过事件获取对应的处理函数,省去了繁琐的if-else结构
三、计时器
(1)OnUpdate
OnUpdate是另一个非常重要的事件,表示界面刷新。也就是每次刷新界面,都会执行OnUpdate事件处理器,如果游戏帧数是60帧,那就是每秒执行60次。
OnUpdate会返回处理器两个参数:
- self 窗体本身
- elapsed 距离上次OnUpdate处理器被调用时间
利用OnUpdate的特性,我们可以实现在将来某个特定时间点执行某个函数。 其原理就是在OnUpdate回调函数(事件处理器)中不断检测是否已经到了特定时间点,如果到了,就执行指定的函数。
代码如下:
local tasks = {} --定义任务table function SimpleTimingLib_Schedule(time, func, ...) --任务安排 local t = {...} --新建一个table,array部分存储任务参数 t.func = func --即将执行的任务 t.time = GetTime() + time --时间点 table.insert(tasks, t) --将任务存入任务列表 end local function onUpdate() --OnUpdate回调函数 for i = #tasks, 1, -1 do --遍历任务 local val = tasks[i] if val.time <= GetTime() then --如果已到执行时间 table.remove(tasks, i) --移除任务 val.func(unpack(val)) --执行任务,unpack可以提取array部分,这里是任务参数 end end end local frame = CreateFrame("Frame") --新建一个Frame frame:SetScript("OnUpdate", onUpdate) --设置OnUpdate回调函数
如果希望将已经添加到任务列表的任务移除,可以通过对比函数名和参数来识别将要移除的任务,代码如下:
function SimpleTimingLib_Unschedule(func, ...) for i = #tasks, 1, -1, do --遍历 local val = tasks[i] if val.func == func then --函数名相同 local matches = true --匹配暂时成功 for i = 1, select("#", ...) do --遍历参数,select("#", ...)返回可变参数个数 if select(i, ...) ~=val[i] then --参数不相等 matches = false --匹配最终失败 break end end if matches then --如果匹配成功,移除 table.remove(tasks, i) end end end end
(2)性能优化
因为OnUpdate的触发频率和界面刷新频率一致,会频繁调用,所以回调函数的执行效率会严重影响游戏性能。我们有必要认真考虑任务执行时间的精确度。如果有0.5s的时间误差也能达到相同的目的,那就应该适当减少执行次数。
我们有两种方法来降低执行次数:最小时间间隔;最小帧间隔。
最小时间间隔,限制每秒最多执行两次:
local function onUpdate(self, elapsed) --self是OnUpdate返回的frame,elapsed是距离上次OnUpdate的时间差 --update任务代码 end local frame = CreateFrame("Frame") local e = 0 frame:SetScript("OnUpdate", function(self, elapsed) --匿名函数 e = e + elapsed --累计时间间隔 if e >= 0.5 then --如果时间间隔大于0.5s,则执行任务 e = 0 return onUpdate(self, elapsed) end end)
最小帧间隔,限制每5帧执行一次:
local frame = CreateFrame("Frame") local counter = 0 --计数器 frame:SetScript("OnUpdate", function(...) --匿名函数 counter = counter + 1 --计数器加1 if counter % 5 == 0 then --如果间隔帧数达到5帧,则执行任务 return onUpdate(self, elapsed) end end
四、一个DKP插件
接下来我们将利用上面学的知识来写一个DKP插件,协助完成工会活动时的物品竞拍。
先说一下工会DKP规则:
- 团长或DKP管理员发起一件物品竞拍
- 所有团队成员可以通过密语的方式向团长或DKP管理员出分
- 出分最高者以次高者+1的分数赢得竞拍
- 如出分最高者不止一个,Roll点决定
- 如只有一人出分,则以最低分赢得竞拍
- 如无人竞拍,则流拍
根据以上规则,我们的插件需要完成如下功能:
- 启动竞拍——通过命令行,对一件物品发起竞拍,并在Raid频道开始倒计时30s
- 成员出分——在倒计时期间,团队成员可以通过密语你出分
- 竞拍结束——倒计时结束后,在Raid频道列出所有出分,并给出竞拍结果
- 中止竞拍——在竞拍过程中,可以通过命令行随时中止竞拍
- 远程命令——可以为官员添加权限,使其可以通过密语你的方式启动或中止竞拍
该插件需要用到上面的计时器插件SimpleTimingLib,需在toc文件中配置:
##Dependencies: SimpleTimingLib
lua代码如下:
local currentItem --the item auctioned on local bids = {} --bid list local prefix = "[SimpleDKP]" --prefix SimpleDKP_Channel = "RAID" --default channel SimpleDKP_AuctionTime = 30 --auction duration SimpleDKP_MinBid = 15 --minimum dkp SimpleDKP_ACL = {} --remote control list local startAuction, endAuction, placeBid, cancelAuction, onEvent --declare function keywords at first ----------------------------------------------------------------------start auction------------------------------------------------------------------------------- do local auctionAlreadyRunning = "There is already an auction running! (on %s)" local startingAuction = prefix.."Starting auction for item %s, please place your bids by whispering me. Remaining time: %d seconds." local auctionProgress = prefix.."Time remaining for %s: %d seconds." function startAuction(item, starter) if currentItem then --one auction already running local msg = auctionAlreadyRunning:format(currentItem) if starter then SendChatMessage(msg, "WHISPER", nil, starter) else print(msg) end else currentItem = item SendChatMessage(startingAuction:format(item, SimpleDKP_AuctionTime), SimpleDKP_Channel) --start auction ,time remain 30s if SimpleDKP_AuctionTime > 30 then SimpleTimeLib_Schedule(SimpleDKP_AuctionTime - 30, SendChatMessage, auctionProgress:format(item, 30), SimpleDKP_Channel) end if SimpleDKP_AuctionTime > 15 then --schedule message (remain time 15) SimpleTimingLib_Schedule(SimpleDKP_AuctionTime - 15, SendChatMessage, auctionProgress:format(item, 15), SimpleDKP_Channel) end if SimpleDKP_AuctionTime > 5 then --schedule message (remain time 5) SimpleTimingLib_Schedule(SimpleDKP_AuctionTime - 5, SendChatMessage, auctionProgress:format(item, 5), SimpleDKP_Channel) end --schedule function endAuction SimpleTimingLib_Schedule(SimpleDKP_AuctionTime, endAuction) end end end ----------------------------------------------------------------------end auction------------------------------------------------------------------------------- do local noBids = prefix.."No one wants to have %s :(" local wonItemFor = prefix.."%s won %s for %d DKP." local pleaseRoll = prefix.."%s bid %d DKP on %s, please roll!" local highestBidders = prefix.."%d. %s bid %d DKP" local function sortBids(v1, v2) return v1.bid > v2.bid end function endAuction() table.sort(bids, sortBids) if #bids == 0 then --case 1:no bid at all SendChatMessage(noBids:format(currentItem), SimpleDKP_Channel) elseif #bids == 1 then --case 2:one bid; the bidder pays the minimum bid SendChatMessage(wonItemFor:format(bids[1].name, currentItem, SimpleDKP_MinBid), SimpleDKP_Channel) SendChatMessage(highestBidders:format(1, bids[1].name, bids[1].bid), SimpleDKP_Channel) elseif bids[1].bid ~= bids[2].bid then --case 3:highest amount is unique SendChatMessage(wonItemFor:format(bids[1].name, currentItem, bids[2].bid + 1), SimpleDKP_Channel) for i = 1, math.min(#bids, 3) do --print the three highest bidders SendChatMessage(highestBidders:format(i, bids[i].name, bids[i].bid), SimpleDKP_Channel) end else -- case4: more then 1 bid and the highest amount is not unique local str = "" --this string holds all players who bid the same amount for i = 1, #bids do --this loop builds the string if bids[i].bid ~= bids[1].bid then --found a player who bid less -->break break else --append the player's name to the string if bids[i + 2] and bids[i + 2].bid == bid then str = str..bids[i].name..", " --use a comma if this is not the last else str = str..bids[i].name.." and " --this is the last player end end end str = str:sub(0, -6) --cut off the last " and " SendChatMessage(pleaseRoll:format(str, bids[1].bid, currentItem), SimpleDKP_Channel) end currentItem = nil --set currentItem to nil as there is no longer an ongoing auction table.wipe(bids) --clear the table that holds the bids end end ----------------------------------------------------------------------place bids------------------------------------------------------------------------------- do local oldBidDetected = prefix.."Your old bid was %d DKP, your new bid is %d DKP." local bidPlaced = prefix.."Your bid of %d DKP has been placed!" local lowBid = prefix.."The minimum bid is %d DKP." function placeBid(msg, sender) if currentItem and tonumber(msg) then local bid = tonumber(msg) if bid < SimpleDKP_MinBid then --invalid bid SendChatMessage(lowBid:format(SimpleDKP_MinBid), "WHISPER", nil, sender) return end for i, v in ipairs(bids) do --check if that player has already bid if sender == v.name then SendChatMessage(oldBidDetected:format(v.bid, bid), "WHISPER", nil, sender) v.bid = bid return end end --he hasn't bid yet, so create a new entry in bids table.instert(bids, {bid = bid, name = sender}) SendChatMessage(bidPlaced:format(bid), "WHISPER", nil, sender) end end end ----------------------------------------------------------------------cancel auction------------------------------------------------------------------------------- do local cancelled = "Auction cancelled by %s" function cancelAuction(sender) currentItem = nil table.wipe(bids) SimpleTimingLib_Unschedule(SendChatMessage) --Attention: this will unschedule all SendChatMessage includding scheduled by other addons! SimpleTimingLib_Unschedule(endAuction) SendChatMessage(cancelled:format(sender or UnitName("player")), SimpleDKP_Channel) --UnitName("player") returns your character name. end end ----------------------------------------------------------------------remote control------------------------------------------------------------------------------- do local addedToACL = "Added %s player(s) to the ACL" local removedFromACL = "Removed %s player(s) from the ACL" local function addToACL(...) --add multiple players to the ACL for i = 1, select("#", ...) do --iterate over the arguments SimpleDKP_ACL[select(i, ...)] = true --and add all players end print(addedToACL:format(select("#", ...))) --print an info message end local function removeFromACL(...) --remove player(s) from the ACL for i = 1, select("#", ...) do --iterate over the vararg SimpleDKP_ACL[select(i, ...)] = nil --remove the players from the ACL end print(removedFromACL:format(select("#", ...))) --print an info message end end ----------------------------------------------------------------------onEvent------------------------------------------------------------------------------- do --register events local frame = CreateFrame("Frame") frame:RegisterEvent("CHAT_MSG_WHISPER") frame:RegisterEvent("CHAT_MSG_RAID") frame:RegisterEvent("CHAT_MSG_RAID_LEADER") frame:RegisterEvent("CHAT_MSG_GUILD") frame:RegisterEvent("CHAT_MSG_OFFICER") frame:SetScript("OnEvent", onEvent) --event handler function onEvent(self, event, msg, sender) if event == "CHAT_MSG_WHISPER" and currentItem and tonumber(msg) then placeBid(msg, sender) elseif SimpleDKP_ACL(sender) then --not a whisper or a whisper that is not a bid and the sender has the permission to send commands local cmd, arg = msg:match("^!(%w+)%s*(.*)") -- "!auction xxx" start auction; "!cancel" cancel auction if cmd and cmd:lower() == "auction" and arg then startAuction(arg, sender) elseif cmd and cmd:lower() == "cancel" then cancelAuction(sender) end end end end ----------------------------------------------------------------------slash commands------------------------------------------------------------------------------- --/sdkp start <item> starts an auction for <item> --/sdkp stop stop the current auction --/sdkp channel <channel> set the chat channel to <channel> --/sdkp time <time> set the time to <time> --/sdkp minbid <minbid> set the lowest bid to <minbid> --/sdkp acl print the list of players who are allowed to control the addon remotely --/sdkp acl add <names> add <names> to the ACL list --/sdkp acl remove <names> remove <names> from the ACL list SLASH_SimpleDKP1 = "/simpledkp" SLASH_SimpleDKP2 = "/sdkp" do local setChannel = "Channel is now "%s"" local setTime = "Time is now %s" local setMinBid = "Lowest bid is now %s" local currChannel = "Channel is currently set to "%s"" local currTime = "Time is currently set to %s" local currMinBid = "Lowest bid is currently set to %s" local ACL = "Access Control List:" SlashCmdList["SimpleDKP"] = function(msg) local cmd, arg = string.split(" ", msg) --split the string with " " cmd = cmd:lower() --the command should not be case-sensitive if cmd == "start" and arg then --/sdkp start item startAuction(msg:match("^start%s+(.+)")) --extract the time link elseif cmd == "stop" then --/sdkp stop cancelAuction() elseif cmd == "channel" then --/sdkp channel arg if arg then --a new channel was provided SimpleDKP_Channel = arg:upper() --set is to arg print(setChannel:format(SimpleDKP_Channel)) else --no channel was provided print(currChannel:format(SimpleDKP_Channel)) --print the current one end elseif cmd == "time" then --/sdkp time arg if arg and tonumber(arg) then --arg is provided and it is a number SimpleDKP_AuctionTime = tonumber(arg) --set it print(setTime:format(SimpleDKP_AuctionTime)) else --arg was not provided or it wasn't a number print(currTime:format(SimpleDKP_AuctionTime)) --print error message end elseif cmd == "minbid" then --/sdkp minbid arg if arg and toumber(arg) then --arg is set and a number SimpleDKP_MinBid = tonumber(arg) --set the option print(setMinBid:format(SimpleDKP_MinBid)) else --arg is not set or not a number print(currMinBid:format(SimpleDKP_MinBid)) --print error message end elseif cmd == "acl" then --/sdkp acl add/remove players 1, player2, ... if not arg then --add/remove not passed print(ACL) --output header for k, v in pairs(SimpleDKP_ACL) do --loop over the ACL print(k) --print all entries end elseif arg:lower() == "add" then --/sdkp add player1, player2, ... --split the string and pass players to our helper function addToACL(select(3, string.split(" ", msg))) elseif arg:lower() == "remove" then --/sdkp remove player1, player2, ... removeFromACL(select(3, string.split(" ", msg))) --split & reomve end end end end
代码注解和结构已经非常清晰,其中:
- startAuction——启动竞拍
- endAuction——竞拍结束
- placeBid——出分
- cancelAuction——中止竞拍
- addACL——添加远程控制人员
- removeACL——移除远程控制人员
- onEvent——事件处理器
最后,添加了一些命令行,可用于配置插件:
- /sdkp start <item> 启动竞拍
- /sdkp stop 停止竞拍
- /sdkp channel <channel> 设置输出频道,默认RAID频道
- /sdkp time 设置竞拍时间,默认30s
- /sdkp minbid 设置最小竞拍分数,默认15
- /sdkp acl 打印远程控制人员列表
- /sdkp acl add <names> 添加远程控制人员
- /sdkp acl remove <names> 移除远程控制人员
远程控制人员可以在团队、工会或官员等频道输入如下命令启动和停止竞拍:
- "!auction <item>" 启动一个竞拍
- "!stop" 停止当前竞拍
至此,一个完整的DKP插件就开发完成了!