-- 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_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__" 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