Files
YataiDON8/Scripts/game/nk_hud.lua
T
2026-10-03 10:39:01 +08:00

485 lines
18 KiB
Lua

local M = {}
M.__index = M
local SCR, SUB = "game", "nk_hud"
local RAD = 0.01745329251944444
-- Resolution independence: every screen-space number below is in taikonantoka's 640x480
-- pixels and is multiplied by K = screen width / 640 (a skin that reuses this script at
-- 960x720 ships the art 1.5x, so K = 1.5). Texture sizes (t.w / t.h) are already in screen
-- pixels and are never scaled. K is the integer 1 at 640 wide, so the native skin keeps
-- exactly the same (integer) arithmetic.
local K = tex.get_screen_width() / 640
K = math.tointeger(K) or K
-- dispconf.ini [演奏] (640x480 px, scaled by K below)
local C = {
combo_x = 55, combo_y = 158, combo_w = 20, combo_over100_w = 28, combo_over1000_w = 28,
cb_x = 278, cb_y = 83, cb_num_x = 292, cb_num_y = 85, cb_num_w = 32, cb_num_w1000 = 26,
roll_x = 245, roll_y = 31, roll_num_x = 230, roll_num_y = 30, roll_num_w = 28, roll_num_x100 = 238, roll_num_w100 = 26,
sg_x = 442, sg_y = 42, meter_x = 416, meter_y = 42, meter_grow = 6,
soul_x = 592, soul_y = 40,
}
for k, v in pairs(C) do C[k] = v * K end
-- 2P (skin_config game_p2_y 231 vs 1P 184*0.5 = 92): the 2P lane is the 1P lane mirrored about
-- y 237.5, so lane-attached parts (combo digits on the drum) move down P2_DY and the 1P HUD parts
-- above the lane (soul gauge, soul, roll / combo balloons) are mirrored to the bottom: y -> 475 - y,
-- balloons flipped vertically so their tails point at the 2P lane.
local P2_DY = 139 * K
local SCREEN_H = 475 * K -- mirror constant (not the screen height)
local BRIGHT_RISE = 20 * K -- combo_bright drifts up 20 px over its 250 ms
-- combo_bright spots (stack arrays in sub_40F6D0 @0x4171CF)
local BRIGHT_X = { -40, -30, 0, 30, 10, 40, -8, -38 }
local BRIGHT_Y = { 30, -15, -20, -20, 16, 16, 14, -20 }
local BRIGHT_T = { 0, 30, 100, 100, 160, 160, 250, 180 }
for i = 1, #BRIGHT_X do BRIGHT_X[i] = BRIGHT_X[i] * K; BRIGHT_Y[i] = BRIGHT_Y[i] * K end
local function trunc(v) if v >= 0 then return math.floor(v) else return math.ceil(v) end end
local function fade_out_alpha(k) return math.max(0, math.min(255, 255 + k)) end
local TC = {}
local function T(name)
local c = TC[name]
if c ~= nil then return c or nil end
local id = tex.get_texture(SCR .. "/" .. SUB .. "/" .. name)
local info = id and tex.get_texture_info(SUB, name)
if not (id and info) then TC[name] = false; return nil end
c = { id = id, w = info.width, h = info.height }
TC[name] = c
return c
end
local function now_ms(self)
local ok, v = pcall(tex.get_current_ms)
if ok and type(v) == "number" then return v end
return self.last_update or 0
end
-- centred draw, integer top-left like the round-1 textures (sub_431B30 / 431BF0)
local function draw_c(t, cx, cy, alpha, frame, blend, mirror)
if not t or (alpha and alpha <= 0) then return end
local p = { x = math.floor(cx - t.w / 2), y = math.floor(cy - t.h / 2), mirror = mirror }
if frame then p.frame = frame end
if alpha and alpha < 255 then p.fade = alpha / 255 end
if blend then p.blend = blend end
tex.draw_texture(t.id, p)
end
-- centred draw scaled by (s, s) (sub_431CC0 / 431DA0 / 432030)
local function draw_cs(t, cx, cy, s, alpha, frame, blend, mirror)
if not t or s <= 0 or (alpha and alpha <= 0) then return end
local p = { x = cx - t.w / 2, y = cy - t.h / 2, center = true, scale = s, mirror = mirror }
if frame then p.frame = frame end
if alpha and alpha < 255 then p.fade = alpha / 255 end
if blend then p.blend = blend end
tex.draw_texture(t.id, p)
end
-- centred draw with sx = 1, sy = s, y already moved so the bottom edge stays put
local function draw_sy(t, cx, cy, s, alpha, frame)
if not t or (alpha and alpha <= 0) then return end
local p = { x = cx - t.w / 2, y = cy - t.h * s / 2, y2 = (s - 1) * t.h, frame = frame }
if alpha and alpha < 255 then p.fade = alpha / 255 end
tex.draw_texture(t.id, p)
end
local function pop(t) -- 1 + 0.22 sin(1.2 t deg) for 150 ms (combo, roll digits)
if t > 150 or t < 0 then return 1.0 end
return math.sin(t * 1.2 * RAD) * 0.22 + 1.0
end
---------------------------------------------------------------------------------------------
function M.new(player_num)
local self = setmetatable({}, M)
self.two = (player_num == 3)
self.solo2 = (player_num == 2) -- 2P playing alone, on the top lane
self.p = {}
for i = 1, 2 do
self.p[i] = { combo = 0, t_combo = -1e9, t10 = nil, t100 = nil,
roll = 0, roll_last = nil, roll_break = true, gauge = nil }
end
tex.load_folder(SCR, SUB)
return self
end
function M:st(pn)
return self.p[(pn == 2) and 2 or 1]
end
function M:dy(pn)
return (self.two and pn == 2) and P2_DY or 0
end
-- HUD y of a 1P part for player pn (2P: mirrored to the bottom HUD)
function M:hy(pn, y)
if self.two and pn == 2 then return SCREEN_H - y end
return y
end
function M:is_p2(pn) return self.two and pn == 2 end
function M:t2(pn, name)
if self:is_p2(pn) then return T(name .. "_2p") end
if self.solo2 then return T(name .. "_2pt") end
end
function M:update(current_ms)
self.last_update = current_ms
self.restart_st = self.restart_st or {}
if NK_RESTARTED and NK_RESTARTED(self.restart_st) then
for i = 1, 2 do
self.p[i] = { combo = 0, t_combo = -1e9, t10 = nil, t100 = nil,
roll = 0, roll_last = nil, roll_break = true, gauge = nil }
end
end
end
local function judged(self, pn, good)
local s = self:st(pn)
local n = now_ms(self)
s.roll_break = true
if not good then
s.combo = 0; s.t10 = nil; s.t100 = nil
return
end
s.combo = s.combo + 1
s.t_combo = n
if s.combo % 10 == 0 then s.t10 = n end
if s.combo % 100 == 0 then s.t100 = n end
end
function M:handle_good(pn) judged(self, pn, true) end
function M:handle_ok(pn) judged(self, pn, true) end
function M:handle_bad(pn) judged(self, pn, false) end
function M:handle_drumroll(pn)
local s = self:st(pn)
local n = now_ms(self)
if s.roll_break or not s.roll_last or n - s.roll_last >= 1550 then s.roll = 0 end
s.roll = s.roll + 1
s.roll_last = n
s.roll_break = false
end
function M:handle_gauge(pn, progress, is_clear, is_rainbow)
local s = self:st(pn)
s.gauge = { g = math.max(0, math.min(1, progress or 0)) * 100,
clear = is_clear and true or false, rainbow = is_rainbow and true or false }
end
---------------------------------------------------------------------------------------------
-- Norma line. taikonantoka's gauge art has it at 80 % (the bar's red / yellow split at column 234 of
-- 298, i.e. 40 of 50 cells; the frame's dark-red step and the ノルマ label above it). The engine's
-- norma is 60 % on easy, 70 % on normal / hard, 80 % on oni / ura; in a dan course it is the soul
-- gauge exam's red value. Lua gets no difficulty here: the song select (YataiDON14 / Juuyon
-- song_select.lua) leaves the chosen course in NK_COURSE_BY_PLAYER[player], the dan background leaves
-- the exam's value in NK_DAN_RED. The art is cut at the line and drawn with it moved: 3 art px / %.
local NORMA_PCT = { [0] = 60, [1] = 70, [2] = 70, [3] = 80, [4] = 80 }
function M:norma_shift(pn)
local pct = 80
if NK_DAN_RED then pct = NK_DAN_RED
elseif type(NK_COURSE_BY_PLAYER) == "table" and NORMA_PCT[NK_COURSE_BY_PLAYER[pn]] then
pct = NORMA_PCT[NK_COURSE_BY_PLAYER[pn]]
end
-- whole cells (6 art px = 2 %), so the overlay's cell stripes stay in step
return 6 * math.floor((math.max(50, math.min(100, pct)) - 80) / 2 + 0.5)
end
-- art columns sx0..sx0+w of t, full height, drawn at art column dx; frame top-left (X, Y), u = texture px per art px
local function cols(t, u, sx0, w, dx, X, Y)
if not t or w <= 0 then return end
tex.draw_texture(t.id, { x = X + dx * u, y = Y, src = { x = sx0 * u, y = 0, width = w * u, height = t.h },
x2 = w * u - t.w })
end
-- dest art columns d0..d1 filled with src columns s0.. repeated in 60-px (10-cell) runs
local function tile(t, u, s0, s1, d0, d1, X, Y)
local run = math.min(60, s1 - s0)
local d = d0
while d < d1 and run > 0 do
local w = math.min(run, d1 - d)
cols(t, u, s0, w, d, X, Y)
d = d + w
end
end
local FRAME_COLS = { sg_1 = { 234, 242, 302 }, sg_2 = { 196, 242, 302 },
sg_1_2p = { 196, 242, 302 }, sg_2_2p = { 196, 242, 302 },
sg_1_2pt = { 234, 242, 302 }, sg_2_2pt = { 196, 242, 302 } }
local function draw_frame(t, key, cx, cy, sh)
if not t then return end
local c = FRAME_COLS[key]
if sh == 0 or not c then return draw_c(t, cx, cy) end
local m0, m1, R = c[1], c[2], c[3]
local u = t.w / 360
local X, Y = math.floor(cx - t.w / 2), math.floor(cy - t.h / 2)
cols(t, u, 0, 6, 0, X, Y)
if sh < 0 then cols(t, u, 6, m0 + sh - 6, 6, X, Y) else tile(t, u, 6, m0, 6, m0 + sh, X, Y) end
cols(t, u, m0, m1 - m0, m0 + sh, X, Y)
if sh < 0 then tile(t, u, m1, R, m1 + sh, R, X, Y) else cols(t, u, m1, R - m1 - sh, m1 + sh, X, Y) end
cols(t, u, R, 360 - R, R, X, Y)
end
-- bar (298 x 23 art), filled to w texture px: red 0..234 -> 0..split, yellow 234..298 -> split..298
local function draw_bar(t, x, y, w, sh, fade)
local u = t.w / 298
local sp, s0 = (234 + sh) * u, 234 * u
local function seg(src0, src1, d0, d1)
if d1 <= d0 or src1 <= src0 then return end
tex.draw_texture(t.id, { x = x + d0, y = y, src = { x = src0, y = 0, width = src1 - src0, height = t.h },
x2 = (d1 - d0) - t.w, fade = fade })
end
local r = math.min(w, sp)
seg(0, r * s0 / sp, 0, r)
if w > sp then seg(s0, s0 + (w - sp) * (t.w - s0) / (t.w - sp), sp, w) end
end
local function draw_rainbow(t, x, y, bar, sh, y0)
if not t then return end
local u = bar.w / 298
local s0, sp = 234 * u, (234 + sh) * u
local function seg(src0, src1, d0, d1)
if d1 <= d0 or src1 <= src0 then return end
tex.draw_texture(t.id, { x = x + d0, y = y, src = { x = 6 + src0, y = y0, width = src1 - src0, height = 34 },
x2 = (d1 - d0) - t.w, y2 = bar.h - t.h })
end
seg(0, s0, 0, sp)
seg(s0, bar.w, sp, bar.w)
end
local T8_FPS = 60
local FEVER_X0, FEVER_Y0 = -20, -120
local function t8_course(pn)
local pct = 80
if NK_DAN_RED then pct = NK_DAN_RED
elseif type(NK_COURSE_BY_PLAYER) == "table" and NORMA_PCT[NK_COURSE_BY_PLAYER[pn]] then
pct = NORMA_PCT[NK_COURSE_BY_PLAYER[pn]]
end
if pct < 65 then return "e" elseif pct < 75 then return "n" end
return "h"
end
function M:draw_gauge(pn)
local s = self:st(pn)
if not s.gauge then return end
local g = s.gauge.g
local n = trunc(now_ms(self))
local key = "t8g_" .. (self:is_p2(pn) and "katsu" or "don") .. "_" .. t8_course(pn) .. "_"
local fv = s.t8_fever
if g >= 100 then
if not (fv and fv.on) then fv = { on = true, t0 = n }; s.t8_fever = fv end
elseif fv and fv.on then
fv = { on = false, t0 = n }; s.t8_fever = fv
end
local cx, cy = C.sg_x, self:hy(pn, C.sg_y)
local f = fv and math.floor((n - fv.t0) * T8_FPS / 1000) or 0
local ff
if fv and fv.on then
f = f + 1
ff = f > 9 and 10 + (f - 10) % 24 or f
elseif fv and f < 12 then
ff = 34 + f
end
if not ff then
draw_c(T(string.format("%s%02d", key, math.max(0, math.min(50, trunc(g / 2))))), cx, cy)
return
end
-- the gauge's top-left (360 x 64 movie px, centred on (cx, cy)) + the fever canvas offset
local x = math.floor(cx - 180 * K + FEVER_X0 * K)
local y = math.floor(cy - 32 * K + FEVER_Y0 * K)
local body, glow = T(string.format("%sf%02d", key, ff)), T(string.format("%sa%02d", key, ff))
if body then tex.draw_texture(body.id, { x = x, y = y }) end
if glow then tex.draw_texture(glow.id, { x = x, y = y, blend = "additive" }) end
end
---------------------------------------------------------------------------------------------
function M:draw_combo(s, n, dy)
local c = s.combo
local X, Y = C.combo_x, C.combo_y + dy
-- flower (drawn before the digits)
if c >= 100 and s.t100 then
local t = n - s.t100
local fl, fm = T("flower"), T("flower_m")
if t < 170 then
local sc = math.sin(t * 0.6588235294117647 * RAD) * 1.083333333333333
draw_cs(fl, X, Y, sc); draw_cs(fm, X, Y, sc)
elseif t < 1970 then
draw_c(fl, X, Y); draw_c(fm, X, Y)
elseif t < 2070 then
draw_c(fl, X, Y); draw_c(fm, X, Y, fade_out_alpha(trunc((t - 1970) * -2.55)))
elseif t < 4670 then
draw_c(fl, X, Y)
elseif t < 4770 then
draw_c(fl, X, Y, fade_out_alpha(trunc((t - 4670) * -2.55)))
end
end
if c < 10 then return end
local sc = pop(n - s.t_combo)
local dig, count, xat
if c < 100 then
dig, count = T("combo"), 2
xat = function(j) return 0.5 * C.combo_w + X - (j - 1) * C.combo_w end
elseif c < 1000 then
dig, count = T("combo_l"), 3
xat = function(j) return X + (2 - j) * C.combo_over100_w end
elseif c < 10000 then
dig, count = T("combo_l"), 4
xat = function(j) return C.combo_over1000_w * 1.5 + X - (j - 1) * C.combo_over1000_w end
elseif c < 100000 then
dig, count = T("combo_l"), 5
xat = function(j) return C.combo_over1000_w * 2 + X - (j - 1) * C.combo_over1000_w end
end
if dig then
for j = 1, count do
local d = (c % (10 ^ j)) // (10 ^ (j - 1))
-- centre y = Y - (s*h/2 - h/2): the digit grows upwards from its bottom edge
draw_sy(dig, xat(j), Y - (sc * dig.h * 0.5 - dig.h * 0.5), sc, nil, math.floor(d))
end
end
if c >= 100 then
local b = T("combo_bright")
local t = n % 500
for i = 1, 8 do
local t0 = BRIGHT_T[i]
if t >= t0 and t < t0 + 250 then
local tt = t - t0
draw_cs(b, X + BRIGHT_X[i], Y + BRIGHT_Y[i] - tt / 250.0 * BRIGHT_RISE, 1.0 - tt / 375.0,
fade_out_alpha(trunc(tt / -250.0 * 255.0)), nil, "additive")
end
end
end
end
function M:draw_callout(s, n, pn)
if s.combo < 10 or not s.t10 then return end
local t = n - s.t10
if t >= 830 then return end
local bal2 = self:t2(pn, "combo_balloon") -- already drawn for the bottom HUD
local bal = bal2 or T("combo_balloon")
local mir = (self:is_p2(pn) and not bal2) and "vertical" or nil
local a, sc = 255, 1.0
if t <= 50 then a = trunc(t / 50.0 * 255.0)
elseif t < 200 then sc = math.sin((t - 50.0) * 180.0 / 150.0 * RAD) * 0.22 + 1.0
elseif t >= 730 then a = fade_out_alpha(trunc((t - 730.0) / -100.0 * 255.0)) end
if bal then
if sc ~= 1.0 then
-- the pop keeps the tail edge put (bottom for 1P, top for the flipped 2P balloon)
local grow = (sc * bal.h * 0.5 - bal.h * 0.5) * (self:is_p2(pn) and -1 or 1)
draw_cs(bal, C.cb_x, self:hy(pn, C.cb_y) - grow, sc, a, nil, nil, mir)
else
draw_c(bal, C.cb_x, self:hy(pn, C.cb_y), a, nil, nil, mir)
end
end
local num = self:t2(pn, "cb_num") or T("cb_num")
if not num then return end
local v = 10 * (s.combo // 10)
local w = (s.combo < 1000) and C.cb_num_w or C.cb_num_w1000
for i = 4, 0, -1 do
local p = 10 ^ i
if s.combo >= p then
draw_c(num, C.cb_num_x - w * i, self:hy(pn, C.cb_num_y), a, math.floor((v % (10 ^ (i + 1))) // p))
end
end
end
function M:draw_roll(s, n, pn)
if not s.roll_last or s.roll <= 0 then return end
local t = n - s.roll_last
if t >= 1550 then return end
local a = (t >= 1400) and fade_out_alpha(trunc((t - 1400.0) / -150.0 * 255.0)) or 255
local sc = pop(t)
-- 2P: the burst (and its 連打!!) is not flipped; it moves to the mirrored centre and the
-- digits keep their 1P offset from it, so number and 連打!! stay on one row
local ry = self:hy(pn, C.roll_y)
draw_c(T("roll_balloon"), C.roll_x, ry, a)
local num = T("roll")
if not num then return end
local cnt = s.roll
local function put(i, x)
local d = (cnt % (10 ^ (i + 1))) // (10 ^ i)
local ny = ry + (C.roll_num_y - C.roll_y)
local yc = num.h * 0.5 + ny - sc * (num.h * 0.5)
draw_sy(num, x, yc, sc, a, math.floor(d))
end
if cnt >= 100 then
for i = 0, 4 do
if 10 ^ i > cnt then break end
put(i, C.roll_num_x100 - i * C.roll_num_w100)
end
else
for i = 1, 0, -1 do
if 10 ^ i <= cnt then put(i, C.roll_num_x - C.roll_num_w * i) end
end
end
end
function M:draw_fore_pre()
local n = now_ms(self)
for pn = 1, (self.two and 2 or 1) do
self:draw_combo(self.p[pn], n, self:dy(pn))
end
end
function M:draw_fore_post()
local n = now_ms(self)
for pn = 1, (self.two and 2 or 1) do
local s = self.p[pn]
self:draw_roll(s, n, pn)
self:draw_callout(s, n, pn)
end
end
function M:destroy()
TC = {}
tex.unload_folder(SCR, SUB)
end
---------------------------------------------------------------------------------------------
local function guard(f, ...)
local ok, err = pcall(f, ...)
if not ok then tex.log("nk_hud: " .. tostring(err)) end
end
function M.attach(obj, player_num)
if type(obj) ~= "table" or rawget(obj, "__nk_hud") then return obj end
local ok, hud = pcall(M.new, player_num)
if not ok then tex.log("nk_hud: " .. tostring(hud)); return obj end
rawset(obj, "__nk_hud", hud)
local function chain(name, pre, post)
local orig = obj[name]
rawset(obj, name, function(self, ...)
if pre then guard(pre, hud, ...) end
local r
if type(orig) == "function" then r = orig(self, ...) end
if post then guard(post, hud, ...) end
return r
end)
end
chain("update", M.update)
chain("handle_good", M.handle_good)
chain("handle_ok", M.handle_ok)
chain("handle_bad", M.handle_bad)
chain("handle_drumroll", M.handle_drumroll)
chain("handle_gauge", M.handle_gauge)
chain("draw_gauge", nil, M.draw_gauge) -- after anything the background draws there
chain("draw_fore", M.draw_fore_pre, M.draw_fore_post)
chain("destroy", nil, M.destroy)
return obj
end
function M.install(cls)
if type(cls) ~= "table" or rawget(cls, "__nk_hud_installed") then return end
rawset(cls, "__nk_hud_installed", true)
local orig_new = cls.new
cls.new = function(player_num, ...)
return M.attach(orig_new(player_num, ...), player_num)
end
end
return M