Module:Sandbox/Vergir: Difference between revisionsGive feedback
Jump to navigation
Jump to search
Parity test for get_hero_scaling_data refactored onto get_scalings (with help from vergir-bot LLM) |
Testing InfoboxHero on shared HeroData helpers (with help from vergir-bot LLM) |
||
| Line 1: | Line 1: | ||
-- | -------------------------------------------------------------------------------- | ||
-- {{#invoke: | -- Module:InfoboxHero | ||
-- | |||
-- Renders the hero infobox. Called from [[Template:Infobox hero]], which passes | |||
-- the editor's parameters through as the parent frame: | |||
-- | |||
-- {{#invoke:InfoboxHero|render}} | |||
-- | |||
-- May also be invoked directly for testing, in which case parameters are read | |||
-- from the invoke itself: | |||
-- | |||
-- {{#invoke:InfoboxHero|render|key=hero_atlas}} | |||
-- | |||
-- Stat data comes from [[Data:HeroData.json]]; per-hero colors, backgrounds and | |||
-- logo sizes from [[Data:Infobox hero.json]]; labels from [[Module:Lang]] and | |||
-- [[Module:Dictionary]]. Styling lives in [[Template:Infobox hero/styles.css]] | |||
-- and [[Template:Infobox hero/themes.css]]. | |||
-- | |||
-------------------------------------------------------------------------------- | |||
local p = {} | local p = {} | ||
local function | local hero_data_all = mw.loadJsonData('Data:HeroData.json') | ||
local | local theme_data = mw.loadJsonData('Data:Infobox_hero.json') | ||
return | local util = require('Module:Utilities') | ||
local lang_module = require('Module:Lang') | |||
local dict_module = require('Module:Dictionary') | |||
local hero_module = require('Module:HeroData') | |||
local sb_module = require('Module:StreetBrawlAbilities') | |||
-- Shared hero-data lookups, see [[Module:HeroData]]. | |||
local get_stat = hero_module.get_stat | |||
local get_scalings = hero_module.get_scalings | |||
local has_stat = hero_module.has_stat | |||
local has_override_or_stat, stat_number | |||
-------------------------------------------------------------------------------- | |||
-- CONFIG | |||
-------------------------------------------------------------------------------- | |||
local DEFAULT_HERO_KEY = 'hero_targetdummy' -- used when |key= is missing/unknown | |||
local DEFAULT_SIG_FIGS = 3 | |||
local FALLBACK_CARD = 'Dummy card.png' | |||
-- Row fields (all optional except a label): | |||
-- stat Key in Data:HeroData.json. Resolved top-level, then .Weapon, | |||
-- then .Weapon.AltFire. Omit for manual-input-only rows. | |||
-- param |param= that overrides the value. | |||
-- label Key in Data:Lang_<code>.json. | |||
-- label_dict Key in Data:Dictionary. Used instead of `label`. | |||
-- label_text Literal, untranslated label. Used instead of `label`. | |||
-- link Target for the label, as 'Page' or 'Page#Anchor'. The language | |||
-- suffix is inserted for us, so write the plain English title. | |||
-- icon_link Target for the icon. Defaults to `link`. | |||
-- icon File: name of the value icon. Omit for no icon. | |||
-- sig_figs Significant figures. Defaults to 3. | |||
-- suffix Appended directly after the value, e.g. '%', 's', 'm/s'. | |||
-- scaling Set false to suppress the Boon/Spirit scaling span. Scaling is | |||
-- shown by default wherever the data provides it. | |||
-- optional Hide the row unless the hero has a non-zero value or scaling. | |||
-- cell_class Extra class on the value cell. | |||
-- show function(ctx) -> boolean. Row omitted when false. | |||
local SECTIONS = { | |||
{ | |||
class = 'infobox-h-dropdown-weapon', | |||
header = 'CitadelHeroStats_Weapon', | |||
link = 'Stats#Weapon', | |||
color = 'Brown', | |||
rows = { | |||
{ | |||
stat = 'DPS', | |||
param = 'dps', | |||
label = 'DPS_label', | |||
link = 'Damage per second', | |||
icon = 'Damage.png', | |||
-- DPS is derived from bullet damage and fire rate, both of which | |||
-- show their own scaling; repeating it here would double-count. | |||
scaling = false, | |||
}, | |||
{ | |||
stat = 'BulletDamage', | |||
param = 'bullet_damage', | |||
label = 'StatDesc_BulletDamage', | |||
link = 'Bullet Damage', | |||
icon = 'Bullet damage.png', | |||
cell_class = 'infobox-h-stat-value-padded', | |||
}, | |||
{ | |||
stat = 'BulletsPerShot', | |||
param = 'pellets_per_shot', | |||
label = 'BonusPelletsPerShot_label', | |||
link = 'Damage per second', | |||
icon = 'Bullets per sec icon.png', | |||
icon_link = 'Fire Rate', | |||
show = function(ctx) return ctx.pellets > 1 end, | |||
}, | |||
{ | |||
stat = 'BulletsPerBurst', | |||
param = 'bullets_per_burst', | |||
label_dict = 'BulletsPerBurst', | |||
link = 'Damage per second', | |||
icon = 'Bullets per sec icon.png', | |||
icon_link = 'Fire Rate', | |||
show = function(ctx) return ctx.burst > 1 end, | |||
}, | |||
{ | |||
stat = 'ClipSize', | |||
param = 'ammo', | |||
label = 'StatDesc_ClipSizeBonus', | |||
link = 'Ammo', | |||
icon = 'Clip size icon.png', | |||
}, | |||
{ | |||
stat = 'RoundsPerSecond', | |||
param = 'bullets_per_sec', | |||
label = 'StatDesc_RoundsPerSecond', | |||
link = 'Fire Rate', | |||
icon = 'Bullets per sec icon.png', | |||
}, | |||
{ | |||
stat = 'ReloadTime', | |||
param = 'reload_time', | |||
label = 'StatDesc_ReloadTime', | |||
link = 'Reload Time', | |||
icon = 'AttributeIconReloadTime.png', | |||
suffix = 's', | |||
}, | |||
{ | |||
stat = 'BulletSpeed', | |||
param = 'bullet_velocity', | |||
label = 'StatDesc_BulletSpeed', | |||
link = 'Bullet Velocity', | |||
icon = 'AttributeIconBulletSpeed.png', | |||
suffix = 'm/s', | |||
}, | |||
{ | |||
stat = 'LightMeleeDamage', | |||
param = 'light_melee', | |||
label = 'StatDesc_LightMeleeDamage', | |||
link = 'Melee Damage', | |||
icon = 'Melee damage.png', | |||
cell_class = 'infobox-h-stat-value-padded', | |||
}, | |||
{ | |||
stat = 'HeavyMeleeDamage', | |||
param = 'heavy_melee', | |||
label = 'StatDesc_HeavyMeleeDamage', | |||
link = 'Melee Damage', | |||
icon = 'Melee damage.png', | |||
cell_class = 'infobox-h-stat-value-padded', | |||
}, | |||
{ | |||
param = 'max_range', | |||
label_text = 'Max Range', | |||
link = 'Falloff Range', | |||
show = function(ctx) return ctx.args.max_range ~= '' end, | |||
}, | |||
{ | |||
stat = 'CritDamageBonusPercent', | |||
label = 'StatDesc_CritDamageBonusScale', | |||
link = 'Weapon Damage#Crit_Multiplier', | |||
icon = 'Crit reduction.png', | |||
suffix = '%', | |||
show = function(ctx) | |||
return stat_number(ctx, 'CritDamageBonusPercent') ~= 0 | |||
end, | |||
}, | |||
}, | |||
}, | |||
{ | |||
class = 'infobox-h-dropdown-vitality', | |||
header = 'CitadelHeroStats_Vitality', | |||
link = 'Stats#Vitality', | |||
color = 'Green', | |||
rows = { | |||
{ | |||
stat = 'MaxHealth', | |||
param = 'max_health', | |||
label = 'StatDesc_HealthAttribute', | |||
link = 'Health', | |||
icon = 'Health.png', | |||
}, | |||
{ | |||
stat = 'BaseHealthRegen', | |||
param = 'health_regen', | |||
label = 'StatDesc_HealthRegen', | |||
link = 'Health Regen', | |||
icon = 'Health regen.png', | |||
sig_figs = 2, | |||
}, | |||
{ | |||
stat = 'CritDamageReceivedPercent', | |||
param = 'crit_reduction', | |||
label = 'StatDesc_CritDamageReceivedScale', | |||
link = 'Damage Resistance#Crit_Reduction', | |||
icon = 'Crit reduction.png', | |||
suffix = '%', | |||
optional = true, | |||
}, | |||
{ | |||
stat = 'BulletResist', | |||
param = 'bullet_resist', | |||
label = 'StatDesc_BulletArmorDamageReduction', | |||
link = 'Damage Resistance', | |||
icon = 'Bullet Armor.png', | |||
icon_link = 'Bullet Resist', | |||
suffix = '%', | |||
optional = true, | |||
}, | |||
{ | |||
stat = 'TechResist', | |||
param = 'spirit_resist', | |||
label = 'StatDesc_TechArmorDamageReduction', | |||
link = 'Damage Resistance#Spirit_Resist', | |||
icon = 'Spirit Armor.png', | |||
suffix = '%', | |||
optional = true, | |||
}, | |||
{ | |||
stat = 'MeleeResist', | |||
param = 'melee_resist', | |||
label = 'StatDesc_MeleeResist', | |||
link = 'Damage Resistance#Melee Resist', | |||
icon = 'Melee damage.png', | |||
suffix = '%', | |||
optional = true, | |||
}, | |||
{ | |||
stat = 'DebuffResist', | |||
param = 'debuff_resist', | |||
label = 'StatDesc_DebuffResist', | |||
link = 'Debuff Resist', | |||
icon = 'Spirit Armor.png', | |||
suffix = '%', | |||
optional = true, | |||
}, | |||
{ | |||
stat = 'BulletLifesteal', | |||
param = 'bullet_lifesteal', | |||
label = 'StatDesc_BulletLifesteal', | |||
link = 'Lifesteal#Bullet Lifesteal', | |||
icon = 'AttributeIconBulletLifesteal.png', | |||
suffix = '%', | |||
optional = true, | |||
}, | |||
{ | |||
stat = 'SpiritLifesteal', | |||
param = 'spirit_lifesteal', | |||
label = 'StatDesc_SpiritLifesteal', | |||
link = 'Lifesteal#Spirit Lifesteal', | |||
icon = 'AttributeIconBulletLifesteal.png', | |||
suffix = '%', | |||
optional = true, | |||
}, | |||
{ | |||
stat = 'MaxMoveSpeed', | |||
param = 'move_speed', | |||
label = 'StatDesc_RunSpeed', | |||
link = 'Move Speed', | |||
icon = 'Move speed.png', | |||
sig_figs = 2, | |||
suffix = 'm/s', | |||
}, | |||
{ | |||
stat = 'SprintSpeed', | |||
param = 'sprint_speed', | |||
label = 'StatDesc_SprintSpeed', | |||
link = 'Move Speed#Sprint', | |||
icon = 'Move speed sprint.png', | |||
sig_figs = 2, | |||
suffix = 'm/s', | |||
}, | |||
{ | |||
stat = 'GroundDashSpeed', | |||
label = 'DashSpeed_label', | |||
link = 'Stamina#Dash_Speed', | |||
icon = 'Springy boots icon.png', | |||
suffix = 'm/s', | |||
}, | |||
{ | |||
stat = 'Stamina', | |||
param = 'stamina', | |||
label = 'StatDesc_Stamina', | |||
link = 'Stamina', | |||
icon = 'Springy boots icon.png', | |||
sig_figs = 2, | |||
}, | |||
{ | |||
stat = 'StaminaCooldown', | |||
param = 'stamina_cooldown', | |||
label = 'StatDesc_StaminaCooldown', | |||
link = 'Stamina#Cooldown', | |||
icon = 'AttributeIconTechDuration.png', | |||
sig_figs = 2, | |||
suffix = 's', | |||
}, | |||
}, | |||
}, | |||
{ | |||
class = 'infobox-h-dropdown-spirit', | |||
header = 'CitadelHeroStats_Spirit', | |||
link = 'Stats#Spirit', | |||
color = 'Purple', | |||
rows = { | |||
{ | |||
stat = 'TechPower', | |||
param = 'spirit_power', | |||
label = 'SpiritPower_label', | |||
link = 'Spirit Power', | |||
icon = 'Spirit icon noborder.png', | |||
sig_figs = 2, | |||
}, | |||
}, | |||
}, | |||
} | |||
-- The "Information" block. Manual input only; hidden entirely when all are empty. | |||
local INFO_ROWS = { | |||
{ param = 'pronouns', label_dict = 'Pronouns' }, | |||
{ param = 'voice_actor', label_dict = 'VoiceActor', link = 'Voice actors' }, | |||
{ param = 'release_date', label_dict = 'ReleaseDate', link = 'Heroes by release' }, | |||
{ param = 'codenames', label_dict = 'Codenames', tooltip = 'Codenames used for this hero in the game files' }, | |||
} | |||
-- name: literal, or function(ctx) -> string. when: function(ctx) -> boolean. | |||
local CATEGORIES = { | |||
{ name = 'Heroes' }, | |||
{ | |||
name = function(ctx) return ctx.hero_en end, | |||
-- Silver (Transformed) is documented on Silver's page and shares her category. | |||
when = function(ctx) return ctx.hero_en ~= 'Silver (Transformed)' end, | |||
}, | |||
{ | |||
name = 'Heroes with spreadshot weapons', | |||
when = function(ctx) return ctx.pellets > 1 end, | |||
}, | |||
{ | |||
name = 'Heroes with single damage spreadshot weapons', | |||
when = function(ctx) | |||
return ctx.pellets > 1 and get_stat(ctx.hero, 'HitOnceAcrossAllBullets') == true | |||
end, | |||
}, | |||
{ | |||
name = 'Heroes with burst weapons', | |||
when = function(ctx) return ctx.burst > 1 end, | |||
}, | |||
{ | |||
name = 'Heroes with automatic weapons', | |||
when = function(ctx) return ctx.pellets <= 1 and ctx.burst <= 1 end, | |||
}, | |||
{ | |||
name = 'Heroes with alternate fire weapons', | |||
when = function(ctx) | |||
return ctx.hero.Weapon ~= nil and ctx.hero.Weapon.AltFire ~= nil | |||
end, | |||
}, | |||
{ | |||
name = 'Heroes with single reload weapons', | |||
when = function(ctx) return get_stat(ctx.hero, 'ReloadSingle') == true end, | |||
}, | |||
{ | |||
name = 'Heroes with spin up weapons', | |||
when = function(ctx) return has_stat(ctx.hero, 'SpinAcceleration') end, | |||
}, | |||
{ | |||
name = 'Heroes with no weapon damage falloff', | |||
when = function(ctx) | |||
return (get_stat(ctx.hero, 'FalloffStartRange') or 0) | |||
== (get_stat(ctx.hero, 'FalloffEndRange') or 0) | |||
end, | |||
}, | |||
{ | |||
name = "Heroes that don't deal critical damage", | |||
when = function(ctx) return get_stat(ctx.hero, 'CanCrit') == false end, | |||
}, | |||
{ | |||
name = 'Heroes with modified crit scale', | |||
when = function(ctx) return stat_number(ctx, 'CritDamageBonusPercent') ~= 0 end, | |||
}, | |||
{ | |||
name = 'Heroes with modified crit resistance', | |||
when = function(ctx) | |||
return has_override_or_stat(ctx, 'crit_reduction', 'CritDamageReceivedPercent') | |||
end, | |||
}, | |||
{ | |||
name = 'Heroes with modified bullet resist', | |||
when = function(ctx) | |||
return has_override_or_stat(ctx, 'bullet_resist', 'BulletResist') | |||
end, | |||
}, | |||
{ | |||
name = 'Heroes with modified spirit resist', | |||
when = function(ctx) | |||
return has_override_or_stat(ctx, 'spirit_resist', 'TechResist') | |||
end, | |||
}, | |||
{ | |||
name = 'Heroes with modified melee resist', | |||
when = function(ctx) | |||
return has_override_or_stat(ctx, 'melee_resist', 'MeleeResist') | |||
end, | |||
}, | |||
{ | |||
name = 'Heroes with modified debuff resist', | |||
when = function(ctx) | |||
return has_override_or_stat(ctx, 'debuff_resist', 'DebuffResist') | |||
end, | |||
}, | |||
{ | |||
name = 'Heroes with modified bullet lifesteal', | |||
when = function(ctx) | |||
return has_override_or_stat(ctx, 'bullet_lifesteal', 'BulletLifesteal') | |||
end, | |||
}, | |||
{ | |||
name = 'Heroes with modified spirit lifesteal', | |||
when = function(ctx) | |||
return has_override_or_stat(ctx, 'spirit_lifesteal', 'SpiritLifesteal') | |||
end, | |||
}, | |||
{ | |||
name = 'Heroes with modified gravity scale', | |||
when = function(ctx) return has_stat(ctx.hero, 'GravityChange') end, | |||
}, | |||
{ | |||
name = function(ctx) return 'Heroes with ' .. ctx.stamina .. ' stamina' end, | |||
when = function(ctx) return ctx.stamina ~= '' end, | |||
}, | |||
{ | |||
name = function(ctx) | |||
if ctx.dash < 14.7 then return 'Heroes with slow dash speed' end | |||
if ctx.dash > 14.7 then return 'Heroes with fast dash speed' end | |||
return 'Heroes with medium dash speed' | |||
end, | |||
}, | |||
{ | |||
name = 'Heroes with Street Brawl changes', | |||
when = function(ctx) return sb_module.hero_has_changes(ctx.hero_key) end, | |||
}, | |||
} | |||
-------------------------------------------------------------------------------- | |||
-- Data access | |||
-------------------------------------------------------------------------------- | |||
has_override_or_stat = function(ctx, param, stat) | |||
if param and ctx.args[param] ~= '' then return true end | |||
return has_stat(ctx.hero, stat) | |||
end | end | ||
function | -- Rounded numeric value of a stat, honouring a |param= override. | ||
local | stat_number = function(ctx, stat, param) | ||
for _, | if param and ctx.args[param] ~= '' then return tonumber(ctx.args[param]) or 0 end | ||
return tonumber(util.round_to_sig_fig(get_stat(ctx.hero, stat) or 0, DEFAULT_SIG_FIGS)) or 0 | |||
if (value | end | ||
local function format_value(raw, sig_figs) | |||
if raw == nil then return '0' end | |||
local rounded = util.round_to_sig_fig(raw, sig_figs or DEFAULT_SIG_FIGS) | |||
return tostring(rounded ~= nil and rounded or raw) | |||
end | |||
-------------------------------------------------------------------------------- | |||
-- Rendering helpers | |||
-------------------------------------------------------------------------------- | |||
local function theme_var(hero_key, name) | |||
local hero_theme = theme_data[hero_key] | |||
if hero_theme and hero_theme[name] ~= nil then return hero_theme[name] end | |||
return theme_data['hero_default'][name] | |||
end | |||
local function label_of(row) | |||
if row.label_text then return row.label_text end | |||
if row.label_dict then return dict_module.translate(row.label_dict) end | |||
return lang_module.get_string(row.label) | |||
end | |||
-- 'Damage Resistance#Spirit_Resist' -> 'Damage Resistance/ru#Spirit_Resist' | |||
local function page_link(ctx, target) | |||
if not target then return '' end | |||
local page, anchor = target:match('^([^#]*)(#?.*)$') | |||
return page .. ctx.if_lang .. anchor | |||
end | |||
local function icon_cell(ctx, row, color, value_html) | |||
local icon = '' | |||
if row.icon then | |||
icon = string.format('[[File:%s|20px|link=%s]]', row.icon, | |||
page_link(ctx, row.icon_link or row.link)) | |||
end | |||
return ctx.frame:expandTemplate{ title = 'Icon/' .. color, args = { icon, value_html } } | |||
end | |||
-- Ammo and reload time are meaningless for heroes whose weapon never reloads. | |||
local NO_AMMO_STATS = { ClipSize = true, ReloadTime = true } | |||
-- Returns true when the row rendered. | |||
local function render_stat_row(ctx, tbl, row, color, is_first) | |||
if row.show and not row.show(ctx) then return false end | |||
if row.optional and not has_override_or_stat(ctx, row.param, row.stat) then return false end | |||
local value, scaling_inner | |||
if NO_AMMO_STATS[row.stat] and ctx.args.no_ammo == 'true' then | |||
value, scaling_inner = 'N/A', '' | |||
else | |||
local override = row.param and ctx.args[row.param] or '' | |||
if override ~= '' then | |||
value = override | |||
else | |||
value = format_value(get_stat(ctx.hero, row.stat), row.sig_figs) | |||
end | |||
value = value .. (row.suffix or '') | |||
if row.stat and row.scaling ~= false then | |||
local parts = {} | |||
for _, scaling in ipairs(get_scalings(ctx.hero, row.stat)) do | |||
parts[#parts + 1] = hero_module.write_scalar_str(scaling.value, scaling.type) | |||
end | |||
scaling_inner = table.concat(parts) | |||
value = value .. '<span class="infobox-h-spiritboon">' .. scaling_inner .. '</span>' | |||
else | |||
scaling_inner = '' | |||
end | |||
end | |||
local tr = tbl:tag('tr') | |||
local label_cell = tr:tag('td') | |||
-- The section's first row needs extra headroom when it carries a scaling icon. | |||
if is_first and scaling_inner ~= '' then | |||
label_cell:addClass('infobox-h-stat-label-scaled') | |||
end | |||
if row.link then | |||
label_cell:wikitext(string.format('[[%s|<span class="infobox-h-stat-label">%s</span>]]:', | |||
page_link(ctx, row.link), label_of(row))) | |||
else | |||
label_cell:wikitext(label_of(row) .. ':') | |||
end | |||
local value_cell = tr:tag('td') | |||
if row.cell_class then value_cell:addClass(row.cell_class) end | |||
value_cell:wikitext(icon_cell(ctx, row, color, value)) | |||
return true | |||
end | |||
local function render_section(ctx, parent, section) | |||
local tbl = parent:tag('tr'):tag('th') | |||
:attr('colspan', '2') | |||
:addClass('infobox-h-subsection infobox-h-dropdown ' .. section.class) | |||
:tag('table'):addClass('mw-collapsible infobox-h-subsection-child') | |||
local caption = tbl:tag('caption'):addClass('infobox-h-dropdown-header') | |||
if ctx.if_lang == '/ru' then | |||
caption:addClass('infobox-h-dropdown-header-overflow') | |||
end | |||
caption:wikitext(string.format( | |||
'[[%s|<span class="infotext-h-bold infobox-h-dropdown-title">%s</span>]]', | |||
page_link(ctx, section.link), lang_module.get_string(section.header))) | |||
local is_first = true | |||
for _, row in ipairs(section.rows) do | |||
if render_stat_row(ctx, tbl, row, section.color, is_first) then | |||
is_first = false | |||
end | end | ||
end | end | ||
end | end | ||
function | local function render_information(ctx, parent) | ||
local | local present = {} | ||
-- | for _, row in ipairs(INFO_ROWS) do | ||
if ctx.args[row.param] ~= '' then present[#present + 1] = row end | |||
local | end | ||
for | if #present == 0 then return end | ||
parent:tag('tr'):tag('th') | |||
:attr('colspan', '2') | |||
:addClass('infobox-h-text-stat-subtitle') | |||
:wikitext('<span class="infotext-h-bold infotext-h-title infotext-h-big">' | |||
.. dict_module.translate('Information') .. '</span>') | |||
local tbl = parent:tag('tr'):tag('th') | |||
:attr('colspan', '2') | |||
:addClass('infobox-h-subsection') | |||
:tag('table'):addClass('infobox-h-subsection-child') | |||
for _, row in ipairs(present) do | |||
local tr = tbl:tag('tr') | |||
local label = dict_module.translate(row.label_dict) | |||
if row.tooltip then | |||
label = ctx.frame:expandTemplate{ title = 'Tooltip', args = { label, row.tooltip } } | |||
end | |||
if row.link then | |||
tr:tag('td'):wikitext(string.format('[[%s|<span class="infobox-h-stat-label">%s</span>]]:', | |||
page_link(ctx, row.link), label)) | |||
else | |||
tr:tag('td'):wikitext(label .. ':') | |||
end | |||
tr:tag('td'):wikitext(ctx.args[row.param]) | |||
end | end | ||
end | end | ||
local function | local function render_title(ctx, parent) | ||
local th = parent:tag('tr'):tag('th'):attr('colspan', '2'):addClass('infobox-h-title') | |||
local | if ctx.args.name_font_size ~= '' then | ||
th:cssText('font-size: ' .. ctx.args.name_font_size .. ';') | |||
end | |||
local wrap = th:tag('div'):addClass('infobox-h-title-hero') | |||
wrap:tag('div'):addClass('infobox-h-title-hero-left') | |||
-- Name logo: <Name>_name.svg, or <Name>_name_<lang>.svg on translated pages. | |||
-- Heroes without a logo fall back to their name as plain text. | |||
local svg = ((ctx.args.name ~= '' and ctx.args.name) or ctx.hero_en) .. '_name' | |||
if ctx.if_lang ~= '' then svg = svg .. '_' .. ctx.lang_code end | |||
local middle = wrap:tag('div'):addClass('infobox-h-title-hero-middle') | |||
if util.page_exists('File:' .. svg .. '.svg') then | |||
middle:wikitext(ctx.frame:expandTemplate{ title = 'Icon/White', args = { | |||
string.format('[[File:%s.svg|%s|class=notpageimage]]', | |||
svg, theme_var(ctx.hero_key, 'logo-height')) } }) | |||
else | |||
middle:wikitext(ctx.display_name) | |||
end | |||
local right = wrap:tag('div'):addClass('infobox-h-title-hero-right'):tag('div') | |||
if ctx.show_icon then | |||
local icon = ((ctx.args.icon ~= '' and ctx.args.icon) or ctx.hero_en) .. '.png' | |||
if util.page_exists('File:' .. icon) then | |||
right:tag('div'):addClass('infobox-h-title-hero-tornbox') | |||
right:tag('div'):addClass('infobox-h-title-hero-icon') | |||
:wikitext(string.format('[[File:%s|center|35px|class=notpageimage]]', icon)) | |||
end | |||
end | end | ||
end | end | ||
function | local function render_card(ctx, parent) | ||
local | local card = (ctx.args.card ~= '' and ctx.args.card) or (ctx.hero_en .. ' card.png') | ||
if not util.page_exists('File:' .. card) then card = FALLBACK_CARD end | |||
parent:tag('tr'):tag('th') | |||
:attr('colspan', '2') | |||
:addClass('infobox-h-image') | |||
:tag('div') | |||
:wikitext(string.format('[[File:%s|150px|alt=%s]]', | |||
card, mw.title.getCurrentTitle().baseText)) | |||
end | |||
local function render_tags(ctx, parent) | |||
if not ctx.show_hero_tags then return end | |||
local tags = {} | |||
for i = 1, 3 do | |||
local tag = hero_module.hero_tag(ctx.hero_en, i) | |||
if tag ~= '' then | |||
tags[#tags + 1] = tag | |||
end | end | ||
end | end | ||
if #tags == 0 then return end | |||
local | local inner = parent:tag('tr'):tag('td') | ||
local | :attr('colspan', '2') | ||
:addClass('infobox-h-herotags-row') | |||
:tag('div'):addClass('infobox-h-herotags-inner ' .. ctx.hero_key) | |||
for _, tag in ipairs(tags) do | |||
inner:tag('div'):addClass('infobox-h-herotag-pill'):wikitext(tag) | |||
end | |||
end | |||
local function render_categories(ctx) | |||
if mw.ustring.lower(ctx.args.nocat ~= '' and ctx.args.nocat or 'false') ~= 'false' then | |||
return '' | |||
end | |||
if get_stat(ctx.hero, 'IsDisabled') ~= false then return '' end | |||
local out = {} | |||
for _, category in ipairs(CATEGORIES) do | |||
if category.when == nil or category.when(ctx) then | |||
local name = category.name | |||
local | if type(name) == 'function' then name = name(ctx) end | ||
out[#out + 1] = ctx.frame:expandTemplate{ title = 'lang cat', args = { name } } | |||
end | end | ||
end | end | ||
return table.concat(out) | |||
end | |||
-------------------------------------------------------------------------------- | |||
-- Entry point | |||
-------------------------------------------------------------------------------- | |||
function p.render(frame) | |||
local parent = frame:getParent() | |||
local invoke_args = frame.args | |||
local template_args = parent and parent.args or {} | |||
-- Missing parameters read as '' so callers never have to nil-check. | |||
local args = setmetatable({}, { __index = function(_, k) | |||
local value = invoke_args[k] | |||
if value == nil then value = template_args[k] end | |||
return value or '' | |||
end }) | |||
local | -- |key= accepts a hero_* key or the hero's English name; unknown values fall | ||
-- back to the target dummy, the default character in Sandbox mode. | |||
local hero_key = hero_module.find_hero_key(args.key) or DEFAULT_HERO_KEY | |||
local hero = hero_data_all[hero_key] | |||
'', | local ctx = { | ||
frame = frame, | |||
args = args, | |||
hero = hero, | |||
hero_key = hero_key, | |||
hero_en = hero.Name, | |||
lang_code = lang_module.get_lang_code(), | |||
if_lang = frame:expandTemplate{ title = 'If lang' }, | |||
} | } | ||
ctx.display_name = (args.name ~= '' and args.name) or lang_module.get_string(hero_key) | |||
table. | ctx.show_hero_tags = args.show_hero_tags ~= 'false' | ||
ctx.show_icon = args.show_icon ~= 'false' | |||
-- Needed by both row visibility and the category rules. | |||
ctx.pellets = stat_number(ctx, 'BulletsPerShot', 'pellets_per_shot') | |||
ctx.burst = stat_number(ctx, 'BulletsPerBurst', 'bullets_per_burst') | |||
ctx.dash = stat_number(ctx, 'GroundDashSpeed') | |||
ctx.stamina = (args.stamina ~= '' and args.stamina) | |||
or format_value(get_stat(hero, 'Stamina'), 2) | |||
local root = mw.html.create('div'):addClass('infobox-h ' .. hero_key) | |||
root:tag('div'):addClass('infobox-h-background') | |||
:wikitext(string.format('[[File:%s|%s|right|link=|class=notpageimage]]', | |||
theme_var(hero_key, 'bg-image'), theme_var(hero_key, 'bg-height'))) | |||
local tbl = root:tag('table'):addClass('infobox-h-content') | |||
render_title(ctx, tbl) | |||
render_card(ctx, tbl) | |||
render_tags(ctx, tbl) | |||
for _, section in ipairs(SECTIONS) do | |||
render_section(ctx, tbl, section) | |||
end | |||
render_information(ctx, tbl) | |||
if mw.title.getCurrentTitle().nsText ~= 'Template' then | |||
frame:callParserFunction('DISPLAYTITLE', ctx.display_name) | |||
end | |||
return tostring(root) .. render_categories(ctx) | |||
end | |||
-------------------------------------------------------------------------------- | |||
-- TEMPORARY render-diff harness - NOT part of the live module. | |||
-- {{#invoke:Sandbox/Vergir|diff}} or |diff|hero_atlas,hero_viscous | |||
-------------------------------------------------------------------------------- | |||
-- Kept small: each render makes several expensive page_exists calls, and the | |||
-- parser allows only 100 per page. | |||
local SAMPLE_HEROES = { | |||
'hero_atlas', -- burst-ish baseline, has scaling on many stats | |||
'hero_viscous', -- has an AltFire block | |||
'hero_haze', -- spreadshot | |||
'hero_werewolf', -- Silver, has a transformed variant | |||
'hero_targetdummy',-- the fallback hero | |||
} | |||
function p.diff(frame) | |||
local other = require('Module:InfoboxHero') | |||
local keys = {} | |||
if frame.args[1] and frame.args[1] ~= '' then | |||
for key in string.gmatch(frame.args[1], '[^,%s]+') do | |||
table.insert(keys, key) | |||
end | |||
else | |||
keys = SAMPLE_HEROES | |||
end | end | ||
if | |||
return table.concat( | local checked, mismatches = 0, {} | ||
for _, hero_key in ipairs(keys) do | |||
local child = frame:newChild{ args = { key = hero_key } } | |||
local ok_a, a = pcall(other.render, child) | |||
local ok_b, b = pcall(p.render, child) | |||
if not ok_a then a = 'ERR:' .. tostring(a) end | |||
if not ok_b then b = 'ERR:' .. tostring(b) end | |||
checked = checked + 1 | |||
if a ~= b then | |||
table.insert(mismatches, hero_key .. ' (len ' .. #tostring(a) .. ' vs ' .. #tostring(b) .. ')') | |||
end | |||
end | |||
return 'Heroes rendered through both modules: ' .. checked .. | |||
'\n\nMismatches: ' .. #mismatches .. | |||
(#mismatches > 0 and ('\n* ' .. table.concat(mismatches, '\n* ')) or '\n\nIdentical output.') | |||
end | end | ||
return p | return p | ||
Revision as of 13:39, 11 August 2026
Documentation for this module may be created at Module:Sandbox/Vergir/doc
--------------------------------------------------------------------------------
-- Module:InfoboxHero
--
-- Renders the hero infobox. Called from [[Template:Infobox hero]], which passes
-- the editor's parameters through as the parent frame:
--
-- {{#invoke:InfoboxHero|render}}
--
-- May also be invoked directly for testing, in which case parameters are read
-- from the invoke itself:
--
-- {{#invoke:InfoboxHero|render|key=hero_atlas}}
--
-- Stat data comes from [[Data:HeroData.json]]; per-hero colors, backgrounds and
-- logo sizes from [[Data:Infobox hero.json]]; labels from [[Module:Lang]] and
-- [[Module:Dictionary]]. Styling lives in [[Template:Infobox hero/styles.css]]
-- and [[Template:Infobox hero/themes.css]].
--
--------------------------------------------------------------------------------
local p = {}
local hero_data_all = mw.loadJsonData('Data:HeroData.json')
local theme_data = mw.loadJsonData('Data:Infobox_hero.json')
local util = require('Module:Utilities')
local lang_module = require('Module:Lang')
local dict_module = require('Module:Dictionary')
local hero_module = require('Module:HeroData')
local sb_module = require('Module:StreetBrawlAbilities')
-- Shared hero-data lookups, see [[Module:HeroData]].
local get_stat = hero_module.get_stat
local get_scalings = hero_module.get_scalings
local has_stat = hero_module.has_stat
local has_override_or_stat, stat_number
--------------------------------------------------------------------------------
-- CONFIG
--------------------------------------------------------------------------------
local DEFAULT_HERO_KEY = 'hero_targetdummy' -- used when |key= is missing/unknown
local DEFAULT_SIG_FIGS = 3
local FALLBACK_CARD = 'Dummy card.png'
-- Row fields (all optional except a label):
-- stat Key in Data:HeroData.json. Resolved top-level, then .Weapon,
-- then .Weapon.AltFire. Omit for manual-input-only rows.
-- param |param= that overrides the value.
-- label Key in Data:Lang_<code>.json.
-- label_dict Key in Data:Dictionary. Used instead of `label`.
-- label_text Literal, untranslated label. Used instead of `label`.
-- link Target for the label, as 'Page' or 'Page#Anchor'. The language
-- suffix is inserted for us, so write the plain English title.
-- icon_link Target for the icon. Defaults to `link`.
-- icon File: name of the value icon. Omit for no icon.
-- sig_figs Significant figures. Defaults to 3.
-- suffix Appended directly after the value, e.g. '%', 's', 'm/s'.
-- scaling Set false to suppress the Boon/Spirit scaling span. Scaling is
-- shown by default wherever the data provides it.
-- optional Hide the row unless the hero has a non-zero value or scaling.
-- cell_class Extra class on the value cell.
-- show function(ctx) -> boolean. Row omitted when false.
local SECTIONS = {
{
class = 'infobox-h-dropdown-weapon',
header = 'CitadelHeroStats_Weapon',
link = 'Stats#Weapon',
color = 'Brown',
rows = {
{
stat = 'DPS',
param = 'dps',
label = 'DPS_label',
link = 'Damage per second',
icon = 'Damage.png',
-- DPS is derived from bullet damage and fire rate, both of which
-- show their own scaling; repeating it here would double-count.
scaling = false,
},
{
stat = 'BulletDamage',
param = 'bullet_damage',
label = 'StatDesc_BulletDamage',
link = 'Bullet Damage',
icon = 'Bullet damage.png',
cell_class = 'infobox-h-stat-value-padded',
},
{
stat = 'BulletsPerShot',
param = 'pellets_per_shot',
label = 'BonusPelletsPerShot_label',
link = 'Damage per second',
icon = 'Bullets per sec icon.png',
icon_link = 'Fire Rate',
show = function(ctx) return ctx.pellets > 1 end,
},
{
stat = 'BulletsPerBurst',
param = 'bullets_per_burst',
label_dict = 'BulletsPerBurst',
link = 'Damage per second',
icon = 'Bullets per sec icon.png',
icon_link = 'Fire Rate',
show = function(ctx) return ctx.burst > 1 end,
},
{
stat = 'ClipSize',
param = 'ammo',
label = 'StatDesc_ClipSizeBonus',
link = 'Ammo',
icon = 'Clip size icon.png',
},
{
stat = 'RoundsPerSecond',
param = 'bullets_per_sec',
label = 'StatDesc_RoundsPerSecond',
link = 'Fire Rate',
icon = 'Bullets per sec icon.png',
},
{
stat = 'ReloadTime',
param = 'reload_time',
label = 'StatDesc_ReloadTime',
link = 'Reload Time',
icon = 'AttributeIconReloadTime.png',
suffix = 's',
},
{
stat = 'BulletSpeed',
param = 'bullet_velocity',
label = 'StatDesc_BulletSpeed',
link = 'Bullet Velocity',
icon = 'AttributeIconBulletSpeed.png',
suffix = 'm/s',
},
{
stat = 'LightMeleeDamage',
param = 'light_melee',
label = 'StatDesc_LightMeleeDamage',
link = 'Melee Damage',
icon = 'Melee damage.png',
cell_class = 'infobox-h-stat-value-padded',
},
{
stat = 'HeavyMeleeDamage',
param = 'heavy_melee',
label = 'StatDesc_HeavyMeleeDamage',
link = 'Melee Damage',
icon = 'Melee damage.png',
cell_class = 'infobox-h-stat-value-padded',
},
{
param = 'max_range',
label_text = 'Max Range',
link = 'Falloff Range',
show = function(ctx) return ctx.args.max_range ~= '' end,
},
{
stat = 'CritDamageBonusPercent',
label = 'StatDesc_CritDamageBonusScale',
link = 'Weapon Damage#Crit_Multiplier',
icon = 'Crit reduction.png',
suffix = '%',
show = function(ctx)
return stat_number(ctx, 'CritDamageBonusPercent') ~= 0
end,
},
},
},
{
class = 'infobox-h-dropdown-vitality',
header = 'CitadelHeroStats_Vitality',
link = 'Stats#Vitality',
color = 'Green',
rows = {
{
stat = 'MaxHealth',
param = 'max_health',
label = 'StatDesc_HealthAttribute',
link = 'Health',
icon = 'Health.png',
},
{
stat = 'BaseHealthRegen',
param = 'health_regen',
label = 'StatDesc_HealthRegen',
link = 'Health Regen',
icon = 'Health regen.png',
sig_figs = 2,
},
{
stat = 'CritDamageReceivedPercent',
param = 'crit_reduction',
label = 'StatDesc_CritDamageReceivedScale',
link = 'Damage Resistance#Crit_Reduction',
icon = 'Crit reduction.png',
suffix = '%',
optional = true,
},
{
stat = 'BulletResist',
param = 'bullet_resist',
label = 'StatDesc_BulletArmorDamageReduction',
link = 'Damage Resistance',
icon = 'Bullet Armor.png',
icon_link = 'Bullet Resist',
suffix = '%',
optional = true,
},
{
stat = 'TechResist',
param = 'spirit_resist',
label = 'StatDesc_TechArmorDamageReduction',
link = 'Damage Resistance#Spirit_Resist',
icon = 'Spirit Armor.png',
suffix = '%',
optional = true,
},
{
stat = 'MeleeResist',
param = 'melee_resist',
label = 'StatDesc_MeleeResist',
link = 'Damage Resistance#Melee Resist',
icon = 'Melee damage.png',
suffix = '%',
optional = true,
},
{
stat = 'DebuffResist',
param = 'debuff_resist',
label = 'StatDesc_DebuffResist',
link = 'Debuff Resist',
icon = 'Spirit Armor.png',
suffix = '%',
optional = true,
},
{
stat = 'BulletLifesteal',
param = 'bullet_lifesteal',
label = 'StatDesc_BulletLifesteal',
link = 'Lifesteal#Bullet Lifesteal',
icon = 'AttributeIconBulletLifesteal.png',
suffix = '%',
optional = true,
},
{
stat = 'SpiritLifesteal',
param = 'spirit_lifesteal',
label = 'StatDesc_SpiritLifesteal',
link = 'Lifesteal#Spirit Lifesteal',
icon = 'AttributeIconBulletLifesteal.png',
suffix = '%',
optional = true,
},
{
stat = 'MaxMoveSpeed',
param = 'move_speed',
label = 'StatDesc_RunSpeed',
link = 'Move Speed',
icon = 'Move speed.png',
sig_figs = 2,
suffix = 'm/s',
},
{
stat = 'SprintSpeed',
param = 'sprint_speed',
label = 'StatDesc_SprintSpeed',
link = 'Move Speed#Sprint',
icon = 'Move speed sprint.png',
sig_figs = 2,
suffix = 'm/s',
},
{
stat = 'GroundDashSpeed',
label = 'DashSpeed_label',
link = 'Stamina#Dash_Speed',
icon = 'Springy boots icon.png',
suffix = 'm/s',
},
{
stat = 'Stamina',
param = 'stamina',
label = 'StatDesc_Stamina',
link = 'Stamina',
icon = 'Springy boots icon.png',
sig_figs = 2,
},
{
stat = 'StaminaCooldown',
param = 'stamina_cooldown',
label = 'StatDesc_StaminaCooldown',
link = 'Stamina#Cooldown',
icon = 'AttributeIconTechDuration.png',
sig_figs = 2,
suffix = 's',
},
},
},
{
class = 'infobox-h-dropdown-spirit',
header = 'CitadelHeroStats_Spirit',
link = 'Stats#Spirit',
color = 'Purple',
rows = {
{
stat = 'TechPower',
param = 'spirit_power',
label = 'SpiritPower_label',
link = 'Spirit Power',
icon = 'Spirit icon noborder.png',
sig_figs = 2,
},
},
},
}
-- The "Information" block. Manual input only; hidden entirely when all are empty.
local INFO_ROWS = {
{ param = 'pronouns', label_dict = 'Pronouns' },
{ param = 'voice_actor', label_dict = 'VoiceActor', link = 'Voice actors' },
{ param = 'release_date', label_dict = 'ReleaseDate', link = 'Heroes by release' },
{ param = 'codenames', label_dict = 'Codenames', tooltip = 'Codenames used for this hero in the game files' },
}
-- name: literal, or function(ctx) -> string. when: function(ctx) -> boolean.
local CATEGORIES = {
{ name = 'Heroes' },
{
name = function(ctx) return ctx.hero_en end,
-- Silver (Transformed) is documented on Silver's page and shares her category.
when = function(ctx) return ctx.hero_en ~= 'Silver (Transformed)' end,
},
{
name = 'Heroes with spreadshot weapons',
when = function(ctx) return ctx.pellets > 1 end,
},
{
name = 'Heroes with single damage spreadshot weapons',
when = function(ctx)
return ctx.pellets > 1 and get_stat(ctx.hero, 'HitOnceAcrossAllBullets') == true
end,
},
{
name = 'Heroes with burst weapons',
when = function(ctx) return ctx.burst > 1 end,
},
{
name = 'Heroes with automatic weapons',
when = function(ctx) return ctx.pellets <= 1 and ctx.burst <= 1 end,
},
{
name = 'Heroes with alternate fire weapons',
when = function(ctx)
return ctx.hero.Weapon ~= nil and ctx.hero.Weapon.AltFire ~= nil
end,
},
{
name = 'Heroes with single reload weapons',
when = function(ctx) return get_stat(ctx.hero, 'ReloadSingle') == true end,
},
{
name = 'Heroes with spin up weapons',
when = function(ctx) return has_stat(ctx.hero, 'SpinAcceleration') end,
},
{
name = 'Heroes with no weapon damage falloff',
when = function(ctx)
return (get_stat(ctx.hero, 'FalloffStartRange') or 0)
== (get_stat(ctx.hero, 'FalloffEndRange') or 0)
end,
},
{
name = "Heroes that don't deal critical damage",
when = function(ctx) return get_stat(ctx.hero, 'CanCrit') == false end,
},
{
name = 'Heroes with modified crit scale',
when = function(ctx) return stat_number(ctx, 'CritDamageBonusPercent') ~= 0 end,
},
{
name = 'Heroes with modified crit resistance',
when = function(ctx)
return has_override_or_stat(ctx, 'crit_reduction', 'CritDamageReceivedPercent')
end,
},
{
name = 'Heroes with modified bullet resist',
when = function(ctx)
return has_override_or_stat(ctx, 'bullet_resist', 'BulletResist')
end,
},
{
name = 'Heroes with modified spirit resist',
when = function(ctx)
return has_override_or_stat(ctx, 'spirit_resist', 'TechResist')
end,
},
{
name = 'Heroes with modified melee resist',
when = function(ctx)
return has_override_or_stat(ctx, 'melee_resist', 'MeleeResist')
end,
},
{
name = 'Heroes with modified debuff resist',
when = function(ctx)
return has_override_or_stat(ctx, 'debuff_resist', 'DebuffResist')
end,
},
{
name = 'Heroes with modified bullet lifesteal',
when = function(ctx)
return has_override_or_stat(ctx, 'bullet_lifesteal', 'BulletLifesteal')
end,
},
{
name = 'Heroes with modified spirit lifesteal',
when = function(ctx)
return has_override_or_stat(ctx, 'spirit_lifesteal', 'SpiritLifesteal')
end,
},
{
name = 'Heroes with modified gravity scale',
when = function(ctx) return has_stat(ctx.hero, 'GravityChange') end,
},
{
name = function(ctx) return 'Heroes with ' .. ctx.stamina .. ' stamina' end,
when = function(ctx) return ctx.stamina ~= '' end,
},
{
name = function(ctx)
if ctx.dash < 14.7 then return 'Heroes with slow dash speed' end
if ctx.dash > 14.7 then return 'Heroes with fast dash speed' end
return 'Heroes with medium dash speed'
end,
},
{
name = 'Heroes with Street Brawl changes',
when = function(ctx) return sb_module.hero_has_changes(ctx.hero_key) end,
},
}
--------------------------------------------------------------------------------
-- Data access
--------------------------------------------------------------------------------
has_override_or_stat = function(ctx, param, stat)
if param and ctx.args[param] ~= '' then return true end
return has_stat(ctx.hero, stat)
end
-- Rounded numeric value of a stat, honouring a |param= override.
stat_number = function(ctx, stat, param)
if param and ctx.args[param] ~= '' then return tonumber(ctx.args[param]) or 0 end
return tonumber(util.round_to_sig_fig(get_stat(ctx.hero, stat) or 0, DEFAULT_SIG_FIGS)) or 0
end
local function format_value(raw, sig_figs)
if raw == nil then return '0' end
local rounded = util.round_to_sig_fig(raw, sig_figs or DEFAULT_SIG_FIGS)
return tostring(rounded ~= nil and rounded or raw)
end
--------------------------------------------------------------------------------
-- Rendering helpers
--------------------------------------------------------------------------------
local function theme_var(hero_key, name)
local hero_theme = theme_data[hero_key]
if hero_theme and hero_theme[name] ~= nil then return hero_theme[name] end
return theme_data['hero_default'][name]
end
local function label_of(row)
if row.label_text then return row.label_text end
if row.label_dict then return dict_module.translate(row.label_dict) end
return lang_module.get_string(row.label)
end
-- 'Damage Resistance#Spirit_Resist' -> 'Damage Resistance/ru#Spirit_Resist'
local function page_link(ctx, target)
if not target then return '' end
local page, anchor = target:match('^([^#]*)(#?.*)$')
return page .. ctx.if_lang .. anchor
end
local function icon_cell(ctx, row, color, value_html)
local icon = ''
if row.icon then
icon = string.format('[[File:%s|20px|link=%s]]', row.icon,
page_link(ctx, row.icon_link or row.link))
end
return ctx.frame:expandTemplate{ title = 'Icon/' .. color, args = { icon, value_html } }
end
-- Ammo and reload time are meaningless for heroes whose weapon never reloads.
local NO_AMMO_STATS = { ClipSize = true, ReloadTime = true }
-- Returns true when the row rendered.
local function render_stat_row(ctx, tbl, row, color, is_first)
if row.show and not row.show(ctx) then return false end
if row.optional and not has_override_or_stat(ctx, row.param, row.stat) then return false end
local value, scaling_inner
if NO_AMMO_STATS[row.stat] and ctx.args.no_ammo == 'true' then
value, scaling_inner = 'N/A', ''
else
local override = row.param and ctx.args[row.param] or ''
if override ~= '' then
value = override
else
value = format_value(get_stat(ctx.hero, row.stat), row.sig_figs)
end
value = value .. (row.suffix or '')
if row.stat and row.scaling ~= false then
local parts = {}
for _, scaling in ipairs(get_scalings(ctx.hero, row.stat)) do
parts[#parts + 1] = hero_module.write_scalar_str(scaling.value, scaling.type)
end
scaling_inner = table.concat(parts)
value = value .. '<span class="infobox-h-spiritboon">' .. scaling_inner .. '</span>'
else
scaling_inner = ''
end
end
local tr = tbl:tag('tr')
local label_cell = tr:tag('td')
-- The section's first row needs extra headroom when it carries a scaling icon.
if is_first and scaling_inner ~= '' then
label_cell:addClass('infobox-h-stat-label-scaled')
end
if row.link then
label_cell:wikitext(string.format('[[%s|<span class="infobox-h-stat-label">%s</span>]]:',
page_link(ctx, row.link), label_of(row)))
else
label_cell:wikitext(label_of(row) .. ':')
end
local value_cell = tr:tag('td')
if row.cell_class then value_cell:addClass(row.cell_class) end
value_cell:wikitext(icon_cell(ctx, row, color, value))
return true
end
local function render_section(ctx, parent, section)
local tbl = parent:tag('tr'):tag('th')
:attr('colspan', '2')
:addClass('infobox-h-subsection infobox-h-dropdown ' .. section.class)
:tag('table'):addClass('mw-collapsible infobox-h-subsection-child')
local caption = tbl:tag('caption'):addClass('infobox-h-dropdown-header')
if ctx.if_lang == '/ru' then
caption:addClass('infobox-h-dropdown-header-overflow')
end
caption:wikitext(string.format(
'[[%s|<span class="infotext-h-bold infobox-h-dropdown-title">%s</span>]]',
page_link(ctx, section.link), lang_module.get_string(section.header)))
local is_first = true
for _, row in ipairs(section.rows) do
if render_stat_row(ctx, tbl, row, section.color, is_first) then
is_first = false
end
end
end
local function render_information(ctx, parent)
local present = {}
for _, row in ipairs(INFO_ROWS) do
if ctx.args[row.param] ~= '' then present[#present + 1] = row end
end
if #present == 0 then return end
parent:tag('tr'):tag('th')
:attr('colspan', '2')
:addClass('infobox-h-text-stat-subtitle')
:wikitext('<span class="infotext-h-bold infotext-h-title infotext-h-big">'
.. dict_module.translate('Information') .. '</span>')
local tbl = parent:tag('tr'):tag('th')
:attr('colspan', '2')
:addClass('infobox-h-subsection')
:tag('table'):addClass('infobox-h-subsection-child')
for _, row in ipairs(present) do
local tr = tbl:tag('tr')
local label = dict_module.translate(row.label_dict)
if row.tooltip then
label = ctx.frame:expandTemplate{ title = 'Tooltip', args = { label, row.tooltip } }
end
if row.link then
tr:tag('td'):wikitext(string.format('[[%s|<span class="infobox-h-stat-label">%s</span>]]:',
page_link(ctx, row.link), label))
else
tr:tag('td'):wikitext(label .. ':')
end
tr:tag('td'):wikitext(ctx.args[row.param])
end
end
local function render_title(ctx, parent)
local th = parent:tag('tr'):tag('th'):attr('colspan', '2'):addClass('infobox-h-title')
if ctx.args.name_font_size ~= '' then
th:cssText('font-size: ' .. ctx.args.name_font_size .. ';')
end
local wrap = th:tag('div'):addClass('infobox-h-title-hero')
wrap:tag('div'):addClass('infobox-h-title-hero-left')
-- Name logo: <Name>_name.svg, or <Name>_name_<lang>.svg on translated pages.
-- Heroes without a logo fall back to their name as plain text.
local svg = ((ctx.args.name ~= '' and ctx.args.name) or ctx.hero_en) .. '_name'
if ctx.if_lang ~= '' then svg = svg .. '_' .. ctx.lang_code end
local middle = wrap:tag('div'):addClass('infobox-h-title-hero-middle')
if util.page_exists('File:' .. svg .. '.svg') then
middle:wikitext(ctx.frame:expandTemplate{ title = 'Icon/White', args = {
string.format('[[File:%s.svg|%s|class=notpageimage]]',
svg, theme_var(ctx.hero_key, 'logo-height')) } })
else
middle:wikitext(ctx.display_name)
end
local right = wrap:tag('div'):addClass('infobox-h-title-hero-right'):tag('div')
if ctx.show_icon then
local icon = ((ctx.args.icon ~= '' and ctx.args.icon) or ctx.hero_en) .. '.png'
if util.page_exists('File:' .. icon) then
right:tag('div'):addClass('infobox-h-title-hero-tornbox')
right:tag('div'):addClass('infobox-h-title-hero-icon')
:wikitext(string.format('[[File:%s|center|35px|class=notpageimage]]', icon))
end
end
end
local function render_card(ctx, parent)
local card = (ctx.args.card ~= '' and ctx.args.card) or (ctx.hero_en .. ' card.png')
if not util.page_exists('File:' .. card) then card = FALLBACK_CARD end
parent:tag('tr'):tag('th')
:attr('colspan', '2')
:addClass('infobox-h-image')
:tag('div')
:wikitext(string.format('[[File:%s|150px|alt=%s]]',
card, mw.title.getCurrentTitle().baseText))
end
local function render_tags(ctx, parent)
if not ctx.show_hero_tags then return end
local tags = {}
for i = 1, 3 do
local tag = hero_module.hero_tag(ctx.hero_en, i)
if tag ~= '' then
tags[#tags + 1] = tag
end
end
if #tags == 0 then return end
local inner = parent:tag('tr'):tag('td')
:attr('colspan', '2')
:addClass('infobox-h-herotags-row')
:tag('div'):addClass('infobox-h-herotags-inner ' .. ctx.hero_key)
for _, tag in ipairs(tags) do
inner:tag('div'):addClass('infobox-h-herotag-pill'):wikitext(tag)
end
end
local function render_categories(ctx)
if mw.ustring.lower(ctx.args.nocat ~= '' and ctx.args.nocat or 'false') ~= 'false' then
return ''
end
if get_stat(ctx.hero, 'IsDisabled') ~= false then return '' end
local out = {}
for _, category in ipairs(CATEGORIES) do
if category.when == nil or category.when(ctx) then
local name = category.name
if type(name) == 'function' then name = name(ctx) end
out[#out + 1] = ctx.frame:expandTemplate{ title = 'lang cat', args = { name } }
end
end
return table.concat(out)
end
--------------------------------------------------------------------------------
-- Entry point
--------------------------------------------------------------------------------
function p.render(frame)
local parent = frame:getParent()
local invoke_args = frame.args
local template_args = parent and parent.args or {}
-- Missing parameters read as '' so callers never have to nil-check.
local args = setmetatable({}, { __index = function(_, k)
local value = invoke_args[k]
if value == nil then value = template_args[k] end
return value or ''
end })
-- |key= accepts a hero_* key or the hero's English name; unknown values fall
-- back to the target dummy, the default character in Sandbox mode.
local hero_key = hero_module.find_hero_key(args.key) or DEFAULT_HERO_KEY
local hero = hero_data_all[hero_key]
local ctx = {
frame = frame,
args = args,
hero = hero,
hero_key = hero_key,
hero_en = hero.Name,
lang_code = lang_module.get_lang_code(),
if_lang = frame:expandTemplate{ title = 'If lang' },
}
ctx.display_name = (args.name ~= '' and args.name) or lang_module.get_string(hero_key)
ctx.show_hero_tags = args.show_hero_tags ~= 'false'
ctx.show_icon = args.show_icon ~= 'false'
-- Needed by both row visibility and the category rules.
ctx.pellets = stat_number(ctx, 'BulletsPerShot', 'pellets_per_shot')
ctx.burst = stat_number(ctx, 'BulletsPerBurst', 'bullets_per_burst')
ctx.dash = stat_number(ctx, 'GroundDashSpeed')
ctx.stamina = (args.stamina ~= '' and args.stamina)
or format_value(get_stat(hero, 'Stamina'), 2)
local root = mw.html.create('div'):addClass('infobox-h ' .. hero_key)
root:tag('div'):addClass('infobox-h-background')
:wikitext(string.format('[[File:%s|%s|right|link=|class=notpageimage]]',
theme_var(hero_key, 'bg-image'), theme_var(hero_key, 'bg-height')))
local tbl = root:tag('table'):addClass('infobox-h-content')
render_title(ctx, tbl)
render_card(ctx, tbl)
render_tags(ctx, tbl)
for _, section in ipairs(SECTIONS) do
render_section(ctx, tbl, section)
end
render_information(ctx, tbl)
if mw.title.getCurrentTitle().nsText ~= 'Template' then
frame:callParserFunction('DISPLAYTITLE', ctx.display_name)
end
return tostring(root) .. render_categories(ctx)
end
--------------------------------------------------------------------------------
-- TEMPORARY render-diff harness - NOT part of the live module.
-- {{#invoke:Sandbox/Vergir|diff}} or |diff|hero_atlas,hero_viscous
--------------------------------------------------------------------------------
-- Kept small: each render makes several expensive page_exists calls, and the
-- parser allows only 100 per page.
local SAMPLE_HEROES = {
'hero_atlas', -- burst-ish baseline, has scaling on many stats
'hero_viscous', -- has an AltFire block
'hero_haze', -- spreadshot
'hero_werewolf', -- Silver, has a transformed variant
'hero_targetdummy',-- the fallback hero
}
function p.diff(frame)
local other = require('Module:InfoboxHero')
local keys = {}
if frame.args[1] and frame.args[1] ~= '' then
for key in string.gmatch(frame.args[1], '[^,%s]+') do
table.insert(keys, key)
end
else
keys = SAMPLE_HEROES
end
local checked, mismatches = 0, {}
for _, hero_key in ipairs(keys) do
local child = frame:newChild{ args = { key = hero_key } }
local ok_a, a = pcall(other.render, child)
local ok_b, b = pcall(p.render, child)
if not ok_a then a = 'ERR:' .. tostring(a) end
if not ok_b then b = 'ERR:' .. tostring(b) end
checked = checked + 1
if a ~= b then
table.insert(mismatches, hero_key .. ' (len ' .. #tostring(a) .. ' vs ' .. #tostring(b) .. ')')
end
end
return 'Heroes rendered through both modules: ' .. checked ..
'\n\nMismatches: ' .. #mismatches ..
(#mismatches > 0 and ('\n* ' .. table.concat(mismatches, '\n* ')) or '\n\nIdentical output.')
end
return p