HordeHud.lua
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
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
-- HORDE MODE: the readout. -- -- Health, ammunition, score, wave, the crosshair, the hit marker, the red -- that closes in when something reaches you, and the banners -- "A -- DARKNESS APPROACHES", then "WAVE 1" and every wave after it. -- -- IT IS DRAWN TWICE, INTO TWO DIFFERENT PLACES, and that is not -- duplication for its own sake. The flat screen's HUD goes into the SCENE -- canvas through Voxel3D.beginOverlay -- the same seam the overworld's FX -- bubbles use -- because that canvas is what the window composites. A -- headset never sees that canvas: with VR live the window's world pass -- short-circuits to the mirror, and the eyes are rendered on their own in -- lib/VR. So the eye canvases get their own pass, at the same instant the -- VR frame paints its fade over them, in the same 2D idiom. -- -- Both call the same draw with a different scale and a different safe -- area: a headset wants everything well inside the lens rather than -- pinned to the corners, because the corners of a VR frame are off the -- edge of the visible world. -- -- EVERY WORD IS ON A WHITE PLATE, and that is not a style choice -- it is -- what the font is. The Game Boy font sheets are BLACK glyphs on -- transparent, so setColor cannot make a letter pale: multiplying black -- by white is still black. That is why every box in the game is drawn -- white first and its text black on top (Font.drawBox, then -- setColor(0,0,0)), and it is why a first cut of this HUD -- pale text, -- straight onto the night -- composited as black letters on a black -- street and could not be read at all. Plates also happen to be the right -- answer aesthetically: the game already talks to the player in white -- boxes, and a horde mode that shouts in the same voice belongs to it. -- the mod namespace (see main.lua): V.require loads a sibling module local V = ... local Horde = V.require("Horde") local HordeHud = {} local Font = nil local function font() if Font then return Font end local ok, F = pcall(require, "src.render.Font") if ok then Font = F end return Font end -- the pulse under the low-health plate and the banner's own breathing local blink = 0 function HordeHud.update(dt) blink = (blink + (dt or 0)) % 1.0 end -- named for the suite: the banner's line breaking, which is the part with -- an answer worth pinning HordeHud._layout = nil -- assigned below, once `layout` exists -- ------- pieces -- -- Every helper takes a scale `s` and draws in GB pixels multiplied by it, -- so one layout serves a 4x window and a headset's eye buffer alike. local PAD = 3 -- plate padding, in GB pixels -- A white plate with a dark edge: the surface a black glyph can be read -- on. Returns the interior origin, so a caller lays text out from there. local function plate(x, y, w, h, s, alpha) love.graphics.setColor(0.06, 0.05, 0.09, (alpha or 1) * 0.92) love.graphics.rectangle("fill", x - s, y - s, w + 2 * s, h + 2 * s) love.graphics.setColor(0.93, 0.94, 0.90, alpha or 1) love.graphics.rectangle("fill", x, y, w, h) return x + PAD * s, y + PAD * s end local function textWidth(str, s) local F = font() if not F then return 0 end return F.width(str) * s end -- Black glyphs at `s` times their size. Black because that is the only -- colour the font has (see the header). local function text(str, x, y, s) local F = font() if not F then return 0 end love.graphics.setColor(0, 0, 0, 1) love.graphics.push() love.graphics.translate(math.floor(x), math.floor(y)) love.graphics.scale(s, s) F.draw(str, 0, 0) love.graphics.pop() end -- One line of text on its own plate, anchored left or right. local function label(str, x, y, s, align) local tw = textWidth(str, s) local pw, ph = tw + PAD * 2 * s, 8 * s + PAD * 2 * s local px = (align == "right") and (x - pw) or x local ix, iy = plate(px, y, pw, ph, s) text(str, ix, iy, s) return pw, ph end -- The health bar: a plate with a red bar inside it, so the red reads -- against white rather than against a night street. local function healthBar(x, y, w, h, s, fill, flash) local ix, iy = plate(x, y, w, h, s) local iw, ih = w - PAD * 2 * s, h - PAD * 2 * s love.graphics.setColor(0.80, 0.80, 0.78, 1) love.graphics.rectangle("fill", ix, iy, iw, ih) local r, g, b = 0.78, 0.12, 0.16 if flash then r, g, b = 1, 0.45, 0.35 end love.graphics.setColor(r, g, b, 1) love.graphics.rectangle("fill", ix, iy, math.max(0, iw * fill), ih) end -- The crosshair: four ticks around a gap that opens as the gun kicks, and -- gone entirely down the sights, where the iron sights ARE the crosshair. -- Drawn as a dark pair under a light pair so it survives both a white -- wall and a black doorway. local function crosshair(cx, cy, s, spread, alpha) local gap = (3 + spread * 4) * s local len = 4 * s local t = math.max(1, s) local function ticks(o, thick, r, g, b, a) love.graphics.setColor(r, g, b, a) love.graphics.rectangle("fill", cx - gap - len - o, cy - thick / 2 - o, len + 2 * o, thick + 2 * o) love.graphics.rectangle("fill", cx + gap - o, cy - thick / 2 - o, len + 2 * o, thick + 2 * o) love.graphics.rectangle("fill", cx - thick / 2 - o, cy - gap - len - o, thick + 2 * o, len + 2 * o) love.graphics.rectangle("fill", cx - thick / 2 - o, cy + gap - o, thick + 2 * o, len + 2 * o) end ticks(math.max(1, s * 0.5), t, 0, 0, 0, alpha * 0.85) ticks(0, t, 0.98, 0.98, 1, alpha) end local function hitMarker(cx, cy, s, amount) if amount <= 0 then return end love.graphics.setColor(1, 0.30, 0.26, amount) local o = 5 * s local len = 5 * s local t = math.max(1, s) for _, d in ipairs({ { -1, -1 }, { 1, -1 }, { -1, 1 }, { 1, 1 } }) do love.graphics.push() love.graphics.translate(cx + d[1] * o, cy + d[2] * o) love.graphics.rotate(math.pi / 4 * (d[1] * d[2] > 0 and 1 or -1)) love.graphics.rectangle("fill", -t / 2, -len / 2, t, len) love.graphics.pop() end end -- How wide a run of glyphs comes out at `bs` pixels per font pixel, with -- `track` of air after each one. local function runWidth(F, codes, bs, track) local total = 0 for _, code in ipairs(codes) do total = total + F.advanceOf(code) * bs + track end return total - track end -- The banner's lines, and the size to draw them at. -- -- THE SCALE IS NEGOTIATED, not assumed. The caller's scale comes from the -- window's own zoom, so a player zoomed well in gets a large `s` -- and -- "A DARKNESS APPROACHES" at twice a large scale is wider than the -- screen, which is how the words ran off both edges. So: shrink until the -- longest single WORD fits, then wrap the words into as many lines as -- that leaves. Wrapping first and shrinking only when a word alone cannot -- fit keeps the announcement as big as the frame can carry it. local function layout(F, str, scale, maxW) local words = {} for word in tostring(str):gmatch("%S+") do words[#words + 1] = word end if #words == 0 then return nil end local bs = math.max(1, scale * 2) local function track(size) return math.max(1, math.floor(size / 2)) end while bs > 1 do local widest = 0 for _, word in ipairs(words) do local ww = runWidth(F, F.encode(word), bs, track(bs)) if ww > widest then widest = ww end end if widest <= maxW then break end bs = bs - 1 end local tr = track(bs) local spaceW = runWidth(F, F.encode(" "), bs, tr) + tr local lines, line, lineW = {}, nil, 0 for _, word in ipairs(words) do local ww = runWidth(F, F.encode(word), bs, tr) if not line then line, lineW = word, ww elseif lineW + spaceW + ww <= maxW then line, lineW = line .. " " .. word, lineW + spaceW + ww else lines[#lines + 1] = { text = line, width = lineW } line, lineW = word, ww end end lines[#lines + 1] = { text = line, width = lineW } return lines, bs, tr end HordeHud._layout = layout -- The banner: a plate across the middle of the frame with the words on -- it, as big as the frame can carry. It fades in and out rather than -- cutting -- an announcement, not a notification -- and the plate fades -- with it. local function banner(w, h, scale) local sess = Horde.session if not (sess and sess.bannerText) then return end local t, hold = sess.bannerT, sess.bannerHold local alpha if t < 0.4 then alpha = t / 0.4 elseif t < hold then alpha = 1 else alpha = math.max(0, 1 - (t - hold) / 1.1) end if alpha <= 0 then return end local F = font() if not F then return end local margin = 6 * scale local lines, bs, tr = layout(F, sess.bannerText, scale, w - margin * 2) if not lines then return end local lineH = 8 * bs local gap = math.max(1, math.floor(bs * 0.4)) local pad = PAD * 2 * scale local ph = #lines * lineH + (#lines - 1) * gap + pad * 2 local y = math.floor(h * 0.30 - ph / 2) -- the plate runs the full width: a band across the world, which reads -- as the game interrupting itself rather than as a label on it plate(0, y, w, ph, scale, alpha) local iy = y + pad love.graphics.setColor(0, 0, 0, alpha) for i, line in ipairs(lines) do local pen = math.floor((w - line.width) / 2) local ly = iy + (i - 1) * (lineH + gap) for _, code in ipairs(F.encode(line.text)) do love.graphics.push() love.graphics.translate(pen, ly) love.graphics.scale(bs, bs) F.drawCode(code, 0, 0) love.graphics.pop() pen = pen + F.advanceOf(code) * bs + tr end end end -- ------- the whole thing -- -- `inset` is how far off the edges the corners sit, which is the one real -- difference between a window and a headset. local function draw(w, h, s, inset) local sess = Horde.session if not sess then return end local Gun = V.require("HordeGun") local ammo, mag, reloading = Gun.ammo() local ads = Gun.adsBlend() love.graphics.push("all") love.graphics.setBlendMode("alpha") -- The red. A VIGNETTE rather than a wash over everything: a full-screen -- fill strong enough to register at a glance also hides the thing that -- just hit you, which in a mode about being surrounded is the one thing -- it must not do. Bands closing in from the edges instead, so the -- middle of the frame stays readable and the alarm arrives in the -- corner of the eye. local hurt = sess.damageFlash local low = 1 - math.min(1, sess.hp / (sess.maxHp * 0.35)) local wash = math.max(hurt * 0.9, low * 0.6 * (0.7 + 0.3 * math.sin(blink * math.pi * 2))) if wash > 0 then local band = math.min(w, h) * 0.38 local steps = 8 for i = 1, steps do local t = i / steps local d = band * t love.graphics.setColor(0.60, 0.02, 0.06, wash * 0.13) love.graphics.rectangle("fill", 0, 0, w, d) love.graphics.rectangle("fill", 0, h - d, w, d) love.graphics.rectangle("fill", 0, 0, d, h) love.graphics.rectangle("fill", w - d, 0, d, h) end end -- health, top left local barW, barH = 60 * s, 8 * s + PAD * 2 * s healthBar(inset, inset, barW, barH, s, sess.hp / sess.maxHp, hurt > 0.3) label(("%d"):format(math.ceil(sess.hp)), inset, inset + barH + 3 * s, s) -- score and wave, top right label(("SCORE %d"):format(math.floor(sess.score)), w - inset, inset, s, "right") label(("WAVE %d"):format(math.max(1, sess.wave)), w - inset, inset + (8 * s + PAD * 2 * s) + 3 * s, s, "right") -- ammunition, bottom right: the rounds as pips over the count, which -- reads at a glance in a firefight where a number does not local ammoStr = reloading and "RELOADING" or ("%d / %d"):format(ammo, mag) local _, ah = label(ammoStr, w - inset, h - inset - (8 * s + PAD * 2 * s), s, "right") local pipW, pipH, pipGap = 3 * s, 7 * s, 2 * s local pipsW = mag * (pipW + pipGap) - pipGap local px = w - inset - pipsW local py = h - inset - ah - pipH - 5 * s love.graphics.setColor(0.06, 0.05, 0.09, 0.85) love.graphics.rectangle("fill", px - 2 * s, py - 2 * s, pipsW + 4 * s, pipH + 4 * s) for i = 1, mag do if i <= ammo and not reloading then love.graphics.setColor(0.98, 0.86, 0.36, 1) else love.graphics.setColor(0.32, 0.30, 0.36, 1) end love.graphics.rectangle("fill", px + (i - 1) * (pipW + pipGap), py, pipW, pipH) end if reloading then local t = math.min(1, Gun.state.reloadT / Gun.RELOAD_TIME) love.graphics.setColor(0.55, 0.78, 0.98, 1) love.graphics.rectangle("fill", px, py + pipH + 1 * s, pipsW * t, 2 * s) end -- the sight picture local cx, cy = w / 2, h / 2 if ads < 0.6 then crosshair(cx, cy, s, Gun.state.kick, (1 - ads / 0.6) * 0.9) end hitMarker(cx, cy, s, sess.hitMarker) banner(w, h, s) love.graphics.pop() love.graphics.setColor(1, 1, 1, 1) end -- ------- the two callers -- The flat window. Called from the voxel pipeline's overlay block, into -- the scene canvas -- which is at the window's PIXEL size and may be -- supersampled on top of that, so the caller's scale carries both. -- -- The caller's scale is CAPPED against the canvas rather than taken as -- given, because that scale is the world's zoom: zoom in far enough and -- the health bar was a metre wide and half of it off the top of the -- screen. A readout is not part of the world and should not zoom with -- it -- so it sizes off the frame it is drawn in, which keeps its -- apparent size the same at every zoom and grows it honestly on a bigger -- display (and with supersampling, which is in both numbers). function HordeHud.drawFlat(w, h, scale) if not Horde.active then return end local cap = math.max(1, math.floor(h / 260)) local s = math.max(1, math.min(math.floor((scale or 1) + 0.5), cap)) draw(w, h, s, 8 * s) end -- ------- and the headset's, which is not a screen overlay at all -- -- A VR eye gets NO 2D overlay. An earlier cut drew this same HUD into -- both eye canvases and it came out torn down the middle: the eye frusta -- are ASYMMETRIC, so the same canvas pixel is a different ANGLE in each -- eye, and the two images never fuse. Nor is there a crosshair to draw -- -- the gun is a real object with real sights and the shot goes down its -- barrel, so a dot painted at the centre of the frame would be pointing -- at something else entirely. -- -- What the headset gets instead is this: the readout as a TEXTURE, which -- lib/VR puts on the POKEDEX in the player's left hand -- already -- tracked, already lit, and already the surface this mod shows -- information on. Geometry in the world, so both eyes see it from their -- own position and the stereo is correct by construction. (It rode the -- gun for one revision and that was worse: a screen on the slide sits -- exactly where the iron sights have to be looked through.) -- -- Sized to the device's own screen, which is the GB frame's 10:9. local panelCanvas = nil local PANEL_W, PANEL_H = 160, 144 function HordeHud.panelTexture() if not Horde.active then return nil end if not (love.graphics and love.graphics.newCanvas) then return nil end local sess = Horde.session if not sess then return nil end local F = font() if not F then return nil end if not panelCanvas then local ok, c = pcall(love.graphics.newCanvas, PANEL_W, PANEL_H) if not ok then return nil end panelCanvas = c pcall(panelCanvas.setFilter, panelCanvas, "nearest", "nearest") end local Gun = V.require("HordeGun") local ammo, mag, reloading = Gun.ammo() local ok = pcall(function() love.graphics.push("all") love.graphics.setCanvas(panelCanvas) love.graphics.setBlendMode("alpha") love.graphics.clear(0.93, 0.94, 0.90, 1) -- the health bar, framed, across the top love.graphics.setColor(0, 0, 0, 1) love.graphics.rectangle("fill", 8, 8, PANEL_W - 16, 20) love.graphics.setColor(0.80, 0.80, 0.78, 1) love.graphics.rectangle("fill", 11, 11, PANEL_W - 22, 14) love.graphics.setColor(0.78, 0.12, 0.16, 1) love.graphics.rectangle("fill", 11, 11, (PANEL_W - 22) * math.max(0, sess.hp / sess.maxHp), 14) love.graphics.setColor(0, 0, 0, 1) F.draw(("HP %d"):format(math.ceil(sess.hp)), 8, 34) F.draw(reloading and "RELOADING" or ("AMMO %d/%d"):format(ammo, mag), 8, 50) -- the round pips, so ammunition reads without counting digits local pipW, gap = 9, 5 for i = 1, mag do if i <= ammo and not reloading then love.graphics.setColor(0.85, 0.65, 0.10, 1) else love.graphics.setColor(0.72, 0.73, 0.70, 1) end love.graphics.rectangle("fill", 8 + (i - 1) * (pipW + gap), 66, pipW, 12) end love.graphics.setColor(0, 0, 0, 1) F.draw(("WAVE %d"):format(math.max(1, sess.wave)), 8, 86) F.draw(("%d"):format(math.floor(sess.score)), 8, 102) -- and the banner, wrapped to the panel rather than to the frame. -- BLACK on the panel's own white, like everything else here: the font -- sheets are black glyphs on transparent, so a pale letter is not a -- thing that can be drawn (see the header). if sess.bannerText then local lines, bs, tr = layout(F, sess.bannerText, 1, PANEL_W - 8) if lines then local top = PANEL_H - 8 * bs * #lines - 5 love.graphics.setColor(0, 0, 0, 1) love.graphics.rectangle("fill", 0, top - 2, PANEL_W, 1) for i, line in ipairs(lines) do local pen = math.floor((PANEL_W - line.width) / 2) local ly = PANEL_H - 8 * bs * (#lines - i + 1) - 3 for _, code in ipairs(F.encode(line.text)) do love.graphics.push() love.graphics.translate(pen, ly) love.graphics.scale(bs, bs) F.drawCode(code, 0, 0) love.graphics.pop() pen = pen + F.advanceOf(code) * bs + tr end end end end love.graphics.setCanvas() love.graphics.pop() end) pcall(love.graphics.setCanvas) if not ok then return nil end return panelCanvas end -- window resize / hot reload function HordeHud.invalidate() if panelCanvas and panelCanvas.release then pcall(panelCanvas.release, panelCanvas) end panelCanvas = nil end return HordeHud