Files
YataiDON14/Scripts/background/background.lua
T
2026-10-15 09:16:53 -04:00

973 lines
42 KiB
Lua

-- YataiDONNantoka gameplay background: taikonantoka's Play/Bg, Dancer, Chara, Chibi,
-- Drapery and Load layers (Files/Theme/Original/Play, 640x480, drawn 1:1).
-- Numbers and timings come from dispconf.ini [演奏], the *.ini files next to the art and
-- the decompiled play loop sub_40F6D0 / sub_422330 / sub_41CEB0 -- see
-- Graphics/background/MAPPING_bg.md for the source of every constant.
--
-- Time bases (taikonantoka):
-- ms : play-screen milliseconds (dancer-bg NORMAL set, upper-bg scroll, fades)
-- clk : beat clock, 3600 units per beat, follows BPM changes (dbl_162B918)
-- The engine hands the background no GOGO flag and no difficulty; see MAPPING_bg.md
-- ("engine gaps") for what that leaves out.
-- Round 2: balloon burst from the engine's "balloon_pop" sound, balloon fail after the
-- 1 s no-hit timeout, and the Play/Load/bg_end wipe when the song audio ("song") stops
-- (continued over the engine's result transition by Scripts/result/result_transition.lua
-- through the shared global NK_PLAY_WIPE_MS).
local SCR = "background"
local BEAT = 3600
-- Resolution independence: every screen-space number in this file (positions, distances,
-- amplitudes, px/s speeds) is in taikonantoka's 640x480 pixels and is multiplied by
-- K = screen width / 640 -- at the constant tables right below them, or inline as "* K".
-- A skin reusing this script at 960x720 ships the art 1.5x, so K = 1.5. Texture sizes
-- (t.w / t.h) are already screen pixels and are never scaled; times, alphas and frame math
-- are unchanged. 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
local SW, SH = 640 * K, 480 * K
-- dispconf.ini [演奏]
local CFG = {
bg_scroll_speed = 44,
character_x = 86, character_y = 62,
character_balloon_x = 125, character_balloon_y = 129,
balloon_x = 247, balloon_y = 150,
balloon_hit_x = 249, balloon_hit_y = 159,
bubble_x = 207.5, bubble_y = 64, bubble_num_y = 58.8, bubble_num_w = 22,
stage_y = 456,
note_y = 156,
}
for k, v in pairs(CFG) do CFG[k] = v * K end -- bg_scroll_speed is px/s: scales too
-- Play/Bg/dancer_1/dancer_bg.ini (the only dancer_N folder the game loads; dancer_2- is
-- skipped by its FindFirstFile("dancer_%d") probe).
-- move: {kind, ...} appear: {kind, t0,t1,t2,t3,h1,h2,h3}
local DBG_NORMAL = {
{ tex = "bg_1", x = 320, y = 323 },
{ tex = "bg_2", x = -256, y = 323, move = { "xloop", 512, -512, 180000 } },
{ tex = "bg_3", x = 256, y = 323, move = { "xloop", 512, -512, 180000 } },
{ tex = "bg_2", x = 768, y = 323, move = { "xloop", 512, -512, 180000 } },
{ tex = "bg_3", x = 1280, y = 323, move = { "xloop", 512, -512, 180000 } },
{ tex = "bg_4", x = -76, y = 358, move = { "xloop", -100, 800, 180000 } },
{ tex = "bg_5", x = -76, y = 358, add = true,
move = { "xloop_blink", -100, 800, 180000, 128, 255, 1000 } },
{ tex = "bg_6", x = 320, y = 370 },
{ tex = "bg_7", x = 320, y = 287, move = { "nodraw" },
appear = { "bound_up", 1300, 1700, 1700, 1700, 60, 0, 0 } },
{ tex = "bg_8", x = 320, y = 347 },
}
local DBG_CLEAR = {
{ tex = "bg_clear_1", x = 320, y = 347,
appear = { "bound_down_cos", 0, 500, 700, 900, 255, 60, 60 } },
{ tex = "bg_clear_2", x = -320, y = 347, offset = -900, move = { "xloop", 640, 0, 6400 },
appear = { "bound_down_cos", 0, 500, 700, 900, 255, 60, 60 } },
{ tex = "bg_clear_2", x = 320, y = 347, offset = -900, move = { "xloop", 640, 0, 6400 },
appear = { "bound_down_cos", 0, 500, 700, 900, 255, 60, 60 } },
}
-- scale x / y, the xloop start / end x (move[2], move[3]) and the appear heights
-- (appear[6..8]); periods, blink alphas, offsets and appear times are ms / alpha
for _, list in ipairs({ DBG_NORMAL, DBG_CLEAR }) do
for _, l in ipairs(list) do
l.x, l.y = l.x * K, l.y * K
local m = l.move
if m and (m[1] == "xloop" or m[1] == "xloop_blink") then m[2], m[3] = m[2] * K, m[3] * K end
local a = l.appear
if a then for i = 6, 8 do a[i] = a[i] * K end end
end
end
-- Play/Dancer/1/dancer.ini (Dancer/2 is byte-identical)
local DANCER = {
x = { 320, 190, 450, 60, 580 },
y = { 352, 342, 361, 353, 353 },
interval = 16,
order = { 1,1,2,2,3,3,2,2,1,1,2,2,3,4,5,6,7,7,8,8,9,9,8,8,7,7,8,8,9,9,6,6,10,11,12,11,10,11,12,11,
10,13,14,15,14,13,14,15,14,13,10,11,12,11,10,11,12,11,10,13,14,15,14,13,14,15,14,13 },
appear_pattern = { 1, 1, 1, 0, 0 },
appear_interval = 3.5,
appear_order = { 1,2,3,4,5,6,7,8,8,9,9,10,10,11,11,12,12,12,12,12 },
}
for i = 1, #DANCER.x do DANCER.x[i] = DANCER.x[i] * K end
for i = 1, #DANCER.y do DANCER.y[i] = DANCER.y[i] * K end
-- Play/Chibi/{1,2,Soul}/chibi.ini
local CHIBI_SETS = {
{ sub = "nk_chibi1", x = 125, y = 62, xp = { 1, 1, 2, 2 }, yp = { 2, 2, 1, 1 }, sort = 4,
order = { 1, 2, 3 }, interval = 0.5, appear = 1, appear_frame = 2 },
{ sub = "nk_chibi2", x = 125, y = 62, xp = { 1, 1, 1, 1 }, yp = { 1, 1, 1, 1 }, sort = 4,
order = { 1, 2, 3, 4, 5 }, interval = 1.0, appear = 1, appear_frame = 1 },
}
local CHIBI_SOUL = { sub = "nk_chibi_soul", x = 174, y = 82, xp = { 0 }, yp = { 0 }, sort = 1,
order = { 4, 5 }, interval = 1.0, appear = 0, appear_frame = 1 }
for _, set in ipairs({ CHIBI_SETS[1], CHIBI_SETS[2], CHIBI_SOUL }) do set.x, set.y = set.x * K, set.y * K end
-- full-combo drapery flower centres (hard-coded in sub_40F6D0)
local FLOWERS = { { 230, 190 }, { 260, 124 }, { 318, 185 }, { 358, 122 },
{ 400, 192 }, { 450, 120 }, { 492, 192 }, { 538, 130 } }
for _, p in ipairs(FLOWERS) do p[1], p[2] = p[1] * K, p[2] * K end
local P2_BALLOON_DY = 30
local P2_MIRROR = 475 -- 2P HUD: 1P y -> 475 - y (640 px; the 2P lane is the 1P lane mirrored)
local SUBSETS = { "nk_upper", "nk_dbg1", "nk_stage", "nk_fiver", "nk_dancer1", "nk_chibi1",
"nk_chibi2", "nk_chibi_soul", "nk_chara", "nk_drapery", "nk_load", "nk_dan", "nk_bubble",
"nk_symbol" } -- [YataiDON14] the difficulty symbol
local function rad(deg) return deg * math.pi / 180 end
local function trunc(v) if v >= 0 then return math.floor(v) else return math.ceil(v) end end
-- C remainder (sign of the dividend)
local function cmod(a, b) return math.fmod(a, b) end
local NEVER = 1e9
---------------------------------------------------------------------------------------------
-- parent collab scenes that have no taikonantoka counterpart keep the parent's look.
-- DAN and PRACTICE gameplay use the taikonantoka enso (Graphics/game/MAPPING_hud.md,
-- "Practice / Dan"): the engine hands them this same Background with scene_preset "DAN" /
-- "PRACTICE"; the practice overlay (large drums, pause buttons, progress bar) and the dan
-- panels / dan gauge are engine draws on top and keep working.
local PARENT_SCENES = {
TUTORIAL = { "background/bg_collabs/tutorial" },
}
Background = {}
Background.__index = Background
-- sub -> name -> texture (two levels: a hit builds no key string)
local TCACHE = {}
local MISSING = {}
local function T(sub, name)
local by_sub = TCACHE[sub]
if not by_sub then by_sub = {}; TCACHE[sub] = by_sub end
local c = by_sub[name]
if c then return (c ~= MISSING) and 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 by_sub[name] = MISSING; return nil end
c = { id = id, w = info.width, h = info.height }
by_sub[name] = c
return c
end
-- top-left draw
local BLIT_P = {}
local function blit(t, x, y, alpha, blend)
if not t then return end
local p = BLIT_P
p.x, p.y = x, y
p.fade = (alpha and alpha < 255) and math.max(alpha, 0) / 255 or nil
p.blend = blend or nil
tex.draw_texture(t.id, p)
end
-- centred draw (taikonantoka sub_431B30 / sub_432490: top-left = centre - size/2)
local function blit_c(t, cx, cy, alpha, blend)
if not t then return end
blit(t, cx - t.w / 2, cy - t.h / 2, alpha, blend)
end
function Background.new(player_num, bpm, scene_preset)
local parent = scene_preset and PARENT_SCENES[scene_preset]
if parent then
local mod = require(parent[1])
local cls = (type(mod) == "table" and mod.new) and mod
or ({ PRACTICE = PracticeBackground, TUTORIAL = TutorialBackground })[scene_preset]
if cls then
if parent[2] then return cls.new(player_num, bpm, parent[2], parent[3]) end
return cls.new(player_num, bpm)
end
end
local self = setmetatable({}, Background)
NK_DAN_RED = nil -- set by handle_dan (nk_hud's norma line)
self.player_num = player_num
self.is_2p = (player_num == 3)
self.bpm = (bpm and bpm > 0) and bpm or 120
for _, s in ipairs(SUBSETS) do tex.load_folder(SCR, s) end
-- random picks, as the game does when config.ini forces none
local up = math.random(1, 4)
self.upper = { p1 = T("nk_upper", "bg_" .. up .. "_p1"), miss = T("nk_upper", "bg_" .. up .. "_miss"),
clear = T("nk_upper", "bg_" .. up .. "_clear") }
-- 2P: the same set recoloured blue (nk_upper/*_2p.png; taikonantoka has no 2P art)
self.upper2 = { p1 = T("nk_upper", "bg_" .. up .. "_p1_2p"), miss = T("nk_upper", "bg_" .. up .. "_miss_2p"),
clear = T("nk_upper", "bg_" .. up .. "_clear_2p") }
self.stage = T("nk_stage", "stage_" .. math.random(1, 3))
self.fiver = T("nk_fiver", "fiver_" .. math.random(1, 2) .. "_1")
self.chibi_set = CHIBI_SETS[math.random(1, 2)]
local function prep(list)
for _, l in ipairs(list) do l.t = T("nk_dbg1", l.tex) end
local maxend = -NEVER
for _, l in ipairs(list) do
l.appear_end = l.appear and trunc(l.appear[5]) or -NEVER
if l.appear_end > maxend then maxend = l.appear_end end
end
return maxend
end
prep(DBG_NORMAL)
self.clear_max_end = prep(DBG_CLEAR)
self.chara = {}
for _, n in ipairs({ "chara_p1_1", "chara_p1_2", "chara_p1_3", "chara_p1_4", "chara_p1_5",
"chara_p1_miss_1", "chara_p1_miss_2", "chara_p1_balloon_1",
"chara_p1_balloon_2", "chara_p1_balloon_3", "chara_p1_balloon_4",
"chara_p1_balloon_5", "balloon_1", "balloon_2", "balloon_3", "balloon_4",
"balloon_5", "balloon_6", "balloon_7", "balloon_8" }) do
self.chara[n] = T("nk_chara", n)
end
-- 2P Don: nk_chara/chara_p1_*_2p.png (blue recolour); the balloons are shared
self.chara2 = {}
for n, t in pairs(self.chara) do self.chara2[n] = T("nk_chara", n .. "_2p") or t end
-- 2P playing alone (top HUD): the blue upper background and the blue Don there too
if player_num == 2 then self.upper, self.chara = self.upper2, self.chara2 end
self.start_ms = nil
self.ms = 0 -- ms since the play screen started
self.now = 0 -- engine frame ms
self.clk = 0 -- beat clock
self.last_now = nil
-- gauge / states (sub_41CEB0)
self.gauge = 0 -- 0..100
self.gauge_int = 0
self.clear_line = 80 -- 60/70/80 by course; refined when the engine reports a clear
self.is_clear = false
self.is_full = false
self.state = 0 -- 0 normal 1 miss 2 clear 3 full
self.prev_state = 0
self.t_miss = NEVER
self.t_clear = NEVER
self.pending_hit = false
self.clear_since = nil -- CLEAR dancer-bg clock origin (ms)
self.level_t = {} -- beat-clock stamp per integer gauge level (dbl_162BFE8)
self.level_t[0] = 7200 - 2000
self.combo = 0
self.hop_ms = -NEVER
-- 2P (is_2p): the bottom HUD's own upper-bg / Don state, driven by player 2's events only.
-- The 1P state stays on self; both go through the same code via Background:ps(player_num).
self.p2 = { state = 0, prev_state = 0, t_miss = NEVER, t_clear = NEVER, pending_hit = false,
combo = 0, hop_ms = -NEVER, is_clear = false,
balloon = 0, balloon_ms = -NEVER, balloon_hit = false, pop_prev = false }
self.balloon = 0 -- dword_8F9C8C: 1..6 rolling, 9 burst, 10 fail
self.balloon_ms = -NEVER -- dbl_162B2F0
self.balloon_hit = false -- a balloon hit arrived since the last update
self.pop_prev = false -- "balloon_pop" was playing at the previous balloon hit
self.wipe_ms = nil -- Play/Load/bg_end start (engine frame ms)
self.end_bg = T("nk_load", "bg_end")
NK_PLAY_WIPE_MS = nil
self.chibis = {}
self.fiver_state = 0
self.song_end_ms = nil
return self
end
-- Quick restart (F1): the engine rebuilds the players but keeps this background object, so its
-- own play state (combo, Don / upper-bg states, balloons, gauge) would carry over. A restart plays
-- "restart"; a new play of it (audio.play_count, else the sound's rising edge) means start over.
function NK_RESTARTED(st)
if audio.play_count then
local n = audio.play_count("restart")
local hit = st.restart_n ~= nil and n > st.restart_n
st.restart_n = n
return hit
end
local on = audio.is_sound_playing("restart")
local hit = on and not st.restart_on
st.restart_on = on
return hit
end
-- the per-play state of Background.new (textures and random picks stay)
function Background:reset_play()
self.start_ms, self.ms, self.now, self.clk, self.last_now = nil, 0, 0, 0, nil
self.gauge, self.gauge_int, self.clear_line = 0, 0, 80
self.is_clear, self.is_full = false, false
self.state, self.prev_state, self.t_miss, self.t_clear = 0, 0, NEVER, NEVER
self.pending_hit, self.clear_since = false, nil
self.level_t = { [0] = 7200 - 2000 }
self.combo, self.hop_ms = 0, -NEVER
self.p2 = { state = 0, prev_state = 0, t_miss = NEVER, t_clear = NEVER, pending_hit = false,
combo = 0, hop_ms = -NEVER, is_clear = false,
balloon = 0, balloon_ms = -NEVER, balloon_hit = false, pop_prev = false }
self.balloon, self.balloon_ms, self.balloon_hit, self.pop_prev = 0, -NEVER, false, false
self.wipe_ms = nil
NK_PLAY_WIPE_MS = nil
self.chibis, self.fiver_state, self.song_end_ms = {}, 0, nil
end
---------------------------------------------------------------------------------------------
function Background:update(current_ms, bpm)
self.restart_st = self.restart_st or {}
if NK_RESTARTED(self.restart_st) then self:reset_play() end
if bpm and bpm > 0 then self.bpm = bpm end
if not self.start_ms then self.start_ms = current_ms end
if self.last_now then
local dt = current_ms - self.last_now
if dt > 0 then self.clk = self.clk + dt * self.bpm / 60000 * BEAT end
end
self.last_now = current_ms
self.now = current_ms
self.ms = current_ms - self.start_ms
-- chibi lifetime: 6 beats, or once off the right edge
local keep = {}
for _, c in ipairs(self.chibis) do
if self.clk - c.clk < 21600 then keep[#keep + 1] = c end
end
self.chibis = keep
self:update_balloon(self)
if self.is_2p then self:update_balloon(self.p2) end
-- Play/Load/bg_end: state 9 of the play loop starts when the BGM stops playing
-- (IDirectSoundBuffer::GetStatus, sub_40F6D0 ~L11774). Polled only after the last note.
if not self.wipe_ms and self.song_end_ms and not audio.is_sound_playing("song") then
self.wipe_ms = current_ms
if not NK_PLAY_WIPE_MS then NK_PLAY_WIPE_MS = current_ms end
end
end
-- per-player state: self (1P, and the only player outside 2P) or self.p2
function Background:ps(player_num)
if self.is_2p and player_num == 2 then return self.p2 end
return self
end
-- balloon burst: the engine plays "balloon_pop" in the frame of the bursting hit
-- (Player::check_balloon); a rising edge right after a balloon hit = burst (state 9).
-- s = the player's state (self or self.p2): each player has its own balloon.
function Background:update_balloon(s)
if s.balloon_hit then
s.balloon_hit = false
local pop = audio.is_sound_playing("balloon_pop")
if pop and not s.pop_prev and s.balloon >= 1 and s.balloon <= 6 then
s.balloon = 9 -- timed from the bursting hit (dbl_162B2F0)
end
s.pop_prev = pop
end
local e = self.now - s.balloon_ms
if s.balloon >= 1 and s.balloon <= 6 and e > 1000 then
-- fallback for an engine without handle_balloon_end: 1 s without a hit counts as the
-- balloon ending unburst
s.balloon = 10; s.balloon_ms = self.now
elseif s.balloon == 9 and e >= 340 then s.balloon = 0
elseif s.balloon == 10 and e > 300 then s.balloon = 0 end
end
local function hit(self, player_num)
local s = self:ps(player_num)
s.symbol_hit_ms = self.now -- [YataiDON14] the difficulty symbol's hit glow
s.pending_hit = true
s.combo = s.combo + 1
if s.combo % 10 == 0 then s.hop_ms = self.now end
if s == self then self:add_chibi(false) end -- chibis belong to the 1P HUD
end
function Background:handle_good(player_num) hit(self, player_num) end
function Background:handle_ok(player_num) hit(self, player_num) end
function Background:handle_bad(player_num)
local s = self:ps(player_num)
s.combo = 0
s.hop_ms = -NEVER
if s.state ~= 1 then
s.t_miss = self.ms
s.prev_state = s.state
s.state = 1
end
if s == self then self:add_chibi(true) end
end
function Background:handle_drumroll(player_num) end
function Background:handle_balloon(player_num, remaining)
-- dword_8F9C8C: first hit 1, +1 per hit, capped at 6
local s = self:ps(player_num)
s.balloon_left = remaining -- hits left (engine 24f8d9b; nil / -1 = not known: no bubble)
if s.balloon == 0 or s.balloon >= 9 then s.balloon = 1 else s.balloon = math.min(s.balloon + 1, 6) end
s.balloon_ms = self.now
s.balloon_hit = true
end
-- the balloon reached its end note (8) unburst: the Don lets go right away (fail, as the 1 s
-- timeout above), not up to a second later
function Background:handle_balloon_end(player_num)
local s = self:ps(player_num)
if s.balloon >= 1 and s.balloon <= 6 then s.balloon = 10; s.balloon_ms = self.now end
s.balloon_left = nil
end
-- kusudama hits (the engine's handle_kusudama; without it they came as handle_balloon): the chara
-- blows up a balloon only for balloon notes, never for a kusudama
function Background:handle_kusudama(player_num) end
function Background:handle_song_end(player_num, good, ok, bad, total)
self.song_end_ms = self.now
self.full_combo = (bad == 0 and total > 0)
end
-- DAN: the player has no gauge (game_dan.cpp resets it), so handle_gauge never comes; the
-- dan gauge arrives with handle_dan (DanGameScreen::push_dan_state, every frame) and drives
-- the dancers / upper-bg states the same way. Clear = the "gauge" exam's red value (80 when
-- the course has none). Called on the object, so nk_hud's handle_gauge (chained onto it) gets
-- the value too and draws taikonantoka's soul gauge at the normal-play position; the engine's
-- own dan gauge is moved off-screen in Graphics/game/gauge_dan.
function Background:handle_dan(player_num, st)
if type(st) ~= "table" then return end
local g = tonumber(st.gauge) or 0
local red = 80
if type(st.exams) == "table" then
for _, row in ipairs(st.exams) do
if type(row) == "table" and row.type == "gauge" and tonumber(row.red) and row.red > 0 then
red = row.red; break
end
end
end
NK_DAN_RED = red
self:handle_gauge(1, g / 100, g >= red, g >= 100)
-- exam rows the engine will draw (draw_dan_info: gauge exams skipped, at most 3)
local rows = 0
if type(st.exams) == "table" then
for _, row in ipairs(st.exams) do
if type(row) == "table" and row.type ~= "gauge" then rows = rows + 1 end
end
end
self.dan_rows = math.min(rows, 3)
end
-- Dan exam panel: the parent's (PyTaikoGreen) dan background under the whole lower half, drawn at
-- the end of draw_back, i.e. right before DanGameScreen::draw_dan_info:
-- nk_dan/wall = background/collab/dan/bg_normal/bg_0 with its notice board painted out (vertical
-- stripes only, so it is stretched from the song-title bar (y 255) to the bottom)
-- nk_dan/footer = background/collab/dan/footer/0 (blue band), y 448..480
-- nk_dan/remaining_box = that notice board (bg_0 15..282 x 103..304, halved), under "残り音符数".
-- Layout (Graphics/game/dan_info + skin_config dan_select_exam_info / dan_game_hori_name): the three exam
-- rows share y 255..448 evenly (tops 263.5 / 327.5 / 391.5, step 64); the rank plate + board group
-- (150 high) is centred in the same span.
local DAN_PANEL = { top = 255, footer_y = 448, box_x = 6.5, box_y = 323.5 }
function Background:draw_dan_panel()
local P = DAN_PANEL
local wall = T("nk_dan", "wall")
if wall then
local h = (480 - P.top) * K
tex.draw_texture(wall.id, { x = 0, y = P.top * K, x2 = SW - wall.w, y2 = h - wall.h })
end
blit(T("nk_dan", "footer"), 0, P.footer_y * K)
blit(T("nk_dan", "remaining_box"), P.box_x * K, P.box_y * K)
end
function Background:add_chibi(bad)
local set = bad and CHIBI_SOUL or self.chibi_set
local kind = trunc(self.now) % set.sort
if #self.chibis >= 100 then table.remove(self.chibis, 1) end
self.chibis[#self.chibis + 1] = { set = set, kind = kind, clk = self.clk }
end
-- upper-bg state machine (sub_41CEB0, Great/Good branch) on a per-player state s
local function upper_state(s, ms, is_clear, was_clear, is_rainbow)
if s.pending_hit then
s.pending_hit = false
if s.state == 1 then s.t_miss = ms end
if is_rainbow and s.state ~= 3 then
s.prev_state = s.state; s.state = 3
elseif is_clear and s.state < 2 then
s.prev_state = s.state; s.state = 2
if not was_clear then s.t_clear = ms end
elseif not is_clear and s.state == 1 then
s.prev_state = 1; s.state = 0; s.t_clear = NEVER
end
end
if is_clear and not was_clear and s.state >= 2 and s.t_clear == NEVER then
s.t_clear = ms
end
end
function Background:handle_gauge(player_num, progress, is_clear, is_rainbow)
if self.is_2p and player_num ~= 1 then
if player_num == 2 then
local s = self.p2
upper_state(s, self.ms, is_clear, s.is_clear, is_rainbow)
s.is_clear = is_clear
end
return
end
local g = math.max(0, math.min(100, progress * 100))
local gi = math.floor(g + 1e-6)
local was_clear = self.is_clear
if is_clear and not was_clear then
-- the engine's norma is 60/70/80 % like taikonantoka's dword_8FA188
if gi < 70 then self.clear_line = 60 elseif gi < 80 then self.clear_line = 70 else self.clear_line = 80 end
elseif not is_clear and gi >= self.clear_line - 10 and self.clear_line < 80 then
self.clear_line = math.min(80, math.floor(gi / 10) * 10 + 10)
end
-- per-level beat-clock stamps (thread code C 12610)
if gi > self.gauge_int then
for i = self.gauge_int + 1, gi do self.level_t[i] = self.clk end
elseif gi < self.gauge_int then
for i = gi, self.gauge_int - 1 do self.level_t[i] = self.clk end
end
-- CLEAR dancer-bg clock: ms since the gauge last reached the clear line
if is_clear and not was_clear then self.clear_since = self.ms end
if not is_clear then self.clear_since = nil end
upper_state(self, self.ms, is_clear, was_clear, is_rainbow)
self.gauge = g
self.gauge_int = gi
self.is_clear = is_clear
self.is_full = is_rainbow
end
---------------------------------------------------------------------------------------------
-- dancer_bg.ini layers (sub_422330)
local function draw_layer(l, t)
local tx = l.t
if not tx then return end
local blend = l.add and "additive" or nil
if l.appear and t < l.appear_end then
local a = l.appear
local t0, t1, t2, t3, h1, h2, h3 = a[2], a[3], a[4], a[5], a[6], a[7], a[8]
local S = h1 - h2 + h3
local y
local function f(ta, tb) return (tb > ta) and (t - ta) / (tb - ta) or 1 end
if a[1] == "bound_up" then
if t < t0 then y = l.y + S
elseif t < t1 then y = l.y + S - h1 * f(t0, t1)
elseif t < t2 then y = l.y - h2 + h3 + h2 * f(t1, t2)
else y = l.y + h3 * (1 - f(t2, t3)) end
else -- bound_down_cos
if t < t0 then y = l.y - S
elseif t < t1 then y = l.y - S + h1 - h1 * math.cos(rad(90 * f(t0, t1)))
elseif t < t2 then y = l.y - S + h1 - h2 * math.sin(rad(90 * f(t1, t2)))
else y = l.y - h3 * math.cos(rad(90 * f(t2, t3))) end
end
blit_c(tx, l.x, y, nil, blend)
return
end
local m = l.move
if not m then blit_c(tx, l.x, l.y, nil, blend); return end
if m[1] == "nodraw" then return end
local a, b, P = m[2], m[3], m[4]
local x = l.x + a - (a - b) * cmod(trunc(t + (l.offset or 0)), P) / P
local alpha = nil
if m[1] == "xloop_blink" then
local amin, amax, pb = m[5], m[6], m[7]
local r = cmod(trunc(t), pb)
alpha = trunc(amin + (amax - amin) / 2 * (1 + math.sin(2 * math.pi * (r - 180) / pb)))
end
blit_c(tx, x, l.y, alpha, blend)
end
function Background:draw_dancer_bg()
local tC = self.clear_since and (self.ms - self.clear_since) or -NEVER
if not (self.clear_max_end <= tC) then
for _, l in ipairs(DBG_NORMAL) do draw_layer(l, self.ms) end
end
if tC >= 0 then
for _, l in ipairs(DBG_CLEAR) do draw_layer(l, tC) end
end
end
-- Play/Bg/upper: tiled, scrolling bg_scroll_speed px/s; clear/miss cross-fade over 100 ms
function Background:draw_upper(y0, u, s)
y0 = y0 or 0
u = u or self.upper
s = s or self
if not u.p1 then return end
local sp = CFG.bg_scroll_speed
local t = self.ms
local function off_for(w)
if sp == 0 then return 0 end
return (trunc(t) % trunc(w * 1000 / sp)) * sp / 1000
end
local off = off_for(u.p1.w)
local i = 0
while (i - 1) * u.p1.w < SW do
local function tx(w) return i * w - off - i * K * (w - off) / w end
blit(u.p1, tx(u.p1.w), y0)
-- clear layer
local dc = t - s.t_clear
if u.clear and dc >= 0 then
if s.state >= 2 and dc < 100 then
blit(u.clear, tx(u.clear.w), y0, trunc(dc / 100 * 255))
elseif dc >= 100 then
blit(u.clear, tx(u.clear.w), y0)
end
end
-- miss layer
local dm = t - s.t_miss
if u.miss and dm >= 0 then
if s.state == 1 then
blit(u.miss, tx(u.miss.w), y0, dm < 100 and trunc(dm * 2.55) or nil)
elseif s.prev_state == 1 and dm < 100 then
blit(u.miss, tx(u.miss.w), y0, trunc(255 - dm * 2.55))
end
end
i = i + 1
end
end
-- Play/Chara (Don), centred at (character_x, character_y)
function Background:draw_chara(cy_override, c, s)
s = s or self
if s.balloon > 0 then return end
c = c or self.chara
local clk = trunc(self.clk)
local cx, cy = CFG.character_x, cy_override or CFG.character_y
if s.state == 1 then
blit_c((clk % 3600 < 1800) and c.chara_p1_miss_2 or c.chara_p1_miss_1, cx, cy)
return
end
local e = self.now - s.hop_ms
if e >= 100 and e <= 440 then
blit_c(c.chara_p1_5, cx, cy - 20 * K * math.sin(rad((e - 100) * 180 / 340)))
return
end
local n = clk % 14400
local t
if n >= 12600 then t = c.chara_p1_4
elseif n >= 10800 then t = c.chara_p1_3
elseif clk % 3600 < 1800 then t = c.chara_p1_1
else t = c.chara_p1_2 end
blit_c(t, cx, cy)
end
-- balloon-roll Don (dword_8F9C8C: 1..6 rolling, 9 burst, 10 fail; asm 0x41993B-0x419F39)
-- s / c: the player's state and art (self / self.chara, or self.p2 / self.chara2);
-- Y maps a 1P y to this player's (2P: mirrored about y 237.5 like the rest of the 2P HUD)
function Background:draw_balloon_chara(s, c, Y)
s = s or self
c = c or self.chara
Y = Y or function(y) return y end
if s.balloon <= 0 then return end
local e = self.now - s.balloon_ms
local st = s.balloon
-- the art is not flipped for 2P: the chara keeps its 1P offset from the (mirrored) balloon,
-- so its mouth stays on the balloon's neck
local bx, by = CFG.character_balloon_x, Y(CFG.balloon_y) - (CFG.balloon_y - CFG.character_balloon_y)
if st == 9 then
-- burst: chara_p1_balloon_5 rises 40 px over 120 ms, fades 240..340 ms;
-- balloon_8 at balloon_hit, opaque < 50 ms, fading 50..100 ms
local y = by - 40 * K * math.min(e, 120) / 120
local a = (e >= 240) and trunc(255 - 255 * (e - 240) / 100) or nil
blit_c(c.chara_p1_balloon_5, bx, y, a)
if e < 100 then
blit_c(c.balloon_8, CFG.balloon_hit_x, Y(CFG.balloon_hit_y),
(e >= 50) and trunc(255 - 255 * (e - 50) / 50) or nil)
end
return
elseif st == 10 then
-- fail: chara_p1_balloon_3 < 100 ms, chara_p1_balloon_4 after, fading 200..300 ms
local a = (e >= 200) and trunc(255 - 255 * (e - 200) / 100) or nil
blit_c((e < 100) and c.chara_p1_balloon_3 or c.chara_p1_balloon_4, bx, by, a)
return
end
if e >= 50 and st == 1 then st = 2; s.balloon = 2 end
if e < 50 or e >= 200 then
blit_c(c.chara_p1_balloon_1, bx, by)
blit_c(c["balloon_" .. st], CFG.balloon_x, Y(CFG.balloon_y))
else
blit_c(c.chara_p1_balloon_2, bx, by)
local b = (e < 140) and st or math.min(st + 1, 7)
blit_c(c["balloon_" .. b], CFG.balloon_hit_x, Y(CFG.balloon_hit_y))
end
self:draw_balloon_bubble(s, e, function(y) return Y(CFG.balloon_y) - (CFG.balloon_y - y) end)
end
-- hits left over the chara (taikonantoka draw_balloon_hitting): the bubble keeps its offset from the
-- balloon (not mirrored for 2P); the digits right to left, each hit popping them up from their
-- bottom edge (100 ms)
function Background:draw_balloon_bubble(s, e, Y)
local left = s.balloon_left
if not left or left < 0 then return end
blit_c(T("nk_bubble", "bubble"), CFG.bubble_x, Y(CFG.bubble_y))
local str = tostring(left)
local n = #str
local w = CFG.bubble_num_w
local sc = (e < 100) and (1 + 0.25 * (1 - e / 100)) or 1
for i = 1, n do
local t = T("nk_bubble", "digit_" .. str:sub(i, i))
if t then
local cx = CFG.bubble_x + (i - (n + 1) / 2) * w
local bottom = Y(CFG.bubble_num_y) + t.h / 2
local h = t.h * sc
tex.draw_texture(t.id, { x = cx - t.w / 2, y = bottom - h, y2 = h - t.h })
end
end
end
-- Play/Dancer: 5 slots, slot s visible from gauge level trunc(s * clear_line / 4)
function Background:draw_dancers()
local clk = self.clk
local P = trunc(DANCER.interval * BEAT)
local AP = trunc(DANCER.appear_interval * BEAT)
for s = 0, 4 do
local lvl = trunc(s * self.clear_line * 0.25)
if lvl <= self.gauge_int then
local typ = s -- dance_pattern = 2 (fixed positions)
local dx, dy = DANCER.x[s + 1], DANCER.y[typ + 1]
local T0 = self.level_t[lvl] or 0
if clk < T0 + AP then
local p = (clk - T0) / AP
if p >= 0 then
local fr = DANCER.appear_order[math.min(trunc(p * 20), 19) + 1]
local t = T("nk_dancer1", "appear_" .. (typ + 1) .. "_" .. fr)
local y = dy
if DANCER.appear_pattern[typ + 1] == 1 then
local v
if p <= 0.4 then v = 520 - 250 * math.sin(rad(90 * p / 0.4))
elseif p < 0.7 then v = 270 + 70 * math.sin(rad(90 * (p - 0.4) / 0.3)) end
if v then y = dy + v * K - 340 * K end
end
blit_c(t, dx, y)
if s == 2 and DANCER.appear_pattern[typ + 1] == 1 and p >= 0.5 then
local v
if p < 0.7 then v = 110 * math.sin(rad(90 + 450 * (p - 0.5)))
elseif p < 0.73 then v = 0
elseif p < 0.95 then v = 80 * math.sin(rad(627.27 * (p - 0.73)))
else v = 54 end
local a = (p < 0.6) and trunc(255 * (p - 0.5) * 10) or nil
blit_c(T("nk_dancer1", "shakushi_1"), DANCER.x[3], DANCER.y[3] - v * K, a)
end
end
else
local m = trunc(clk) % P
local k = 68 * m / P
local idx = math.min(trunc(k), 67)
local fr = DANCER.order[idx + 1]
if s == 2 then
local f0 = fr - 1
local sn = math.sin(rad(180 + 90 * k))
local sx, sy, name = DANCER.x[3], dy, nil
if f0 <= 2 then name = "shakushi_2"; sy = dy - 24 * K * math.max(0, sn)
elseif f0 == 3 then name = "shakushi_2"; sx = sx + 5 * K; sy = dy - 5 * K
elseif f0 == 4 then name = "shakushi_3"; sy = dy - 6 * K
elseif f0 == 5 then name = "shakushi_4"; sx = sx + 5 * K; sy = dy - 5 * K
elseif f0 <= 8 then
name = "shakushi_4"
if sn >= 0 and k >= 16 and k <= 29 then sy = dy - 24 * K * sn end
end
if name then blit_c(T("nk_dancer1", name), sx, sy) end
end
blit_c(T("nk_dancer1", "dance_" .. (typ + 1) .. "_" .. fr), dx, dy)
end
end
end
end
-- Play/Bg/stage: centred on stage_y
function Background:draw_stage()
if self.stage then blit(self.stage, 0, CFG.stage_y - self.stage.h / 2) end
end
-- Play/Bg/fiver: rises with the full soul gauge, bobs each beat, falls when it drops
function Background:draw_fiver()
local f = self.fiver
if not f then return end
local p = (self.clk % 3600) / 20
local h = f.h
local function bob() return 526 * K - trunc(46 * K * math.sin(rad(p))) - h end
local st = self.fiver_state
if self.is_full then
if st == 0 then
if p > 110 and p <= 180 then self.fiver_state = 1 end
elseif st == 1 then
if p <= 90 then
blit(f, 0, SH - trunc(h * math.sin(rad(p))))
if p >= 70 then self.fiver_state = 2 end
elseif p <= 110 then
blit(f, 0, bob()); self.fiver_state = 2
end
else
blit(f, 0, bob())
end
else
if st == 2 then
blit(f, 0, bob())
if p <= 90 then self.fiver_state = 1 end
elseif st == 1 then
if p <= 90 then
blit(f, 0, bob())
else
local y = 530 * K - trunc((h + 50 * K) * math.sin(rad(p)))
blit(f, 0, y)
if p >= 175 or y >= SH then self.fiver_state = 0 end
end
end
end
end
-- "chibi_<kind+1>_<frame>" strings, built once instead of per chibi per frame
local CHIBI_NAME = setmetatable({}, { __index = function(t, kind)
local names = setmetatable({}, { __index = function(n, frame)
local s = "chibi_" .. (kind + 1) .. "_" .. frame
n[frame] = s
return s
end })
t[kind] = names
return names
end })
-- Play/Chibi: one per Great/Good hit (Soul chibi on a miss), 6 beats of life
function Background:draw_chibis()
for _, c in ipairs(self.chibis) do
local set, kind = c.set, c.kind
local dt = self.clk - c.clk
local name, x, y
if dt < 1800 then
x = set.x + dt / 18 * K
if set.appear == 1 then
y = set.y - 35 * K * math.sin(rad(dt / 10))
name = CHIBI_NAME[kind][set.appear_frame]
else
y = set.y
name = CHIBI_NAME[kind][(dt < 600) and 1 or (dt < 1200) and 2 or 3]
end
else
x = set.x + 100 * K + (dt - 1800) / 27 * K
if set.xp[kind + 1] == 2 then x = x + 18 * K * math.sin(rad(dt / 20)) end
local yp = set.yp[kind + 1]
if yp == 0 then
if dt < 5900 then y = set.y - 16 * K * math.sin(rad((dt - 2300) / 20))
else y = set.y + 19 * K * math.sin(rad((dt - 5900) / 15)) end
elseif yp == 1 then
y = CHIBI_SETS[1].y
else
y = set.y + 9 * K * math.sin(rad(dt / 10))
end
local Pi = trunc(set.interval * BEAT)
local n = #set.order
local idx = math.floor(((trunc(dt) % Pi) * n) / Pi)
name = CHIBI_NAME[kind][set.order[idx + 1]]
end
local t = T(set.sub, name)
if t and x - t.w / 2 <= SW then blit_c(t, x, y) end
end
end
-- Play/Drapery: full-combo curtain, from last note + 1000 ms
function Background:draw_drapery()
if not (self.song_end_ms and self.full_combo) then return end
local d = self.now - self.song_end_ms
local d1, d2, d3 = T("nk_drapery", "drapery_1"), T("nk_drapery", "drapery_2"), T("nk_drapery", "drapery_3")
if not (d1 and d2 and d3) then return end
local E
if d < 800 then E = 150 + 490 * math.sin(rad(90 + 90 * d / 800))
elseif d < 950 then E = 150 + 20 * math.sin(rad(180 * (d - 800) / 150))
elseif d < 1050 then E = 150 + 10 * math.sin(rad(180 * (d - 950) / 100))
else E = 150 end
E = E * K
local x0 = d2.w - 28 * K
local F
if d < 1050 then F = x0
elseif d < 1400 then F = x0 * (1 - 0.8 * (d - 1050) / 350)
elseif d < 1500 then F = 0.2 * x0 * (1 - math.sin(rad(90 * (d - 1400) / 100)))
else F = 0 end
local S = CFG.note_y
local function cropped(t)
local l = trunc(F)
if l >= t.w then return end
tex.draw_texture(t.id, { x = d1.w + E - 2 * K, y = S - t.h / 2 + 0,
src = { x = l, y = 0, width = t.w - l, height = t.h }, x2 = -l })
end
cropped(d2)
blit(d1, E, S - d1.h / 2)
for i, pos in ipairs(FLOWERS) do
local tt = d - 1500 - 33.33 * (i - 1)
if tt >= 0 then
local fl = T("nk_drapery", "flower_" .. i)
if fl then
local sc = (tt < 80) and (1 + 0.3 * math.sin(rad(90 * tt / 80))) or 1
tex.draw_texture(fl.id, { x = pos[1] - fl.w / 2, y = pos[2] - fl.h / 2, center = true, scale = sc })
end
end
end
cropped(d3)
end
---------------------------------------------------------------------------------------------
-- 2P (skin_config game_p2_y 231): 1P HUD / 1P lane (116..220) / title bar (220..255) / 2P lane
-- (255..359) / 2P HUD (359..480) -- the 2P lane is the 1P lane mirrored about y 237.5 (y -> 475 - y).
-- The 2P HUD gets the blue upper background and the blue Don (mirrored position, not flipped); the
-- soul gauge / balloons are nk_hud's.
local P2_HUD_Y = 359
local P2_LANE_EDGE_H = 4
function Background:draw_p2_hud()
self:draw_upper(P2_HUD_Y * K, self.upper2, self.p2)
self:draw_symbol(2, self.p2, (P2_MIRROR - 64) * K) -- [YataiDON14] the 2P difficulty symbol
self:draw_chara((P2_MIRROR - 62) * K, self.chara2, self.p2) -- CFG.character_y is already x K: 62 = dispconf
tex.draw_rect(0, P2_HUD_Y * K, SW, P2_LANE_EDGE_H * K, 0, 0, 0, 255)
end
-- The black title band under the 1P lane (lane_background rows 109..145 in taikonantoka's art).
-- It is cut out of lane/lane_background.png and drawn here once, so the 2P lane (which reuses
-- lane_background) does not get a second band below it. 1P lane_background top = 92 + 18.
local TITLE_BAND_Y = 92 + 18 + 109
local SYMBOL_COURSE = { [0] = "easy", "normal", "hard", "oni", "edit" }
local SYMBOL_GLOW1 = { 0, 0, .5, 1, .918, .832, .75, .668, .582, .5, .418, .332, .25, .168, .082, 0 }
local SYMBOL_GLOW2 = { 0, 0, 0, 0, .5, 1, .801, .602, .398, .199, 0, 0, 0, 0, 0, 0 }
function Background:draw_symbol(pn, s, cy)
local c = type(NK_COURSE_BY_PLAYER) == "table" and NK_COURSE_BY_PLAYER[pn]
local name = SYMBOL_COURSE[c or 3] or "oni"
local base = T("nk_symbol", name .. "_1")
if not base then return end
local x, y = 98 * K, cy - base.h / 2
blit(base, x, y)
if s.symbol_hit_ms then
local f = trunc((self.now - s.symbol_hit_ms) * 60 / 1000) + 1
local a1, a2 = SYMBOL_GLOW1[f], SYMBOL_GLOW2[f]
if a1 and a1 > 0 then blit(T("nk_symbol", name .. "_2"), x, y, trunc(a1 * 255), "additive") end
if a2 and a2 > 0 then blit(T("nk_symbol", name .. "_3"), x, y, trunc(a2 * 255), "additive") end
end
end
function Background:draw_back()
self:draw_dancer_bg()
self:draw_upper()
self:draw_symbol(1, self, 64 * K) -- [YataiDON14] under the chara
self:draw_chara()
if not self.is_2p then self:draw_dancers() end
self:draw_stage()
if not self.is_2p then self:draw_fiver() end
if self.is_2p then self:draw_p2_hud() end
blit(T("nk_upper", "title_band"), 0, TITLE_BAND_Y * K)
if self.dan_rows then self:draw_dan_panel() end
end
function Background:draw_fore()
self:draw_chibis()
self:draw_balloon_chara()
if self.is_2p then
self:draw_balloon_chara(self.p2, self.chara2, function(y) return (P2_MIRROR + P2_BALLOON_DY) * K - y end)
end
self:draw_drapery()
-- The wipe is drawn by Scripts/result/result_transition.lua only: that layer is above the
-- engine's song title (GameScreen::draw_overlay), this one is below it (user: no title on
-- the transition).
end
-- Play/Load/bg_end.png (640x960): y = 480 - 0.96 d, d = ms since the BGM stopped,
-- -480 from 1000 ms on (asm 0x41C229); the bottom half then covers the screen until the
-- engine changes scene (result_transition.lua keeps drawing it above the HUD).
function Background:draw_end_wipe()
if not (self.wipe_ms and self.end_bg) then return end
local d = self.now - self.wipe_ms
blit(self.end_bg, 0, (d < 1000) and (SH - d * 0.96 * K) or -SH)
end
function Background:destroy()
TCACHE = {}
for _, s in ipairs(SUBSETS) do tex.unload_folder(SCR, s) end
end
-- HUD module (Graphics/game/MAPPING_hud.md, round 2)
do local ok, hud = pcall(require, "game.nk_hud"); if ok and type(hud) == "table" then hud.install(Background) end end