local p = {}
local SECTION_ORDER = {
{ key = "通常", title = "通常", collapsed = false },
{ key = "战斗", title = "战斗", collapsed = false },
{ key = "内番", title = "内番", collapsed = true },
{ key = "习合", title = "习合", collapsed = true },
{ key = "特殊", title = "特殊", collapsed = true },
{ key = "活动", title = "活动", collapsed = true },
{ key = "周年", title = "周年", collapsed = true },
{ key = "手机版近待推送通知", title = "手机版近待推送通知", collapsed = true },
{ key = "手机版散步机制内台词", title = "手机版散步机制内台词", collapsed = true },
}
local MAX_ITEMS = 50
local MAX_LINES = 6
local MAX_LETTERS = 3
local config_ok, external_config = pcall(require, "Module:Sandbox/Sailors-bh/语音集3/参数配置")
if not config_ok or type(external_config) ~= "table" then
external_config = {}
end
local BUILTIN_AUDIO_SUFFIX = external_config.audio_suffix or {}
local BUILTIN_SECTION_ITEMS = external_config.section_items or {}
local function trim(value)
if value == nil then
return ""
end
return mw.text.trim(tostring(value))
end
local function collect_args(frame)
local merged = {}
local parent = frame:getParent()
if parent then
for key, value in pairs(parent.args) do
merged[key] = value
end
end
for key, value in pairs(frame.args) do
if trim(value) ~= "" then
merged[key] = value
end
end
return merged
end
local function get_arg(args, name)
return trim(args[name])
end
local function first_non_empty(args, names)
for _, name in ipairs(names) do
local value = trim(args[name])
if value ~= "" then
return value
end
end
return ""
end
local function clamp(value)
if value < 0 then
return 0
end
if value > 255 then
return 255
end
return math.floor(value + 0.5)
end
local function parse_hex_color(color)
color = trim(color)
local hex = color:match("^#?(%x%x%x%x%x%x)$")
if not hex then
return nil
end
return {
r = tonumber(hex:sub(1, 2), 16),
g = tonumber(hex:sub(3, 4), 16),
b = tonumber(hex:sub(5, 6), 16),
}
end
local function to_hex(rgb)
return string.format("#%02X%02X%02X", clamp(rgb.r), clamp(rgb.g), clamp(rgb.b))
end
local function mix_color(base, target, ratio)
return {
r = base.r + (target.r - base.r) * ratio,
g = base.g + (target.g - base.g) * ratio,
b = base.b + (target.b - base.b) * ratio,
}
end
local function relative_luminance(rgb)
local function channel(value)
value = value / 255
if value <= 0.03928 then
return value / 12.92
end
return ((value + 0.055) / 1.055) ^ 2.4
end
return 0.2126 * channel(rgb.r) + 0.7152 * channel(rgb.g) + 0.0722 * channel(rgb.b)
end
local function contrast_ratio(rgb_a, rgb_b)
local lum_a = relative_luminance(rgb_a)
local lum_b = relative_luminance(rgb_b)
local lighter = math.max(lum_a, lum_b)
local darker = math.min(lum_a, lum_b)
return (lighter + 0.05) / (darker + 0.05)
end
local function build_palette(args)
local color = first_non_empty(args, { "代表色", "header-color", "accent-color" })
local background_color = first_non_empty(args, { "背景色", "header-bg-color", "background-color" })
local rgb = parse_hex_color(color) or parse_hex_color("#C77B9B")
local header_bg_rgb = parse_hex_color(background_color)
local white = { r = 255, g = 255, b = 255 }
local dark = { r = 31, g = 41, b = 55 }
local default_header_bg_rgb = mix_color(rgb, white, 0.84)
local effective_header_bg_rgb = header_bg_rgb or default_header_bg_rgb
local header_title_rgb = rgb
if contrast_ratio(header_title_rgb, effective_header_bg_rgb) < 2.2 then
if relative_luminance(effective_header_bg_rgb) < 0.45 then
header_title_rgb = mix_color(rgb, white, 0.58)
else
header_title_rgb = mix_color(rgb, dark, 0.58)
end
end
return {
accent = to_hex(rgb),
header_fg = relative_luminance(rgb) > 0.45 and "#1F2937" or "#FFFFFF",
header_title = to_hex(header_title_rgb),
border = to_hex(mix_color(rgb, white, 0.45)),
body_bg = to_hex(mix_color(rgb, white, 0.88)),
top_bg = to_hex(header_bg_rgb or mix_color(rgb, white, 0.78)),
bottom_bg = to_hex(header_bg_rgb or mix_color(rgb, white, 0.9)),
item_border = to_hex(mix_color(rgb, white, 0.65)),
item_title = to_hex(mix_color(rgb, { r = 31, g = 41, b = 55 }, 0.28)),
muted_bg = to_hex(mix_color(rgb, white, 0.94)),
muted_border = to_hex(mix_color(rgb, white, 0.78)),
muted_label = to_hex(mix_color(rgb, white, 0.35)),
muted_text = to_hex(mix_color(rgb, { r = 75, g = 85, b = 99 }, 0.7)),
}
end
local function template_call(name, positional, named)
local parts = { "{{", name }
positional = positional or {}
named = named or {}
for _, value in ipairs(positional) do
if trim(value) ~= "" then
parts[#parts + 1] = "|" .. value
end
end
for key, value in pairs(named) do
if trim(value) ~= "" or value == "" then
parts[#parts + 1] = "|" .. key .. "=" .. tostring(value)
end
end
parts[#parts + 1] = "}}"
return table.concat(parts)
end
local function preprocess(frame, text)
text = trim(text)
if text == "" then
return ""
end
return frame:preprocess(text)
end
local function lang_ja(frame, text)
text = trim(text)
if text == "" then
return ""
end
return frame:expandTemplate{
title = "lang",
args = {
["1"] = "ja",
["2"] = text,
}
}
end
local function maybe_poem(frame, text)
text = trim(text)
if text == "" then
return ""
end
return frame:extensionTag{
name = "poem",
content = text,
}
end
local function maybe_blackout(frame, text)
text = trim(text)
if text == "" then
return ""
end
return frame:expandTemplate{
title = "黑幕",
args = {
["1"] = text,
}
}
end
local function is_blackout_tone(tone)
tone = trim(tone)
return tone == "blackout"
or tone == "heimu"
or tone == "hidden"
or tone == "黑幕"
or tone == "已删"
or tone == "deleted"
end
local function resolve_audio(args, prefix, base_name, suffix)
local explicit = get_arg(args, prefix .. "语音")
if explicit ~= "" then
return explicit
end
base_name = trim(base_name)
suffix = trim(suffix)
if base_name == "" or suffix == "" then
return ""
end
return base_name .. suffix
end
local function normalize_audio_suffix(suffix)
suffix = trim(suffix)
if suffix == "" then
return ""
end
if suffix:find("%.[A-Za-z0-9]+$") then
return suffix
end
return suffix .. ".mp3"
end
local function get_builtin_suffix(section_key, title, label)
local section_map = BUILTIN_AUDIO_SUFFIX[trim(section_key)]
if type(section_map) ~= "table" then
return ""
end
local mapped = section_map[trim(title)]
if type(mapped) == "string" then
return mapped
end
if type(mapped) == "table" then
local normalized_label = trim(label)
return mapped[normalized_label] or ""
end
return ""
end
local function first_from_keys(args, names)
return first_non_empty(args, names or {})
end
local function resolve_builtin_audio(args, explicit_keys, base_name, builtin_suffix, fallback_suffix)
local explicit = first_from_keys(args, explicit_keys)
if explicit ~= "" then
return explicit
end
base_name = trim(base_name)
if base_name == "" then
return ""
end
local suffix = normalize_audio_suffix(builtin_suffix)
if suffix ~= "" then
return base_name .. suffix
end
suffix = normalize_audio_suffix(fallback_suffix)
if suffix ~= "" then
return base_name .. suffix
end
return ""
end
local function make_audio_button(frame, audio)
audio = trim(audio)
if audio == "" then
return ""
end
return frame:expandTemplate{
title = "音频按钮",
args = {
["audio"] = "File:" .. audio,
["btn"] = "",
}
}
end
local function build_single(args, prefix, base_name)
local jp = get_arg(args, prefix .. "-jp")
local zh = get_arg(args, prefix .. "-zh")
if jp == "" and zh == "" then
return nil
end
return {
kind = "single",
title = first_non_empty(args, { prefix .. "标题", prefix .. "title" }),
jp = jp,
zh = zh,
poem = get_arg(args, prefix .. "样式") == "poem",
tone = first_non_empty(args, { prefix .. "状态", prefix .. "tone" }),
audio = resolve_audio(args, prefix, base_name, get_arg(args, prefix .. "后缀")),
}
end
local function build_lines(args, prefix, base_name)
local lines = {}
for line_index = 1, MAX_LINES do
local line_prefix = string.format("%s-%d", prefix, line_index)
local jp = get_arg(args, line_prefix .. "-jp")
local zh = get_arg(args, line_prefix .. "-zh")
if jp ~= "" or zh ~= "" then
lines[#lines + 1] = {
label = first_non_empty(args, { line_prefix .. "标签", line_prefix .. "label" }),
jp = jp,
zh = zh,
poem = get_arg(args, line_prefix .. "样式") == "poem",
tone = first_non_empty(args, { line_prefix .. "状态", line_prefix .. "tone" }),
audio = resolve_audio(args, line_prefix, base_name, get_arg(args, line_prefix .. "后缀")),
}
end
end
if #lines == 0 then
return nil
end
return lines
end
local function build_multi_like(args, prefix, item_type, base_name)
local lines = build_lines(args, prefix, base_name)
if not lines then
return nil
end
return {
kind = item_type,
title = first_non_empty(args, { prefix .. "标题", prefix .. "title" }),
lines = lines,
}
end
local function build_title_override_single(args, prefix, default_title, base_name)
local jp = get_arg(args, prefix .. "-jp")
local zh = get_arg(args, prefix .. "-zh")
if jp == "" and zh == "" then
return nil
end
return {
kind = "single",
title = first_non_empty(args, { prefix .. "标题", prefix .. "title" }) ~= ""
and first_non_empty(args, { prefix .. "标题", prefix .. "title" })
or default_title,
jp = jp,
zh = zh,
poem = get_arg(args, prefix .. "样式") == "poem",
tone = first_non_empty(args, { prefix .. "状态", prefix .. "tone" }),
audio = resolve_audio(args, prefix, base_name, get_arg(args, prefix .. "后缀")),
}
end
local function build_title_override_multi(args, prefix, default_title, item_type, base_name)
local lines = {}
for line_index = 1, MAX_LINES do
local line_prefix = string.format("%s-%d", prefix, line_index)
local jp = get_arg(args, line_prefix .. "-jp")
local zh = get_arg(args, line_prefix .. "-zh")
if jp ~= "" or zh ~= "" then
lines[#lines + 1] = {
label = first_non_empty(args, { line_prefix .. "标签", line_prefix .. "label" }),
jp = jp,
zh = zh,
poem = get_arg(args, line_prefix .. "样式") == "poem",
tone = first_non_empty(args, { line_prefix .. "状态", line_prefix .. "tone" }),
audio = resolve_audio(args, line_prefix, base_name, get_arg(args, line_prefix .. "后缀")),
}
end
end
if #lines == 0 then
return nil
end
return {
kind = item_type,
title = first_non_empty(args, { prefix .. "标题", prefix .. "title" }) ~= ""
and first_non_empty(args, { prefix .. "标题", prefix .. "title" })
or default_title,
lines = lines,
}
end
local function build_title_override(args, schema, base_name)
local prefix = schema.title
local item_type = first_non_empty(args, { prefix .. "类型", prefix .. "type" })
if item_type == "" then
return nil
end
if item_type == "single" then
return build_title_override_single(args, prefix, schema.title, base_name)
end
if item_type == "multi" or item_type == "variant" then
return build_title_override_multi(args, prefix, schema.title, item_type, base_name)
end
return nil
end
local function build_item(args, section_key, item_index, base_name)
local prefix = string.format("%s%d", section_key, item_index)
local item_type = first_non_empty(args, { prefix .. "类型", prefix .. "type" })
if item_type == "" then
return nil
end
if item_type == "single" then
return build_single(args, prefix, base_name)
end
if item_type == "multi" or item_type == "variant" then
return build_multi_like(args, prefix, item_type, base_name)
end
return nil
end
local function build_builtin_single(args, section_key, schema, base_name)
local jp = first_from_keys(args, schema.jp)
local zh = first_from_keys(args, schema.zh)
if jp == "" and zh == "" then
return nil
end
return {
kind = "single",
title = schema.title,
jp = jp,
zh = zh,
poem = schema.poem == true,
tone = schema.tone or "",
audio = resolve_builtin_audio(
args,
schema.audio,
base_name,
get_builtin_suffix(section_key, schema.title, ""),
schema.suffix
),
}
end
local function build_builtin_multi(args, section_key, schema, base_name, kind)
local lines = {}
for index, line_schema in ipairs(schema.lines or {}) do
local jp = first_from_keys(args, line_schema.jp)
local zh = first_from_keys(args, line_schema.zh)
if jp ~= "" or zh ~= "" then
local label = line_schema.label or ""
lines[#lines + 1] = {
label = label,
jp = jp,
zh = zh,
poem = line_schema.poem == true,
tone = line_schema.tone or "",
audio = resolve_builtin_audio(
args,
line_schema.audio,
base_name,
get_builtin_suffix(section_key, schema.title, label ~= "" and label or tostring(index)),
line_schema.suffix
),
}
end
end
if #lines == 0 then
return nil
end
return {
kind = kind,
title = schema.title,
lines = lines,
}
end
local function build_builtin_items(args, section_key, base_name)
local schemas = BUILTIN_SECTION_ITEMS[section_key]
if type(schemas) ~= "table" then
return {}
end
local items = {}
for _, schema in ipairs(schemas) do
local item = build_title_override(args, schema, base_name)
if not item then
if schema.kind == "single" then
item = build_builtin_single(args, section_key, schema, base_name)
elseif schema.kind == "multi" or schema.kind == "variant" then
item = build_builtin_multi(args, section_key, schema, base_name, schema.kind)
end
end
if item then
items[#items + 1] = item
end
end
return items
end
local function has_voice_sections(args, base_name)
for _, section_def in ipairs(SECTION_ORDER) do
for item_index = 1, MAX_ITEMS do
if build_item(args, section_def.key, item_index, base_name) then
return true
end
end
if #build_builtin_items(args, section_def.key, base_name) > 0 then
return true
end
end
return false
end
local function get_letter_field(args, index, suffixes)
local names = {}
for _, suffix in ipairs(suffixes) do
names[#names + 1] = string.format("书信%d%s", index, suffix)
end
return first_non_empty(args, names)
end
local function build_letters(args)
local letters = {}
for index = 1, MAX_LETTERS do
local jp = get_letter_field(args, index, { "-jp", "原文", "内容" })
local zh = get_letter_field(args, index, { "-zh", "译文", "翻译" })
if jp ~= "" or zh ~= "" then
letters[#letters + 1] = {
label = get_letter_field(args, index, { "标题", "标签" }),
jp = jp,
zh = zh,
}
end
end
return letters
end
local function make_toggle_key(seed)
local bytes = {}
for index = 1, #seed do
bytes[#bytes + 1] = string.format("%02X", string.byte(seed, index))
end
return "tkrb" .. table.concat(bytes)
end
local function render_section_header(parent, title, palette, toggle_key)
local summary = parent:tag("div")
:addClass("mw-customtoggle-" .. toggle_key)
:css("cursor", "pointer")
:css("line-height", "1.2")
:css("background", palette.accent)
:css("color", palette.header_fg)
:css("padding", "0.62rem 0.85rem")
:css("font-weight", "700")
:css("text-align", "center")
:css("position", "relative")
:css("min-height", "1.25rem")
summary:tag("span")
:css("display", "block")
:css("padding", "0 1.2rem")
:wikitext(title)
end
local function render_collapsible_body(parent, palette, toggle_key, collapsed)
local body = parent:tag("div")
:attr("id", "mw-customcollapsible-" .. toggle_key)
:addClass("mw-collapsible")
if collapsed then
body:addClass("mw-collapsed")
end
return body:tag("div")
:css("padding", "0.28rem 0.7rem 0.42rem 0.7rem")
:css("display", "grid")
:css("gap", "0")
end
local function render_row_shell(parent, opts)
opts = opts or {}
local row = parent:tag("div")
:css("padding", opts.padding or "0.42rem 0.1rem")
if opts.border_top ~= false then
row:css("border-top", "1px solid " .. (opts.border_color or "#d8dee6"))
end
if opts.opacity then
row:css("opacity", opts.opacity)
end
return row
end
local function render_item_title(parent, title, palette)
parent:tag("div")
:css("display", "flex")
:css("justify-content", "center")
:css("align-items", "center")
:css("width", "100%")
:css("min-height", "100%")
:css("font-weight", "700")
:css("color", palette.item_title)
:css("line-height", "1.45")
:css("text-align", "center")
:css("justify-self", "stretch")
:css("align-self", "center")
:wikitext(title ~= "" and title or "未命名条目")
end
local function render_text_block(frame, parent, jp, zh, opts)
opts = opts or {}
local wrapper = parent:tag("div")
:css("display", "grid")
:css("gap", "0.12rem")
if trim(jp) ~= "" then
local jpBlock = wrapper:tag("div")
local jpText = lang_ja(frame, jp)
if opts.poem then
jpText = maybe_poem(frame, jpText)
end
if opts.blackout then
jpText = maybe_blackout(frame, jpText)
end
jpBlock:tag("div"):wikitext(jpText)
end
if trim(zh) ~= "" then
local zhBlock = wrapper:tag("div")
local zhText = trim(zh)
if opts.poem then
zhText = maybe_poem(frame, zhText)
end
if opts.blackout then
zhText = maybe_blackout(frame, zhText)
end
zhBlock:tag("div"):wikitext(zhText)
end
end
local function render_audio_slot(frame, parent, audio)
local slot = parent:tag("div")
:css("display", "flex")
:css("align-items", "center")
:css("justify-content", "center")
:css("justify-self", "center")
:css("align-self", "center")
:css("min-width", "4rem")
if trim(audio) ~= "" then
slot:wikitext(make_audio_button(frame, audio))
end
end
local function render_single_item(frame, parent, item, palette, is_first)
local item_is_muted = item.tone == "muted"
local item_is_blackout = is_blackout_tone(item.tone)
local row = render_row_shell(parent, {
border_top = not is_first,
padding = "0.28rem 0.08rem 0.38rem 0.08rem",
opacity = item_is_muted and "0.82" or nil,
})
local content = row:tag("div")
:css("display", "grid")
:css("grid-template-columns", "minmax(4.6rem, 5.8rem) 1fr auto")
:css("gap", "0.55rem")
:css("align-items", "start")
render_item_title(content, item.title, palette)
render_text_block(frame, content, item.jp, item.zh, {
poem = item.poem,
blackout = item_is_blackout,
})
render_audio_slot(frame, content, item.audio)
end
local function render_multi_item(frame, parent, item, palette, is_first)
local is_variant = item.kind == "variant"
local block = render_row_shell(parent, {
border_top = not is_first,
padding = is_variant and "0.24rem 0.08rem 0.34rem 0.08rem" or "0.28rem 0.08rem 0.34rem 0.08rem",
opacity = is_variant and "0.96" or nil,
})
local header_row = block:tag("div")
:css("display", "grid")
:css("grid-template-columns", "minmax(4.6rem, 5.8rem) 1fr")
:css("gap", "0.55rem")
:css("align-items", "start")
:css("padding-bottom", "0.05rem")
render_item_title(header_row, item.title, palette)
local list = header_row:tag("div")
:css("display", "grid")
:css("gap", "0")
for line_index, line in ipairs(item.lines) do
local show_label = line.label ~= "" and not line.label:match("^%d+$")
local line_is_muted = line.tone == "muted"
local line_is_blackout = is_blackout_tone(line.tone)
local row = list:tag("div")
:css("display", "grid")
:css("grid-template-columns", (show_label and "3rem 1fr auto" or "1fr auto"))
:css("gap", "0.5rem")
:css("align-items", "start")
:css("padding", line_index == 1 and "0.12rem 0 0.24rem 0" or "0.24rem 0")
if line_index > 1 then
row:css("border-top", "1px dashed " .. (line_is_muted and palette.muted_border or "#d1d5db"))
end
if line_is_muted then
row:css("opacity", "0.82")
end
if show_label then
row:tag("div")
:css("font-weight", "600")
:css("color", line_is_muted and palette.muted_label or "#6b7280")
:wikitext(line.label)
end
local text_host = row:tag("div")
if line_is_muted then
text_host:css("color", palette.muted_text)
end
render_text_block(frame, text_host, line.jp, line.zh, {
poem = line.poem,
blackout = line_is_blackout,
})
render_audio_slot(frame, row, line.audio)
end
end
local function render_item(frame, parent, item, palette, is_first)
if item.kind == "single" then
render_single_item(frame, parent, item, palette, is_first)
return
end
render_multi_item(frame, parent, item, palette, is_first)
end
local function render_section(frame, parent, section_def, args, base_name, palette)
local items = {}
for item_index = 1, MAX_ITEMS do
local item = build_item(args, section_def.key, item_index, base_name)
if item then
items[#items + 1] = item
end
end
if #items == 0 then
items = build_builtin_items(args, section_def.key, base_name)
end
if #items == 0 then
return
end
local card = parent:tag("div")
:css("border", "1px solid " .. palette.border)
:css("border-radius", "16px")
:css("overflow", "hidden")
:css("background", palette.body_bg)
:css("margin-bottom", "1rem")
local toggle_key = make_toggle_key("section-" .. section_def.key)
render_section_header(card, section_def.title, palette, toggle_key)
local body = render_collapsible_body(card, palette, toggle_key, section_def.collapsed)
for index, item in ipairs(items) do
render_item(frame, body, item, palette, index == 1)
end
end
local function render_letters(frame, parent, args, palette)
local letters = build_letters(args)
if #letters == 0 then
return false
end
local card = parent:tag("div")
:css("border", "1px solid " .. palette.border)
:css("border-radius", "16px")
:css("overflow", "hidden")
:css("background", palette.body_bg)
:css("margin-bottom", "1rem")
local toggle_key = make_toggle_key("letters")
render_section_header(card, "修行书信", palette, toggle_key)
local body_inner = render_collapsible_body(card, palette, toggle_key, true)
for index, letter in ipairs(letters) do
local row = render_row_shell(body_inner, {
border_top = index ~= 1,
padding = "0.3rem 0.08rem 0.4rem 0.08rem",
})
local content = row:tag("div")
:css("display", "grid")
:css("grid-template-columns", "minmax(4.6rem, 5.8rem) 1fr 1fr")
:css("gap", "0.6rem")
:css("align-items", "start")
render_item_title(content, letter.label ~= "" and letter.label or ("第" .. tostring(index) .. "封"), palette)
render_text_block(frame, content, letter.jp, "", { poem = true })
render_text_block(frame, content, "", letter.zh, { poem = true })
end
return true
end
function p.main(frame)
local args = collect_args(frame)
local title = first_non_empty(args, { "__title", "标题", "刀剑男士" })
local base_name = get_arg(args, "name")
local palette = build_palette(args)
local has_letters = #build_letters(args) > 0
local has_voice = has_voice_sections(args, base_name)
local heading_suffix = has_letters and "修行书信" or "语音集"
local root = mw.html.create("div")
:addClass("tkrb-voice-lua-light-prototype")
:css("display", "grid")
:css("gap", "0.7rem")
local header = root:tag("div")
:css("padding", "0.72rem 0.9rem")
:css("border-radius", "18px")
:css("background", "linear-gradient(135deg, " .. palette.top_bg .. " 0%, " .. palette.bottom_bg .. " 100%)")
:css("border", "1px solid " .. palette.border)
header:tag("div")
:css("font-size", "1.16rem")
:css("font-weight", "700")
:css("color", palette.header_title or palette.accent)
:css("text-align", "center")
:wikitext((title ~= "" and title or "刀剑男士") .. heading_suffix)
header:tag("div")
:css("margin-top", "0.15rem")
:css("font-size", "0.88rem")
:css("color", "#6b7280")
:css("text-align", "center")
:wikitext("")
local sections = root:tag("div")
for _, section_def in ipairs(SECTION_ORDER) do
render_section(frame, sections, section_def, args, base_name, palette)
end
render_letters(frame, sections, args, palette)
return tostring(root)
end
return p