Files
YataiDONNijiiro/Scripts/result/result_player.lua
TaikoZhongandClaude Fable 5.1 8ac1ac9f22 Result: play the score-rank sound with the rank plate
The original plays se_result `scorerank_NN_<seat>` (one sample for every rank)
when the plate appears; shipped as scorerank_{c,l,r} and played when the
ScoreRank state starts, seat chosen like the crown sound.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 15:26:32 +08:00

795 lines
44 KiB
Lua

-- YataiDON-HSS: Nijiiro arcade result board (lumen 000_default/result/result, script_lua/result/ResultMain.lua + ScoreBase.lua)
-- Layout (1080p px, 1P = left board; 2P = same board shifted +956 px = main.score_base_R vs score_base_L):
-- board frame main__shape313/317 @ (40,180)/(996,180) difficulty_mc tab @ score_base+(-596.6,-187.7)
-- option_icon_bg @ score_base+(-353,-167), option icons 43px pitch total score digits @ score_base+(-318,20) pitch 46
-- judge table: score_board_count_label @ score_base+(-90,93); 4-digit counters @ score_base+(249, -30/32/94/156/218) pitch 31
-- crown_mc @ score_base+(-212,165) best_score_mc @ score_base+(-316,-46) tamashii gauge (0.7 scale) @ main+(-466,-302)
-- donback @ main+(-464,244) msg_L_mc @ main+(-300,246) control_guide_L @ main+(-476,486)
-- Texture names are the parent's (the TexID enum is generated from PyTaikoGreen); see Graphics/result/MAPPING.md.
ResultPlayer = {}
ResultPlayer.__index = ResultPlayer
local GAUGE_SCALE = 0.7 -- arcade places tamashii_gage at 0.7 on the result board
local GAUGE_UNIT = 12 * GAUGE_SCALE -- 12 px per YataiDON gauge unit in the plate groove (87 units)
local OPTION_PITCH = 43
-- ROUND 15 — the whole result sequence is now the cabinet's own state machine, read out of
-- `script_lua/result/ResultMain.lua` (Main.m_State) and `ScoreBase.lua`. Every number below is
-- a frame count from those scripts; nothing here is tuned by eye. 1 frame = 1000/120 ms
-- (ROUND 24, r24-fpsaudit: Common.FPS is the cabinet's 120 fps script-logic clock, not 60 --
-- see ENGINE_BINDINGS.md; this file's ms comments below are corrected to match).
--
-- Fadein g_isFadefinish -> BGM primal_loop
-- WaitUpdateTamashiiGage timer_ > 100 (833.3 ms) then count_up_loop_c starts
-- UpdateTamashiiGage kGageSpeed_ = 7 frames per gauge unit, out of kMaxGage_ = 50
-- WaitUpdateScore timer_ > 100 (833.3 ms) then StartScoreDisplay
-- UpdateScore timer_ > 500 (4166.7 ms)
-- ScoreRank skipped outright when g_scoreRank == 0 -- YataiDON has no rank
-- Crown timer_ > 150 (1250.0 ms) then MoveResultAction
-- MoveResultAction MoveDonResult + MoveBg (StartSuccessEffect) + DisplayMessage
--
-- Two consequences the earlier rounds had wrong:
-- * the clear ("success") backdrop and the message bubble are NOT gauge/crown-settle events.
-- Both fire in MoveResultAction, at the END of the Crown state -- 10.8 s after the count-up
-- starts. We fired the gold backdrop the moment the gauge finished, roughly 12 s early.
-- * the gauge fills at 7 frames per arcade unit, i.e. 350 frames (2916.7 ms at 120 fps) for a
-- full bar. Our 50 ms per YataiDON unit x 87 units = 4350 ms, too slow at the corrected rate.
local F = 1000 / 120 -- ROUND 24 (r24-fpsaudit): Common.FPS is the cabinet's
-- 120 fps script-logic clock, not 60 -- see ENGINE_BINDINGS.md
local WAIT_GAUGE_MS = 100 * F -- 833.3 fade-in done -> the gauge starts filling
local GAUGE_MS_UNIT = 7 * F * (50 / 87) -- 33.53 ms per YataiDON unit == 7 f per arcade unit
local WAIT_SCORE_MS = 100 * F -- 833.3 gauge full -> StartScoreDisplay
local SCORE_TO_CROWN = 500 * F -- 4166.7 UpdateScore state length
local CROWN_TO_ACTION = 150 * F -- 1250.0 Crown state length -> MoveResultAction
-- ScoreRank state (between UpdateScore and Crown) when the score earned a rank: the
-- arcade result/result.nulm `score_rank_mc` (`start` f5 .. `end` f120 at 60 fps).
local RANK_STATE_MS = 120 * (1000 / 60) -- 2000.0
local ClipPlayer = require("game/clip_player")
-- score_rank_mc / score_rank_kiwami_mc placements inside the 1920x1080 result stage
local RANK_POS = { L = { 181.1, 500.7 }, R = { 1126.9, 501.0 } }
local KIWAMI_POS = { L = { 198.1, 505.7 }, R = { 1144.0, 506.0 } }
local RANK_SHAPES = { 74, 76, 78, 80, 82, 84, 86 } -- rank 1..7 plates inside score_rank
local RANK_THRESHOLDS = { 500000, 600000, 700000, 800000, 900000, 950000, 1000000 }
local function rank_of(score)
local r = 0
for i, t in ipairs(RANK_THRESHOLDS) do if score >= t then r = i end end
return r
end
-- ROUND 15 — the digit pop. Every arcade digit MC plays a scale keyframe when it lands on
-- `numN`; we drew the settled frame flat. Both curves are exported straight out of
-- result/result.nulm (ANIM_PIPELINE.md), so they are the cabinet's numbers, not a guess:
-- judge counters (sprite 167, digit4): scale 1.300 -> 1.000 LINEAR over 4 frames (66.7 ms)
-- score counters (sprite 245, digit7): a 19-frame squash-and-stretch --
-- 0.90 -> 1.40 (f0..6) -> 1.00 (f11) -> 0.90 (f15) -> 1.00 (f19), 316.7 ms
-- `center = true` scales about the digit cell's own centre, so a settled digit (scale 1)
-- lands on exactly the texture.json pixel it landed on before this change.
local _dg_ok, dg_anim = pcall(require, "anim/sampler")
if not _dg_ok then dg_anim = { safe = function()
local n = {}
n.at = function() return nil end; n.get = function() return nil end
n.label = function() return nil end; n.has = function() return false end
n.first = function() return 0 end; n.last = function() return 0 end
return n
end } end
local DGJ, DGJ_TRACK = dg_anim.safe("anim/result_digit_judge"), "#147@0"
local DGS, DGS_TRACK = dg_anim.safe("anim/result_digit_score"), "#225@0"
local DGJ_F0, DGJ_F1 = DGJ:first(), DGJ:last()
local DGS_F0, DGS_F1 = DGS:first(), DGS:last()
-- ROUND 53 (r53-anim-lumen-sweep): the success backdrop cross-fade, sampled from the
-- cabinet's own timeline. `Scripts/anim/result_bg_success.lua` is sprite 88 (`bg_1p_l`)
-- of `result/result_bg.nulm`: at the `success` label (f5) it places `crear_1P_l` at
-- alpha 0 and fades it to 1 over f5..f14 — 9 frames = 150 ms, not the 333 ms the
-- inherited PyTaikoGreen animation.json id 1 plays (R17 measured this ramp but only used
-- it to re-arm the Fuji clock; the fade itself stayed on the fan value). Fail-soft: with
-- the table missing, success_fade_alpha() returns nil and every caller falls back to the
-- engine animation's attribute (ROUND 8 rule).
local SBG = dg_anim.safe("anim/result_bg_success")
local SBG_F0 = SBG:label("success") or 5
local function success_fade_alpha(t0, now_ms)
if not t0 then return nil end
return SBG:get("crear_1P_l", SBG_F0 + (now_ms - t0) * 0.06, "a")
end
-- ROUND 53 (r53-anim-lumen-sweep): 魂 fire + rainbow gauge, per-frame from the cabinet's
-- own `enso_normal/enso/tamashiigage/tamashii_gage.nulm`.
-- * `anim/fever_fire` = sprite 58 (`fire` MC): fever_start f1..9 (alpha fade-in + growth),
-- fever loop f10..33 (8 flame cels x 3 frames = 400 ms), fever_end f34..44. The flame
-- cels are the display-list leaves #40/#44/../#56 -> texture frames 0..7.
-- * The disc blink (#35 bright / #42 plain at depth 1) and the sheen (#39 at depth 2,
-- alpha 0.6016 -- the old hand value was 0.5) are INTERMITTENT display-list presence,
-- which the exporter flattens into one continuous row span (known pipeline limitation,
-- recorded in ANIM_COVERAGE.md). The parity rule below -- bright disc + sheen on for
-- (frame - fever) % 4 < 2 -- was verified against the dense dump on every frame of the
-- f10..f33 loop.
-- * `anim/gauge_rainbow` = sprite 31 (41 f): each rainbow cel holds alpha 1 while the next
-- fades in linearly over 5 frames = 83.33 ms/step, 666.7 ms full cycle. The old hand
-- period was 75 ms.
-- The clip's fever_start GROWTH channel (sx 0.14 -> 0.80, bottom-anchored via its ty) is
-- in the table but deliberately NOT applied yet: draw_texture scales around the frame's
-- top-left here and the anchor compensation is unverified -- same class as best_score's
-- unapplied sy (C-res-4). Fail-soft throughout: with a table missing these return nil and
-- the callers fall back to the pre-R53 engine-animation ladder.
local FIRE = dg_anim.safe("anim/fever_fire")
local FIRE_LEAVES = { { "#40@0", 0 }, { "#44@0", 1 }, { "#46@0", 2 }, { "#48@0", 3 },
{ "#50@0", 4 }, { "#52@0", 5 }, { "#54@0", 6 }, { "#56@0", 7 } }
local FIRE_START = FIRE:label("fever_start") or 1
local FIRE_LOOP = FIRE:label("fever") or 10
local FIRE_END = FIRE:label("fever_end") or 34
local FIRE_SHEEN_A = FIRE:get("#39@2", (FIRE_LOOP + FIRE_END) / 2, "a") or 0.5
-- flame cel, alpha and sheen alpha at (now - t0) ms since the gauge maxed; nil = no table
local function fire_state(t0, now_ms)
if not t0 or t0 < 0 or not FIRE.tracks then return nil end
local f = FIRE_START + (now_ms - t0) * 0.06
if f >= FIRE_LOOP then
f = FIRE_LOOP + math.fmod(f - FIRE_LOOP, FIRE_END - FIRE_LOOP)
end
local fi = math.floor(f)
for i = 1, #FIRE_LEAVES do
local rows = FIRE.tracks[FIRE_LEAVES[i][1]]
if rows and #rows > 0 and fi >= rows[1][1] and fi <= rows[#rows][1] then
local a = FIRE:get(FIRE_LEAVES[i][1], f, "a")
if a then
local sheen
if fi < FIRE_LOOP then
sheen = FIRE:get("#39@2", f, "a") or 0.0 -- start ramp 0 -> 0.6016
else
sheen = ((fi - FIRE_LOOP) % 4 < 2) and FIRE_SHEEN_A or 0.0
end
return FIRE_LEAVES[i][2], a, sheen
end
end
end
return nil
end
local RBW = dg_anim.safe("anim/gauge_rainbow")
local RBW_LEAVES = { { "#15@0", 0 }, { "#17@1", 1 }, { "#19@2", 2 }, { "#21@3", 3 },
{ "#23@4", 4 }, { "#25@5", 5 }, { "#27@6", 6 }, { "#29@7", 7 },
{ "#15@8", 0 } }
local RBW_LEN = RBW:last() - RBW:first() -- 40 frames = 666.7 ms cycle
local function pop_scale(clip, track, f0, f1, land_ms, now_ms)
if not land_ms then return 1.0 end
local f = f0 + (now_ms - land_ms) * 0.06
if f > f1 then return 1.0 end
return clip:get(track, f, "sx") or 1.0
end
function ResultPlayer.new(player_num, is_2p, has_2p,
gauge_length, difficulty, selected_difficulty,
crown_type, score_diff, score_method,
mod_display, mod_inverse, mod_random, mod_speed)
local self = setmetatable({}, ResultPlayer)
tex.load_animations("result")
self.player_num = player_num
self.is_2p = is_2p
self.has_2p = has_2p
self.gauge_length = gauge_length
self.difficulty = difficulty
self.selected_difficulty = selected_difficulty
self.crown_type = crown_type
self.score_diff = score_diff
self.score_method = score_method
self.mod_display = mod_display
self.mod_inverse = mod_inverse
self.mod_random = mod_random
self.mod_speed = mod_speed
-- ROUND 48 (r48-soulgauge-chn-port): the reference build norma semantics replace the
-- Green-era clear_start {52,60,69} table. The clear line is TamashiiNorm
-- (easy 6000 / normal 7000 / HARD 7000 / oni 8000 of the raw 0..10000
-- soul, corpus-constant per tier) — hard shares normal's norma and thus
-- the segment-35 ("_normal") clear-zone art, not Green's hard-at-79%
-- layout. gauge_length arrives as the 87-unit display value
-- (soul / 10000 * 87), so the raw soul is recovered for the test.
local ART = { [0] = 0, [1] = 1, [2] = 1, [3] = 2 }
local NORMA = { [0] = 6000, [1] = 7000, [2] = 7000, [3] = 8000 }
local d = math.min(math.max(difficulty, 0), 3)
self.art_index = ART[d]
self.norma = NORMA[d]
if gauge_length == 87 then
self.gauge_state = 2
elseif gauge_length / 87.0 * 10000.0 >= self.norma - 0.5 then
self.gauge_state = 1
else
self.gauge_state = 0
end
if self.art_index >= 2 then
self.diff_str = "_hard"
elseif self.art_index >= 1 then
self.diff_str = "_normal"
else
self.diff_str = "_easy"
end
local p = tostring(player_num) .. "p"
self.player_str = p
self.score, self.good, self.ok, self.bad, self.max_combo, self.total_drumroll = "", "", "", "", "", ""
self.fade_in_finished_flag = false
self.gauge_started = false
self.gauge_start_ms = -1
self.gauge_shown = 0 -- units currently filled (animates up to gauge_length)
self.gauge_done = false
self.score_delay = nil
self.fade_in_started = false -- read by C++: starts the score roll
self.rainbow_start_ms = -1.0
self.rainbow_frac = 0.0
self.crown_at = nil
self.msg_at = nil -- MoveResultAction: message bubble + success backdrop + Don pose
self.skipped = false
-- ROUND 19: read by C++ every frame (`ResultPlayer::reveal_end_ms`, ENGINE_BINDINGS
-- ROUND 19). 0/nil = the reveal is still running, so a drum press is a SKIP; once it
-- carries the MoveResultAction timestamp a press is an ADVANCE instead. That is the
-- cabinet's own boundary: MoveResultAction is the last thing ResultMain's `Crown`
-- state does, and every state after it (Medal / WaitEffectEnd / WaitNextScene)
-- reveals nothing and ignores isEffectSkip_.
self.reveal_end_ms = 0
-- ROUND 15: the engine's ResultPlayer reads these three fields once, right after the
-- script loads (src/objects/result/player.cpp). `count_up_instant` turns off the
-- per-digit ScoreAnimator roll: on the cabinet, ScoreBase.lua only ever calls
-- SkipCounter(), so every row LANDS on its final value -- no digit ever spins. The two
-- cadences are ScoreBase.lua's own `timer_ > 50` / `timer_ > 100`.
self.count_up_instant = true
self.count_up_row_ms = 50 * F
self.count_up_score_ms = 100 * F
-- ROUND 52 (r52-lua-divergence-fixes): the arcade count-up SE names, read
-- by the same engine block (result/player.cpp update_score_animation).
-- ScoreBase.lua:191 plays count_stop_c per landed row and :174/:198
-- don_big on the total score, BOTH gated `this.player_ == 1` (1P-only, in
-- duet too); the engine used to hardcode "num_up"/"don" for every seat.
self.count_up_row_sound = "count_stop"
self.count_up_score_sound = "don_big"
self.count_up_sound_1p_only = true
-- ROUND 52: seat suffixes for the arcade result cues.
-- count_up_loop / atmos_clear: _c solo, _l/_r in duet
-- (ResultMain.lua:459-464 / :1153-1162)
-- achieve_tamashii: _l for 1P even solo, _r for 2P
-- (ResultMain.lua:1086-1088: idx==1 && entryType 0|2 -> _l)
self.seat_c = has_2p and (is_2p and "_r" or "_l") or "_c"
self.seat_lr = is_2p and "_r" or "_l"
self.count_loop_on = false
self.achieve_played = false
self.skip_don_big_played = false
-- per-row landing times, for the arcade digit pop (see draw_score_info / draw_total_score)
self.row_land_ms = {}
self.score_land_ms = nil
self.crown_obj = nil
self.crown_msg_obj = nil
self.high_score_obj = nil
self.fade_in_bottom = tex.get_animation(1, "result") -- success bg fade -- ROUND 53:
-- fallback only; the drawn alpha is sampled from anim/result_bg_success (150 ms)
self.tamashii_fire = tex.get_animation(20, "result")
-- background success overlay (1P full / 2P halves)
self.t_success = tex.load_texture(has_2p and "result/background/footer_2p" or "result/background/footer_1p")
-- board
self.t_board = tex.load_texture("result/score/overlay") -- frames: 0 = left board, 1 = right board
self.t_difficulty = tex.load_texture("result/score/difficulty") -- 5 frames + course name
self.t_opt_bg = tex.load_texture("result/score/mod_sanbai") -- option pill, frame = icon count-1
self.t_mod_doron = tex.load_texture("result/score/mod_doron")
self.t_mod_abekobe = tex.load_texture("result/score/mod_abekobe")
self.t_mod_kimagure = tex.load_texture("result/score/mod_kimagure")
self.t_mod_detarame = tex.load_texture("result/score/mod_detarame")
self.t_mod_speed = tex.load_texture("result/score/mod_baisaku") -- 14 frames x1.1 .. x4
self.t_judge_labels = tex.load_texture("result/score/max_combo") -- whole 良/可/不可/連打数/最大コンボ数 block (ja/en)
self.t_judge_num = tex.load_texture("result/score/judge_num")
self.t_score_lbl = tex.load_texture("result/score/score")
self.t_score_shinuchi_lbl = tex.load_texture("result/score/score_shinuchi")
self.t_score_num = tex.load_texture("result/score/score_num")
self.t_donback = tex.load_texture("result/bottom/chara_0") -- frames: 0 = 1P red, 1 = 2P blue
-- NO control guide: the arcade's control_guide_L/R (common/control_guide_xs) is created by
-- ResultReward:InitControlGuide with SetVisible(false) and is only turned on inside the
-- reward-choice phase (StateId.RewardChoicePanel_, SetupMenFuchiKR("select","decide")), then
-- FadeOut() once the choice is made. A plain result screen -- which is all YataiDON has --
-- never shows it. `result/bottom/chara_center` is an 8x8 blank. See MAPPING.md ROUND 6.
-- gauge (HUD bakes, drawn at 0.7)
self.t_gauge_unfilled = tex.load_texture("result/gauge/" .. p .. "_unfilled" .. self.diff_str)
self.t_gauge_bar = tex.load_texture("result/gauge/" .. p .. "_bar")
self.t_gauge_rainbow = tex.load_texture("result/gauge/rainbow" .. self.diff_str)
self.t_gauge_overlay = tex.load_texture("result/gauge/overlay" .. self.diff_str)
self.t_gauge_clear = tex.load_texture("result/gauge/clear")
self.t_gauge_clear_dark = tex.load_texture("result/gauge/clear_dark")
self.t_gauge_bar_clear_transition = tex.load_texture("result/gauge/bar_clear_transition")
self.t_gauge_bar_clear_top = tex.load_texture("result/gauge/bar_clear_top")
self.t_gauge_bar_clear_bottom = tex.load_texture("result/gauge/bar_clear_bottom")
self.t_gauge_tamashii = tex.load_texture("result/gauge/tamashii")
self.t_gauge_tamashii_dark = tex.load_texture("result/gauge/tamashii_dark")
self.t_gauge_tamashii_fire = tex.load_texture("result/gauge/tamashii_fire")
self.t_gauge_tamashii_overlay = tex.load_texture("result/gauge/tamashii_overlay")
-- digit pitches are fixed by the arcade digit MCs (digit4 pitch 31, digit7 pitch 46), not skin_config
self.sc_score_info_margin = 31
self.sc_result_score_margin = 46
-- option icon list (arcade order: speed, doron, abekobe, random)
self.options = {}
if mod_speed > 10 then
local speeds = { 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 25, 30, 35, 40 }
local best, bi = 1e9, 1
for i, s in ipairs(speeds) do
if math.abs(s - mod_speed) < best then best, bi = math.abs(s - mod_speed), i end
end
table.insert(self.options, { self.t_mod_speed, bi - 1 })
end
if mod_display then table.insert(self.options, { self.t_mod_doron, 0 }) end
if mod_inverse then table.insert(self.options, { self.t_mod_abekobe, 0 }) end
if mod_random == 1 then table.insert(self.options, { self.t_mod_kimagure, 0 })
elseif mod_random == 2 then table.insert(self.options, { self.t_mod_detarame, 0 }) end
return self
end
function ResultPlayer:update(current_ms, fade_in_finished,
score, good, ok, bad, max_combo, total_drumroll, is_skipped)
self.fade_in_finished_flag = fade_in_finished
self.now_ms = current_ms
-- ROUND 15: record when each row first shows a value, so the digits can play the
-- cabinet's pop. With `count_up_instant` a row goes "" -> final in one frame, which
-- is exactly the cabinet's SkipCounter().
local prev = { self.good, self.ok, self.bad, self.total_drumroll, self.max_combo }
self.score, self.good, self.ok, self.bad = score, good, ok, bad
self.max_combo, self.total_drumroll = max_combo, total_drumroll
local now_rows = { good, ok, bad, total_drumroll, max_combo }
for i = 1, 5 do
if prev[i] == "" and now_rows[i] ~= "" and not self.row_land_ms[i] then
self.row_land_ms[i] = current_ms
end
end
if self.score ~= "" and not self.score_land_ms then self.score_land_ms = current_ms end
-- isEffectSkip_ (ResultMain): every remaining state collapses at once.
if is_skipped and not self.skipped then
self.skipped = true
self.gauge_shown = math.floor(self.gauge_length)
-- ROUND 52: skip cuts the count-up loop (UpdateTamashiiGage's
-- isEffectSkip branch does TSound:StopSlot, ResultMain.lua:1069) and
-- lands everything on one don_big (ScoreBase UpdateScoreDisplay's
-- skip branch, :174 -- 1P-only; the per-row count_stop_c never fires).
if self.count_loop_on then
self.count_loop_on = false
if audio.stop_sound then audio.stop_sound("count_up_loop" .. self.seat_c) end
end
if self.player_num == 1 and not self.skip_don_big_played then
self.skip_don_big_played = true
audio.play_sound("don_big", "sound")
end
end
if fade_in_finished and not self.gauge_started then
self.gauge_started = true
self.gauge_start_ms = current_ms
self.tamashii_fire:start()
end
if self.gauge_started then
self.tamashii_fire:update(current_ms)
local target = math.floor(self.gauge_length)
-- WaitUpdateTamashiiGage: 100 frames of nothing, then 7 frames per arcade unit
local elapsed = current_ms - self.gauge_start_ms - WAIT_GAUGE_MS
if self.skipped then
self.gauge_shown = target
elseif elapsed > 0 then
-- ROUND 52: WaitUpdateTamashiiGage ends here -- start the arcade
-- count-up loop (ResultMain.lua:459-464, PlaySlot count_up_loop_*;
-- looped by the new engine binding, cut below when the fill lands).
if not self.count_loop_on and not self.gauge_done then
self.count_loop_on = true
audio.play_sound("count_up_loop" .. self.seat_c, "sound", true)
end
self.gauge_shown = math.min(target, math.floor(elapsed / GAUGE_MS_UNIT))
-- ROUND 52: the fill reaching the rainbow top plays
-- achieve_tamashii (ResultMain.lua:1086-1088; non-skip path only).
if not self.achieve_played and self.gauge_state == 2 and self.gauge_shown >= 87 then
self.achieve_played = true
audio.play_sound("achieve_tamashii" .. self.seat_lr, "sound")
end
end
-- ROUND 49 (r49-lua-chn-crosscheck): completion may only be declared after the
-- WaitUpdateTamashiiGage dwell has actually elapsed (ResultMain.lua:457 `timer_ > 100`
-- runs UNCONDITIONALLY, even when the gauge has nothing to fill). Without the
-- `elapsed > 0` arm, a total-miss run (gauge_length == 0) satisfied
-- `gauge_shown >= target` on the very first gauge frame and armed score_delay
-- 833 ms early, skipping the state the cabinet always sits through.
if self.gauge_shown >= target and not self.gauge_done
and (self.skipped or elapsed > 0) then
self.gauge_done = true
self.score_delay = current_ms + (self.skipped and 0 or WAIT_SCORE_MS)
-- ROUND 52: StopSlot the moment the fill lands on its target
-- (ResultMain.lua:1094 `current == g_tamashiiGage` -> StopSlot).
if self.count_loop_on then
self.count_loop_on = false
if audio.stop_sound then audio.stop_sound("count_up_loop" .. self.seat_c) end
end
end
if self.gauge_state == 2 and self.gauge_shown >= 87 then
if self.rainbow_start_ms < 0 then self.rainbow_start_ms = current_ms end
self.rainbow_frac = math.fmod((current_ms - self.rainbow_start_ms) / 75.0, 8.0)
end
end
self.fade_in_bottom:update(current_ms)
-- ROUND 11: publish the success cross-fade so ResultBackground / ResultFadeIn can swap the
-- live backdrop layers (gold sky, cyan pulsing Fuji, clear cloud bars) under the fireworks
-- overlay this same animation fades in. 1P only -- the arcade 2P backdrop has no clouds.
if not self.has_2p then
-- ROUND 53: sampled cabinet ramp (150 ms, f5..f14) wins; engine anim 1 (333 ms) is
-- the fail-soft fallback.
local sfa = success_fade_alpha(self.success_fade_t0, current_ms)
or self.fade_in_bottom.attribute
__hss_result_bg_clear = (self.gauge_state >= 1) and sfa or 0.0
end
if self.score_delay ~= nil and current_ms > self.score_delay and not self.fade_in_started then
self.fade_in_started = true
-- UpdateScore (500 f). ScoreRank is skipped outright -- YataiDON has no 粋/雅/極 rank,
-- and ResultMain skips that whole state when g_scoreRank == 0. Then the Crown state:
-- the crown flies in at its start, MoveResultAction fires 150 f later. With no crown
-- (a failed run) the Crown state is skipped too, so MoveResultAction is immediate.
self.crown_at = current_ms + SCORE_TO_CROWN
self.msg_at = self.crown_at + ((self.gauge_state >= 1) and CROWN_TO_ACTION or 0)
end
if self.skipped and self.fade_in_started then
self.rank_obj = nil
self.crown_at = math.min(self.crown_at or current_ms, current_ms)
self.msg_at = math.min(self.msg_at or current_ms, current_ms)
end
-- ScoreRank state (arcade: UpdateScore -> ScoreRank -> Crown), entered when the
-- score would otherwise hand over to the crown: the total is only known by then.
-- Skipped at rank 0 and on a skipped run, as the arcade skips the state.
if self.crown_at and current_ms >= self.crown_at and not self.rank_done then
self.rank_done = true
local rank = (not self.skipped) and rank_of(math.floor(tonumber(self.score) or 0)) or 0
if rank > 0 then
local side = self.is_2p and "R" or "L"
local pos = (rank == 7) and KIWAMI_POS[side] or RANK_POS[side]
local clip = (rank == 7) and "rank_result_kiwami" or "rank_result"
self.rank_obj = ClipPlayer.new("game/ending_anim/" .. clip, "game", clip .. "_anim", pos[1], pos[2])
if self.rank_obj then
self.rank_obj.hold_last = true
if rank < 7 then self.rank_obj.shape_map = { [RANK_SHAPES[1]] = RANK_SHAPES[rank] } end
self.rank_obj:start(current_ms)
-- se_result `scorerank_NN_<seat>`: one sample for every rank, seat like the crown
audio.play_sound("scorerank" .. (self.has_2p and (self.is_2p and "_r" or "_l") or "_c"), "sound")
self.crown_at = current_ms + RANK_STATE_MS
if self.msg_at then self.msg_at = self.msg_at + RANK_STATE_MS end
end
end
end
-- Crown state entry: StartCrownDisplay (crownMc_ GotoAndPlay "start" + crown_<colour>_c)
if self.crown_at and current_ms >= self.crown_at
and self.gauge_state >= 1 and not self.crown_obj then
-- ROUND 52: has_2p passed through so the crown SE picks its arcade
-- seat suffix (_c solo, _l/_r duet -- ScoreBase.lua:322-342).
self.crown_obj = ResultCrown.new(self.crown_type, self.is_2p, self.has_2p)
if self.crown_type == 1 or self.crown_type == 2 then
audio.play_sound("full_combo_voice_" .. tostring(self.player_num) .. "p", "voice")
end
end
if self.crown_obj then self.crown_obj:update(current_ms) end
-- MoveResultAction: DisplayMessage + MoveBg(StartSuccessEffect) on the same beat.
-- The arcade message state is chosen from the SOUL GAUGE, not the crown:
-- perfect = gauge at max, success = cleared, near = failed but gauge > 30 % of max,
-- miss = everything else (ResultMain.DisplayMessage).
if self.msg_at and current_ms >= self.msg_at and not self.crown_msg_obj then
local frame
if self.gauge_state == 2 then frame = 3 -- perfect
elseif self.gauge_state >= 1 then frame = 2 -- success
elseif self.gauge_length > 87 * 0.3 then frame = 1 -- near
else frame = 0 end -- miss
self.crown_msg_obj = ResultCrownMessage.new(frame, self.is_2p)
local voices = { [0] = "max_fail_voice_", [1] = "fail_voice_",
[2] = "clear_voice_", [3] = "max_clear_voice_" }
audio.play_sound(voices[frame] .. tostring(self.player_num) .. "p", "voice")
if self.gauge_state >= 1 then
self.fade_in_bottom:start() -- MoveBg: success background
-- ROUND 53: arm the sampled f5..f14 cross-fade on the same beat
self.success_fade_t0 = current_ms
-- ROUND 52: the cleared-run ambience rides the same
-- MoveResultAction beat as the glad voice (ResultMain.lua
-- MoveDonResult :1153-1162 -- atmos_clear_c solo, _l/_r duet;
-- a failed run plays only the sad voice, no atmos).
audio.play_sound("atmos_clear" .. self.seat_c, "sound")
end
end
if self.crown_msg_obj then self.crown_msg_obj:update(current_ms) end
-- ROUND 19: MoveResultAction has fired -> the reveal is over. Publish the beat
-- itself (not `current_ms`), so the two cabinet dwells C++ adds on top of it are
-- measured from the same instant the arcade measures them from.
if self.reveal_end_ms == 0 and self.msg_at and current_ms >= self.msg_at then
self.reveal_end_ms = self.msg_at
end
-- DisplayHiscoreEffect runs inside the same branch that lands the total score.
if not self.high_score_obj and self.score_diff > 0 and self.score ~= "" then
self.high_score_obj = HighScoreIndicator.new(self.score_diff, self.is_2p)
end
if self.high_score_obj then self.high_score_obj:update(current_ms) end
end
function ResultPlayer:draw()
local p_idx = self.is_2p and 1 or 0
-- success background (ResultBg "success") — ROUND 53: sampled f5..f14 ramp, engine
-- anim 1 as fail-soft fallback (see success_fade_alpha above).
local success_a = success_fade_alpha(self.success_fade_t0, tex.get_current_ms())
or self.fade_in_bottom.attribute
if self.gauge_state >= 1 and success_a > 0 and self.t_success then
tex.draw_texture(self.t_success, { frame = self.has_2p and p_idx or 0, index = p_idx, fade = success_a })
end
-- board frame (opaque white body), then the red/blue donback panel on top of it, then the difficulty tab
if self.t_board then tex.draw_texture(self.t_board, { frame = p_idx, index = p_idx }) end
if self.t_donback then tex.draw_texture(self.t_donback, { frame = p_idx, index = p_idx }) end
if self.t_difficulty then tex.draw_texture(self.t_difficulty, { frame = self.selected_difficulty, index = p_idx }) end
-- option icons
if #self.options > 0 then
if self.t_opt_bg then tex.draw_texture(self.t_opt_bg, { frame = #self.options - 1, index = p_idx }) end
for i, o in ipairs(self.options) do
if o[1] then tex.draw_texture(o[1], { frame = o[2], x = (i - 1) * OPTION_PITCH, index = p_idx }) end
end
end
-- judge table labels
if self.t_judge_labels then tex.draw_texture(self.t_judge_labels, { index = p_idx }) end
self:draw_score_info()
self:draw_total_score()
if self.high_score_obj then self.high_score_obj:draw() end
if self.rank_obj then self.rank_obj:draw(tex.get_current_ms()) end
if self.crown_obj then self.crown_obj:draw() end
if self.crown_msg_obj then self.crown_msg_obj:draw() end
end
-- ROUND 9 (engine binding `ResultPlayer:chara_pos` -> {x, y, scale}, see
-- ENGINE_BINDINGS.md). Two arcade facts the engine used to make unreachable:
-- * the result Don is placed at scale 1.12 (don1p placeholder (237,773));
-- Chara3D's own scale was a fixed 650 with no multiplier.
-- * don_R sits at (1682,787), the mirror of don_L across the screen centre.
-- ResultPlayer::draw only ever added screen_height/2 for 2P, which put the
-- 2P Don 540 px below the 1P one - off the bottom of the screen.
-- Chara3D's visible centre is (x,y) + (5,-130)*scale and the feet land another
-- 125*scale below it, so holding the round-6 tuned centre (210, 805) / feet 935
-- at scale 1.12 gives (204, 941).
local CHARA_SCALE = 1.12
local CHARA_X = 204
local CHARA_Y = 941
local CHARA_X_2P = 1649 -- arcade don_R 1682 minus the same -27 the 1P value carries
function ResultPlayer:chara_pos()
return { self.is_2p and CHARA_X_2P or CHARA_X, CHARA_Y, CHARA_SCALE }
end
-- ROUND 17 (engine binding `ResultPlayer:nameplate_pos` -> {x, y, fade}, see
-- ENGINE_BINDINGS.md), the exact mirror of `chara_pos` above.
--
-- `result.nulm --sprite 319 --range 25,25` (`duet`) measured this round:
-- name_plate_L tx -754.0 ty +430 alpha 1
-- name_plate_R tx +753.2 ty +430 alpha 1
-- and at `use_left` (frame 5) only L is visible, at `use_right` (frame 15) only R
-- is -- so the cabinet's rule is SEAT-fixed: left board = left plate, right board
-- = right plate, both in duet. `Result2PScreen` builds player_1 on the left seat
-- and player_2 on the right, so this needs no join-order logic at all.
--
-- screen = (960,540) + (tx,ty) => L (206,970) R (1713.2,970)
-- our plate canvas is 408x96 with the MC-280 registration at canvas (204,48), so
-- the draw origin is centre - (204,48):
-- L (206-204, 970-48) = (2, 922) == skin_config `result_nameplate`
-- R (1713.2-204, 922) = (1509.2, 922) -> 1509, whole-pixel like every other
-- value in this skin (the plate art and its text are drawn 1:1, so a .2
-- destination would blur both -- ROUND 11 text-sharpness note).
local NAMEPLATE_X = 2
local NAMEPLATE_Y = 922
local NAMEPLATE_X_2P = 1509
function ResultPlayer:nameplate_pos()
return { self.is_2p and NAMEPLATE_X_2P or NAMEPLATE_X, NAMEPLATE_Y }
end
function ResultPlayer:draw_gauge()
if not self.gauge_started then return end
local p_idx = self.is_2p and 1 or 0
local scale = GAUGE_SCALE
-- ROUND 26 (r26-resultgaugebox): the gauge's own box/frame used to cross-fade in over
-- animation 17 (366 ms) starting only once WaitUpdateTamashiiGage begins, so it visibly
-- lagged behind the score board -- which pops to full opacity the instant Fadein's wipe
-- clears, with no fade of its own. Nothing in ResultMain.lua/ScoreBase.lua's documented
-- state machine (see the ROUND 15 comment above) puts a box-opacity ramp anywhere in
-- Fadein -> WaitUpdateTamashiiGage; the only delay that state actually has is on the FILL
-- (gauge_shown / GAUGE_MS_UNIT below). This animation was inherited unaudited from
-- PyTaikoGreen (whose own player.lua still runs it) and has no cabinet counterpart, so the
-- box itself now snaps to full opacity together with the rest of the board.
local base_fade = 1.0
local gl_int = self.gauge_shown
-- ROUND 48: the clear-zone position follows the norma art tier
-- (hard = normal's segment-35 zone), see the init block above.
local clear_pts = { 52, 60, 69 }
local clear_pt = clear_pts[self.art_index + 1]
local u = GAUGE_UNIT
if self.t_gauge_unfilled then
tex.draw_texture(self.t_gauge_unfilled, { scale = scale, fade = base_fade, index = p_idx })
end
if self.gauge_state == 2 and gl_int >= 87 then
-- ROUND 53: rainbow cycle sampled from anim/gauge_rainbow (sprite 31): 83.33 ms per
-- step, base cel at 1 with the next fading in over it — the cabinet's own display
-- list, drawn leaf by leaf in depth order. Fallback = pre-R53 75 ms hand cycle.
local drew_rainbow = false
if self.t_gauge_rainbow and RBW.tracks and RBW_LEN > 0 and self.rainbow_start_ms >= 0 then
local f = math.fmod((tex.get_current_ms() - self.rainbow_start_ms) * 0.06, RBW_LEN)
local fi = math.floor(f)
for i = 1, #RBW_LEAVES do
local rows = RBW.tracks[RBW_LEAVES[i][1]]
if rows and #rows > 0 and fi >= rows[1][1] and fi <= rows[#rows][1] then
local a = RBW:get(RBW_LEAVES[i][1], f, "a") or 0.0
if a > 0.0 then
tex.draw_texture(self.t_gauge_rainbow,
{ frame = RBW_LEAVES[i][2], scale = scale, fade = base_fade * a, index = p_idx })
drew_rainbow = true
end
end
end
end
if not drew_rainbow and self.t_gauge_rainbow then
local fa = math.floor(self.rainbow_frac) % 8
local fb = (fa + 1) % 8
local t = self.rainbow_frac - math.floor(self.rainbow_frac)
tex.draw_texture(self.t_gauge_rainbow, { frame = fa, scale = scale, fade = base_fade, index = p_idx })
tex.draw_texture(self.t_gauge_rainbow, { frame = fb, scale = scale, fade = base_fade * t, index = p_idx })
end
else
if gl_int > 0 and self.t_gauge_bar then
local n = math.min(gl_int, clear_pt - 1)
tex.draw_texture(self.t_gauge_bar, { scale = scale, x2 = (n - 1) * u, fade = base_fade, index = p_idx })
end
if gl_int >= clear_pt - 1 and self.t_gauge_bar_clear_transition then
tex.draw_texture(self.t_gauge_bar_clear_transition,
{ scale = scale, x = (clear_pt - 1) * u, fade = base_fade, index = p_idx })
end
if gl_int > clear_pt then
local extra = (gl_int - clear_pt - 1) * u
if self.t_gauge_bar_clear_top then
tex.draw_texture(self.t_gauge_bar_clear_top,
{ scale = scale, x = clear_pt * u, x2 = extra, fade = base_fade, index = p_idx })
end
if self.t_gauge_bar_clear_bottom then
tex.draw_texture(self.t_gauge_bar_clear_bottom,
{ scale = scale, x = clear_pt * u, x2 = extra, fade = base_fade, index = p_idx })
end
end
end
if self.t_gauge_overlay then
tex.draw_texture(self.t_gauge_overlay, { scale = scale, fade = math.min(0.15, base_fade), index = p_idx })
end
local clear_idx = self.art_index + p_idx * 3
-- ROUND 48: a failed run never lights the クリア caption, even when the
-- final fill pixel-touches the clear zone (the reference build lights it on
-- IsNormaClear, i.e. gauge_state >= 1).
if gl_int >= clear_pt - 1 and self.gauge_state >= 1 then
if self.t_gauge_clear then
tex.draw_texture(self.t_gauge_clear, { scale = scale, fade = base_fade, index = clear_idx })
end
-- ROUND 53: flame cel + fade-in and the sheen duty cycle now come from
-- anim/fever_fire (sprite 58); see the fire_state block up top. ROUND 17's anchor
-- findings stand: the flame holds local (+43,-43) scale **0.800** through the
-- `fever` loop, which is why the drawn scale stays a constant 0.8 here.
local fire_sheen = nil
if self.gauge_state == 2 and gl_int >= 87 and self.t_gauge_tamashii_fire then
local ff, fa2, sheen_a = fire_state(self.rainbow_start_ms, tex.get_current_ms())
if ff then
fire_sheen = sheen_a
tex.draw_texture(self.t_gauge_tamashii_fire,
{ frame = ff, scale = 0.8 * scale, fade = fa2 * base_fade, index = p_idx })
else
-- fail-soft: pre-R53 engine ladder (animation 20, 8 x 50 ms)
local fire_f = math.floor(self.tamashii_fire.attribute)
tex.draw_texture(self.t_gauge_tamashii_fire,
{ frame = fire_f, scale = 0.8 * scale, fade = base_fade, index = p_idx })
end
end
if self.t_gauge_tamashii then
tex.draw_texture(self.t_gauge_tamashii, { scale = scale, fade = base_fade, index = p_idx })
end
if self.gauge_state == 2 and gl_int >= 87 and self.t_gauge_tamashii_overlay then
if fire_sheen ~= nil then
if fire_sheen > 0.0 then
tex.draw_texture(self.t_gauge_tamashii_overlay,
{ scale = scale, fade = math.min(fire_sheen, base_fade), index = p_idx })
end
else
local f = math.floor(self.tamashii_fire.attribute)
if f == 0 or f == 1 or f == 4 or f == 5 then
tex.draw_texture(self.t_gauge_tamashii_overlay,
{ scale = scale, fade = math.min(0.5, base_fade), index = p_idx })
end
end
end
else
if self.t_gauge_clear_dark then
tex.draw_texture(self.t_gauge_clear_dark, { scale = scale, fade = base_fade, index = clear_idx })
end
if self.t_gauge_tamashii_dark then
tex.draw_texture(self.t_gauge_tamashii_dark, { scale = scale, fade = base_fade, index = p_idx })
end
end
end
function ResultPlayer:draw_score_info()
-- Index order == the arcade's own ScoreBase.lua numMc_[1..5]:
-- good 良 (y 314), ok 可 (376), bad 不可 (438), dramroll 連打数 (500), maxcombo 最大コンボ数 (562)
-- measured off result/result.nulm `score_base_L` at `use_left` (good_num_mc … max_combo_num_mc).
-- ROUND 15 bug fix: `result/score/texture.json` had judge_num[3].y = 538 and [4].y = 476,
-- i.e. slot 3 was the BOTTOM (最大コンボ数) row -- so since round 5, when this array was
-- reordered to fill top-down, the drumroll count was printed on the 最大コンボ数 line and
-- the max combo on the 連打数 line. texture.json now runs 290/352/414/476/538 (and
-- 1798 + the same five for 2P), which is top-down, matching this array and the label block.
local scores = { self.good, self.ok, self.bad, self.total_drumroll, self.max_combo }
local p_idx = self.is_2p and 1 or 0
for j, s in ipairs(scores) do
if s ~= "" and self.t_judge_num then
local pop = pop_scale(DGJ, DGJ_TRACK, DGJ_F0, DGJ_F1,
self.row_land_ms[j], self.now_ms or 0)
local n = math.floor(tonumber(s) or 0)
local str = tostring(n)
for i = 1, #str do
local d = tonumber(string.sub(str, #str - i + 1, #str - i + 1)) or 0
tex.draw_texture(self.t_judge_num, {
frame = d,
x = -((i - 1) * self.sc_score_info_margin),
index = (j - 1) + p_idx * 5,
scale = pop,
center = true,
})
end
end
end
end
function ResultPlayer:draw_total_score()
local p_idx = self.is_2p and 1 or 0
if self.score_method then
if self.t_score_shinuchi_lbl then tex.draw_texture(self.t_score_shinuchi_lbl, { index = p_idx }) end
else
if self.t_score_lbl then tex.draw_texture(self.t_score_lbl, { index = p_idx }) end
end
if not self.fade_in_finished_flag then return end
if self.score ~= "" and self.t_score_num then
local pop = pop_scale(DGS, DGS_TRACK, DGS_F0, DGS_F1,
self.score_land_ms, self.now_ms or 0)
local n = math.floor(tonumber(self.score) or 0)
local str = tostring(n)
-- arcade draws total_score_back_mc (all outlines) first, then total_score_mc (all fills) on top:
-- score_num frames 0..9 = outline layer, 10..19 = fill layer (see MAPPING.md ROUND 5)
for layer = 0, 10, 10 do
for i = 1, #str do
local d = tonumber(string.sub(str, #str - i + 1, #str - i + 1)) or 0
tex.draw_texture(self.t_score_num, {
frame = d + layer,
x = -((i - 1) * self.sc_result_score_margin),
index = p_idx,
scale = pop,
center = true,
})
end
end
end
end