Repository navigation
Expand file tree
/
Copy pathPingGame.lua
More file actions
463 lines (400 loc) · 14.4 KB
/
Copy pathPingGame.lua
File metadata and controls
463 lines (400 loc) · 14.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
local ADDON_NAME, ns = ...
local PingGame = {}
ns.PingGame = PingGame
local Utils = ns.Utils
local GetTime = Utils.GetTime
local tinsert = Utils.tinsert
local tremove = Utils.tremove
local wipe = Utils.wipe
local mfloor = Utils.mfloor
local mmin = Utils.mmin
local mmax = Utils.mmax
local ipairs = Utils.ipairs
local CreateFrame = Utils.CreateFrame
local GetBindingKey = GetBindingKey
local isRunning = false
local isPaused = false
local gameFrame
local activeTargets = {}
local score = 0
local combo = 0
local maxCombo = 0
local hits = 0
local wrongType = 0 -- correct target, wrong ping type
local expired = 0
local strayPings = 0 -- pings into empty space
local gameStartTime = 0
local gameDuration = 30
local gameElapsed = 0
local pauseStartTime = 0
local spawnAccumulator = 0
local targetIDCounter = 0
local difficulty = "Normal"
local health = 100
local MAX_HEALTH = 100
-- Ping action -> binding name mapping
local PING_BINDINGS = {
attack = "PINGATTACK",
assist = "PINGASSIST",
warning = "PINGWARNING",
onmyway = "PINGONMYWAY",
}
-- Target types
local TARGET_ENEMY = 1 -- requires attack ping
local TARGET_ALLY = 2 -- requires assist ping
local TARGET_HAZARD = 3 -- requires warning ping
local TARGET_TYPES = { TARGET_ENEMY, TARGET_ALLY, TARGET_HAZARD }
PingGame.TARGET_ENEMY = TARGET_ENEMY
PingGame.TARGET_ALLY = TARGET_ALLY
PingGame.TARGET_HAZARD = TARGET_HAZARD
-- Which ping action is correct for each target type
local CORRECT_PING = {
[TARGET_ENEMY] = "attack",
[TARGET_ALLY] = "assist",
[TARGET_HAZARD] = "warning",
}
PingGame.CORRECT_PING = CORRECT_PING
-- Friendly names for the target-relevant pings, used by the "bind all pings"
-- prompt. onmyway is intentionally excluded: it is not a target ping.
local PING_LABEL = { attack = "Attack", assist = "Assist", warning = "Warning" }
-- Difficulty follows how aim trainers ramp: the entry tier is one large target at
-- a time that waits long and is replaced the moment it is hit; each tier above adds
-- one target on screen and shortens the timeout and the size. A ping asks more than
-- a click (find the target, read its type, press the right modifier and key), so
-- the timeouts sit well above a click trainer's.
-- Health drains all the time (drain per second, as rhythm games do) and hits refill
-- it (heal, plus comboHeal per combo step up to 5); damage scales every loss. Tuned
-- in a simulation of these rules with model players: a player who does nothing
-- fails at about 23 s on Easy, 17 s on Normal, 15 s on Hard and 12 s on Extreme; a
-- beginner always survives Easy and about 7 in 10 Normal rounds, an average player
-- about 2 in 3 Hard rounds, a good player about 4 in 10 Extreme rounds.
local DIFFICULTY_SETTINGS = {
Easy = { spawnInterval = 0.6, targetLifetime = 6.0, maxActive = 1, duration = 30, sizeScale = 1.3,
drain = 3.0, damage = 1.0, heal = 8, comboHeal = 1 },
Normal = { spawnInterval = 1.2, targetLifetime = 5.0, maxActive = 2, duration = 45, sizeScale = 1.1,
drain = 2.5, damage = 1.0, heal = 6, comboHeal = 1 },
Hard = { spawnInterval = 1.0, targetLifetime = 4.5, maxActive = 3, duration = 60, sizeScale = 0.95,
drain = 3.0, damage = 0.8, heal = 5, comboHeal = 1 },
Extreme = { spawnInterval = 0.75, targetLifetime = 3.6, maxActive = 4, duration = 60, sizeScale = 0.82,
drain = 3.5, damage = 0.7, heal = 4, comboHeal = 1 },
}
PingGame.DIFFICULTIES = { "Easy", "Normal", "Hard", "Extreme" }
-- Scoring
local SCORE_HIT = 100
local SCORE_SPEED_BONUS = 200
local SCORE_WRONG_TYPE = -50 -- right target, wrong ping type
local SCORE_EXPIRED = -50
local SCORE_STRAY = -25 -- ping into empty space
-- Grade from accuracy (hits out of every target and every ping), the way rhythm
-- games grade; a round that ran out of health is an F. Score, which also rewards
-- speed, is kept per difficulty as the record.
local GRADES = { { 95, "S" }, { 90, "A" }, { 80, "B" }, { 70, "C" } }
local function GradeFor(accuracy, failed)
if failed then return "F" end
for _, g in ipairs(GRADES) do
if accuracy >= g[1] then return g[2] end
end
return "D"
end
-- Health loss per event, scaled by the difficulty's damage (heal and drain are per
-- difficulty, in DIFFICULTY_SETTINGS)
local HEALTH_WRONG_TYPE = -12 -- wrong ping type
local HEALTH_MISS = -6 -- ping into empty space
local HEALTH_EXPIRED = -10 -- target timed out
local function GetSettings()
return DIFFICULTY_SETTINGS[difficulty]
end
-- A health loss (amount < 0), scaled by the difficulty's damage.
local function Hurt(amount)
local s = GetSettings()
health = mmax(0, health + amount * (s and s.damage or 1))
end
-- Build keybind -> ping action reverse lookup
local pingKeybinds = {} -- key string -> ping action name
local function RefreshPingKeybinds()
wipe(pingKeybinds)
for action, binding in pairs(PING_BINDINGS) do
local key1, key2 = GetBindingKey(binding)
if key1 then pingKeybinds[key1] = action end
if key2 then pingKeybinds[key2] = action end
end
end
function PingGame:GetPingKeybinds()
return pingKeybinds
end
function PingGame:HasPingKeybinds()
return next(pingKeybinds) ~= nil
end
-- True only when every target-relevant ping (attack/assist/warning) is bound.
function PingGame:AreAllPingsKeybound()
for i = 1, #TARGET_TYPES do
if not GetBindingKey(PING_BINDINGS[CORRECT_PING[TARGET_TYPES[i]]]) then
return false
end
end
return true
end
-- Display names of any target-relevant pings that are not yet bound.
function PingGame:GetUnboundPingNames()
local names = {}
for i = 1, #TARGET_TYPES do
local action = CORRECT_PING[TARGET_TYPES[i]]
if not GetBindingKey(PING_BINDINGS[action]) then
names[#names + 1] = PING_LABEL[action]
end
end
return names
end
function PingGame:Initialize()
gameFrame = CreateFrame("Frame", "BoundToLearnPingGameFrame", UIParent)
gameFrame:Hide()
gameFrame:SetScript("OnUpdate", function(_, elapsed)
self:OnUpdate(elapsed)
end)
ns.eventFrame:HookScript("OnEvent", function(_, event)
if event == "PLAYER_REGEN_DISABLED" and isRunning then
self:Pause()
end
end)
end
function PingGame:IsRunning()
return isRunning
end
function PingGame:GetDifficulty()
return difficulty
end
function PingGame:SetDifficulty(diff)
if DIFFICULTY_SETTINGS[diff] then
difficulty = diff
end
end
function PingGame:Start()
if isRunning then return end
if InCombatLockdown() then
BoundToLearn:Print("Cannot start during combat.")
return
end
RefreshPingKeybinds()
if not self:HasPingKeybinds() then
BoundToLearn:Print("No ping keybinds found. Bind Attack Ping and Warning Ping in your Key Bindings first.")
return
end
local settings = GetSettings()
gameDuration = settings.duration
score = 0
combo = 0
maxCombo = 0
hits = 0
wrongType = 0
expired = 0
strayPings = 0
health = MAX_HEALTH
spawnAccumulator = 999 -- immediate first spawn
targetIDCounter = 0
gameElapsed = 0
pauseStartTime = 0
wipe(activeTargets)
isRunning = true
isPaused = false
gameStartTime = GetTime()
if ns.PingGameUI then
ns.PingGameUI:OnGameStart()
end
gameFrame:Show()
end
function PingGame:Stop()
if not isRunning then return end
isRunning = false
isPaused = false
gameFrame:Hide()
if ns.PingGameUI then
for i = #activeTargets, 1, -1 do
ns.PingGameUI:ReleaseTarget(activeTargets[i])
end
end
wipe(activeTargets)
-- Every target and every ping counts: a stray ping is a miss, as in aim trainers.
local attempts = hits + wrongType + expired + strayPings
local accuracy = attempts > 0 and mfloor(hits / attempts * 100) or 0
local failed = health <= 0
local results = {
score = mmax(0, score),
hits = hits,
wrongType = wrongType,
expired = expired,
strayPings = strayPings,
maxCombo = maxCombo,
accuracy = accuracy,
grade = GradeFor(accuracy, failed),
elapsed = gameElapsed,
difficulty = difficulty,
healthDepleted = failed,
}
-- Records per difficulty: an Easy score never stands against an Extreme one.
local pt = BoundToLearn.db and BoundToLearn.db.pingTrainer
if pt and attempts > 0 then
pt.best = pt.best or {}
local prev = pt.best[difficulty]
results.prevBest = prev
if not prev or results.score > prev.score then
pt.best[difficulty] = { score = results.score, accuracy = accuracy, grade = results.grade,
combo = maxCombo, time = time() }
results.newBest = true
end
end
if ns.PingGameUI then
ns.PingGameUI:OnGameEnd(results)
end
if ns.Stats then
ns.Stats:AddSession({
timestamp = time(),
type = "ping",
score = results.score,
accuracy = accuracy,
grade = results.grade,
combo = maxCombo,
difficulty = difficulty,
elapsed = mfloor(gameElapsed * 10) / 10,
})
end
end
function PingGame:Pause()
if not isRunning or isPaused then return end
isPaused = true
pauseStartTime = GetTime()
if ns.PingGameUI then ns.PingGameUI:ShowPause() end
end
function PingGame:Resume()
if not isPaused then return end
if InCombatLockdown() then
BoundToLearn:Print("Resume once you are out of combat.")
return
end
isPaused = false
local pauseDuration = GetTime() - pauseStartTime
gameStartTime = gameStartTime + pauseDuration
if ns.PingGameUI then ns.PingGameUI:HidePause() end
end
function PingGame:OnUpdate(elapsed)
if not isRunning or isPaused then return end
gameElapsed = GetTime() - gameStartTime
if gameElapsed >= gameDuration then
self:Stop()
return
end
local settings = GetSettings()
local now = GetTime()
-- Health drains all the time; hits refill it.
health = mmax(0, health - settings.drain * elapsed)
-- Expire old targets
for i = #activeTargets, 1, -1 do
local target = activeTargets[i]
if now >= target.expireTime then
expired = expired + 1
score = score + SCORE_EXPIRED
combo = 0
Hurt(HEALTH_EXPIRED)
if ns.PingGameUI then
ns.PingGameUI:OnTargetExpired(target)
end
if ns.PingGameUI then
ns.PingGameUI:ReleaseTarget(target)
end
tremove(activeTargets, i)
end
end
-- Spawn new targets
spawnAccumulator = spawnAccumulator + elapsed
-- Time spent full does not bank up: a freed slot refills once, not in a burst.
if #activeTargets >= settings.maxActive then
spawnAccumulator = mmin(spawnAccumulator, settings.spawnInterval)
end
if spawnAccumulator >= settings.spawnInterval and #activeTargets < settings.maxActive then
spawnAccumulator = spawnAccumulator - settings.spawnInterval
self:SpawnTarget(settings)
end
-- Check for health depletion
if health <= 0 then
self:Stop()
return
end
if ns.PingGameUI then
ns.PingGameUI:OnUpdate(gameElapsed, gameDuration, score, combo, health / MAX_HEALTH)
end
end
function PingGame:SpawnTarget(settings)
local targetType = TARGET_TYPES[math.random(#TARGET_TYPES)]
targetIDCounter = targetIDCounter + 1
local target = {
id = targetIDCounter,
targetType = targetType,
spawnTime = GetTime(),
expireTime = GetTime() + settings.targetLifetime,
lifetime = settings.targetLifetime,
sizeScale = settings.sizeScale or 1,
}
tinsert(activeTargets, target)
if ns.PingGameUI then
ns.PingGameUI:OnTargetSpawned(target)
end
end
-- Called when player presses a ping key while hovering a target
function PingGame:OnPingAction(targetID, pingAction)
if not isRunning or isPaused then return end
for i, target in ipairs(activeTargets) do
if target.id == targetID then
local now = GetTime()
local reactionTime = now - target.spawnTime
local correctAction = CORRECT_PING[target.targetType]
local wasHit = pingAction == correctAction
if wasHit then
-- Correct ping type
hits = hits + 1
local speedBonus = mfloor(SCORE_SPEED_BONUS * mmax(0, 1 - reactionTime / target.lifetime))
local points = SCORE_HIT + speedBonus
score = score + points
combo = combo + 1
if combo > maxCombo then maxCombo = combo end
local s = GetSettings()
health = mmin(MAX_HEALTH, health + s.heal + mmin(combo, 5) * s.comboHeal)
if ns.PingGameUI then
ns.PingGameUI:OnTargetResult(target, "hit", reactionTime, points, pingAction)
end
else
-- Wrong ping type
wrongType = wrongType + 1
score = score + SCORE_WRONG_TYPE
combo = 0
Hurt(HEALTH_WRONG_TYPE)
if ns.PingGameUI then
ns.PingGameUI:OnTargetResult(target, "wrong_type", reactionTime, nil, pingAction)
end
end
if ns.PingGameUI then
ns.PingGameUI:ReleaseTarget(target)
end
tremove(activeTargets, i)
-- Instant respawn on a correct ping keeps a steady number of targets on
-- screen (aim-trainer style) instead of waiting for the spawn timer.
local respawnSettings = GetSettings()
if wasHit and respawnSettings and #activeTargets < respawnSettings.maxActive then
self:SpawnTarget(respawnSettings)
end
return
end
end
end
-- Called when player presses a ping key but isn't hovering any target
function PingGame:OnPingMiss(pingAction)
if not isRunning or isPaused then return end
combo = 0
strayPings = strayPings + 1
score = score + SCORE_STRAY
Hurt(HEALTH_MISS)
if ns.PingGameUI then
ns.PingGameUI:OnMissClick(pingAction)
end
end
function PingGame:GetTimeRemaining()
return mmax(0, gameDuration - gameElapsed)
end