Files
YataiDONNijiiro/Scripts/result/result_player.lua
T
TaikoZhongandClaude Fable 5.1 41c484a8c8 result: the gold gauge cell follows the cleared state, not the cell count
The first gold cell was lit from the cell count (39 of 50 for oni) while the game clears on soul >= norma (8000); a failed run at 7800..7999 soul grew a gold cell. Draw the transition piece and the gold strip only when gauge_state >= 1, like the HUD gauge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 12:19:06 +08:00

804 lines
45 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_CELLS = 50 -- the cabinet's gauge is 50 cells (kMaxGage_), like the game HUD
local GAUGE_UNIT = 21 * GAUGE_SCALE -- one 21 px cell of the groove, at the board's 0.7
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 -- 58.33 ms per cell (kGageSpeed_ = 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
-- Fill target in cells: gauge_length is the engine's 87-unit display value
-- (points / max * 87), so cells = points * 50 / max, floored like the HUD gauge.
self.gauge_cells = math.floor(gauge_length * GAUGE_CELLS / 87 + 1e-4)
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 -- cells currently filled (animates up to gauge_cells)
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 = self.gauge_cells
-- 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 = self.gauge_cells
-- 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 >= GAUGE_CELLS 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 >= GAUGE_CELLS 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 = { 30, 35, 40 } -- easy / normal+hard / oni norma cells
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 >= GAUGE_CELLS 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
-- The transition piece is the first gold cell (the rounded cap of the clear zone).
-- Light it only when the run is cleared, like the HUD gauge (Gauge::get_is_clear):
-- the norma sits inside that cell (oni 8000 = cell 40.0 of 50, but a failed run at
-- 7800..7999 soul already shows 39 cells), so a cell-count test lit it a cell early
-- and a failed result grew a gold cell.
if gl_int >= clear_pt - 1 and self.gauge_state >= 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 and self.gauge_state >= 1 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 >= GAUGE_CELLS 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 >= GAUGE_CELLS 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