Module:曲师列表

Admin​(留言 | 贡献)2026年10月1日 (四) 21:21的版本 (署名原名与显示名一致时,不再输出多余的「名义:X」备注)

此模块的文档可以在Module:曲师列表/doc创建

-- 模块:曲师列表
-- 数据源: 模板:曲目列表.json (歌曲的 曲师 字段) + 模板:曲师映射.json (显示名/简介/排序键)
-- 结构参照 Arcaea 中文维基 曲师列表: 按名字顺序分节, 每节 = 手工简介 + 作品表
-- 排序: 分组为 符号 → 拉丁字母 → 假名 → 汉字, 组内按名字; 可用映射表 "排序键" 覆盖
local p = {}

local DATA_TITLE = '模板:曲目列表.json'
local validator = require('模块:数据页校验')

local MAP_TITLE = '模板:曲师映射.json'
local INTRO_TITLE = '模板:曲师简介'   -- 手工简介数据页(可编辑 wikitext)

local DIFFS = { 'CASUAL', 'TOUGH', 'INSANE', 'PRO' }

-- 拆词与归一化统一由 模块:署名解析 提供(本模块不再自带一份,避免规则漂移)
local parse = require('模块:署名解析')
local norm = parse.norm

-- ===== 两段式名义表(映射页新结构: 主名义 / 合作名义)=====
-- 新结构下: 署名整串查表 → 归并到主名义; 查不到则原样。旧结构(平表)保持原逻辑,便于平滑切换。
local function buildLookup(data)
	if type(data) ~= 'table' or type(data['主名义']) ~= 'table' then return nil end
	local alias, collab, extra = {}, {}, {}
	for _, m in pairs(data['主名义']) do                     -- 第一趟: 显示名/写法
		if type(m) == 'table' and m['显示名'] then
			local dn = m['显示名']
			extra[parse.norm(dn)] = m
			alias[parse.norm(dn)] = dn
			for _, w in ipairs(m['_写法'] or {}) do alias[parse.norm(w)] = dn end
		end
	end
	for _, m in pairs(data['主名义']) do                     -- 第二趟: 马甲优先(后写覆盖)
		if type(m) == 'table' and m['显示名'] then
			for _, a in ipairs(m['马甲'] or {}) do alias[parse.norm(a)] = m['显示名'] end
		end
	end
	for _, u in pairs(data['未确认名义'] or {}) do          -- 第三段: 主名义未确认的马甲
		if type(u) == 'table' and u['名义'] then
			local dn = u['名义']
			local e = { ['显示名'] = dn, ['马甲'] = {}, ['_未确认'] = true }
			extra[parse.norm(dn)] = e
			alias[parse.norm(dn)] = dn
		end
	end
	for _, c in pairs(data['合作名义']) do
		if type(c) == 'table' and c['名义'] then
			collab[parse.norm(c['名义'])] = c['组合'] or {}
		end
	end
	return { alias = alias, collab = collab, extra = extra }
end

-- 把一个署名原串解析成归属列表: 合作名义 → 每位成员; 马甲 → 主名义(备注里标出名义); 其余 → 原样
local function attribute(lk, raw)
	if not lk then return nil end
	local key = parse.norm(raw)
	local c = lk.collab[key]
	if c then
		local out = {}
		for _, m in ipairs(c) do out[#out + 1] = { base = m, ann = '', other = c } end
		return out
	end
	local m = lk.alias[key]
	if m then
		-- 原名与显示名一致时不加「名义」备注
		local ann = (parse.norm(raw) == parse.norm(m)) and '' or ('名义:' .. raw)
		return { { base = m, ann = ann } }
	end
	return { { base = raw, ann = '' } }
end

-- ===== 工具 =====
local function esc(s)
	return (tostring(s or ''):gsub('|', '|'))
end

-- 链接目标: # 是 MediaWiki 的锚点分隔符, 标题里一律换成全角 #
-- 优先用「链接名」字段; 没有就回退到曲名并自动替换 (与 模块:曲目列表 保持一致)
local function pageName(song, suffix)
	local n = song['链接名'] or song['曲名']
	if suffix then
		n = n .. '(' .. suffix .. ')'
	end
	return (n:gsub('#', '#'))
end


-- 排序分组: 0=符号/数字 1=拉丁 2=假名 3=汉字 4=其他
local function groupRank(name)
	local cp = mw.ustring.codepoint(name, 1, 1)
	if not cp then return 9 end
	if (cp >= 0x30 and cp <= 0x39) or cp < 0x30 then return 0 end
	if (cp >= 0x41 and cp <= 0x5A) or (cp >= 0x61 and cp <= 0x7A) then return 1 end
	if (cp >= 0x3040 and cp <= 0x30FF) then return 2 end
	if (cp >= 0x3400 and cp <= 0x9FFF) then return 3 end
	return 4
end

local function langWrap(song)
	local lang = song['lang']
	if lang == 'ja' then
		return '{{lang|ja|' .. esc(song['曲名']) .. '}}'
	elseif lang == 'zh-Hant' then
		return '{{lang|zh-Hant|' .. esc(song['曲名']) .. '}}'
	end
	return esc(song['曲名'])
end

local function loadJson(title)
	local t = mw.title.new(title)
	if not t or not t.exists then return nil end
	local ok, data = pcall(mw.text.jsonDecode, t:getContent())
	if not ok then return nil end
	return data
end

-- 解析简介数据页: 每个 == 名字 == 小节为该名字的介绍(wikitext 原样输出)
local function loadIntros()
	local t = mw.title.new(INTRO_TITLE)
	if not t or not t.exists then return {} end
	local content = (t:getContent() or ''):gsub('<noinclude>.-</noinclude>', '')
	local map, cur = {}, nil
	for line in mw.text.gsplit(content, '\n') do
		local h = line:match('^==%s*(.-)%s*==%s*$')
		if h then
			cur = norm(h)
			map[cur] = ''
		elseif cur then
			map[cur] = map[cur] .. line .. '\n'
		end
	end
	for k, v in pairs(map) do
		v = mw.text.trim(v)
		v = v:gsub('<!%-%-.-%-%->', '')   -- 去掉占位注释
		map[k] = mw.text.trim(v)
	end
	return map
end

local function loadSongs()
	local data, err = validator.load(DATA_TITLE)
	if not data then return nil, err end
	local all = {}
	for _, s in ipairs(data.songs or {}) do all[#all + 1] = s end
	for _, s in ipairs(data.special or {}) do all[#all + 1] = s end   -- 站长要求: special(石墩子)也计入曲师列表
	return all
end

-- ===== 主渲染 =====
-- ===== 自动排行榜(子页面用): {{#invoke:曲师列表|rank|min=3}} =====
-- 实时统计每位主名义收录的曲目数;并列名次用 rowspan 合并;名字链到曲师列表对应小节
function p.rank(frame)
	local args = frame.args or {}
	local minN = tonumber(args['min']) or 3
	local mode = args['mode'] or '署名'
	local data = validator.load(DATA_TITLE)
	if not data then return '<span class="error">数据页错误</span>' end
	local map = loadJson(MAP_TITLE) or {}
	local lk = buildLookup(map)
	local roles = loadJson('模板:曲师参与.json') or {}
	local sets, display = {}, {}
	local function touch(key, name)
		if not sets[key] then sets[key] = {}; display[key] = name end
	end
	for _, song in ipairs(data.songs or {}) do
		local raw = song['曲师']
		if type(raw) == 'table' then raw = raw[1] end
		local parts = attribute(lk, raw) or parse.parts(raw)
		local seen = {}
		for _, pt in ipairs(parts) do
			local key = norm(pt.base)
			if key ~= '' and not seen[key] then
				seen[key] = true
				local m = (lk and lk.extra[key]) or map[key] or {}
				touch(key, m['显示名'] or pt.base)
				sets[key][song['曲名']] = true              -- 署名归属
			end
		end
		if mode == '参与' then
			for ak, songs in pairs(roles) do
				if ak ~= '_说明' and type(songs) == 'table' and songs[song['曲名']] then
					local m = (lk and lk.extra[ak]) or map[ak] or {}
					touch(ak, m['显示名'] or ak)
					sets[ak][song['曲名']] = true           -- 参与记录
				end
			end
		end
	end
	local counts = {}
	for k, set in pairs(sets) do
		local n = 0
		for _ in pairs(set) do n = n + 1 end
		counts[k] = n
	end
	local byCount, nums = {}, {}
	for k, c in pairs(counts) do
		if c >= minN then
			byCount[c] = byCount[c] or {}
			table.insert(byCount[c], k)
		end
	end
	for c in pairs(byCount) do table.insert(nums, c) end
	table.sort(nums, function(a, b) return a > b end)
	local out = { '{| class="wikitable"', '|+收录曲数量排行榜(' .. mode .. ')', '!名次!!数量!!名字' }
	local emitted = 0
	for _, c in ipairs(nums) do
		local list = byCount[c]
		table.sort(list, function(a, b)
			return mw.ustring.lower(display[a]) < mw.ustring.lower(display[b])
		end)
		local r = emitted + 1
		for i, k in ipairs(list) do
			table.insert(out, '|-')
			if i == 1 then
				table.insert(out, '| rowspan="' .. #list .. '" |' .. r)
				table.insert(out, '| rowspan="' .. #list .. '" |' .. c)
			end
			table.insert(out, '| [[' .. '曲师列表#' .. esc(display[k]) .. '|' .. esc(display[k]) .. ']]')
		end
		emitted = emitted + #list
	end
	table.insert(out, '|}')
	if emitted == 0 then return '<span class="error">没有达到门槛(' .. minN .. ')的曲师</span>' end
	return frame:preprocess(table.concat(out, '\n'))
end

function p.render(frame)
	local songs, songsErr = loadSongs()
	if not songs then
		return songsErr
	end
	local map = loadJson(MAP_TITLE) or {}
	local roles = loadJson('模板:曲师参与.json') or {}   -- 参与(作曲/作词/人声…):人工知识,并入备注
	local lk = buildLookup(map)
	local intros = loadIntros()

	-- 归集: key -> { display, intro, sort, songs = { {song=..., ann=...} } }
	local artists, order = {}, {}
	for _, song in ipairs(songs) do
		local raw = song['曲师']
		if type(raw) == 'table' then raw = raw[1] end
		local parts = attribute(lk, raw) or parse.parts(raw)
		local seen = {}
		for _, pt in ipairs(parts) do
			local key = norm(pt.base)
			if key ~= '' and not seen[key] then
				seen[key] = true
				if not artists[key] then
					local m = (lk and lk.extra[key]) or map[key] or {}
					artists[key] = {
						display = m['显示名'] or pt.base,
						intro = (m['简介'] and m['简介'] ~= '') and m['简介']
							or intros[key] or intros[norm(m['显示名'] or pt.base)] or '',
						sort = m['排序键'] or '',
						unconfirmed = m['_未确认'] or false,
						songs = {},
					}
					table.insert(order, key)
				end
				table.insert(artists[key].songs, { song = song, ann = pt.ann, base = pt.base, raw = raw, other = pt.other })
			end
		end
	end

	local function sortKey(key)
		local a = artists[key]
		if a.sort ~= '' then return '0' .. a.sort end
		return string.format('%d_%s', groupRank(a.display), mw.ustring.lower(a.display))
	end
	table.sort(order, function(x, y) return sortKey(x) < sortKey(y) end)

	local out = {}
	if not (frame.args and frame.args['intro'] == '0') then
		table.insert(out, "'''曲师列表''':本列表按名字顺序整理了参与游戏曲目制作的曲师及其作品。")
		table.insert(out, '若某首歌曲由多人合作完成,会分别列在每位曲师的条目下;'
			.. '同一人的不同写法(含马甲)统一列在最常见的名称之下。')
		table.insert(out, '曲包与曲目一览见[[曲目列表]]与[[曲包列表]]。\n')
	end

	for _, key in ipairs(order) do
		local a = artists[key]
		table.insert(out, '\n==' .. esc(a.display) .. '==')
		if a.unconfirmed then
			table.insert(out, '<div style="font-size:92%;color:#b45309">主名义待确认</div>')
		end
		if a.intro ~= '' then
			table.insert(out, a.intro)
		end
		table.insert(out, '{| class="wikitable sortable" style="width:100%"')
		table.insert(out, '! 曲目名称 !! 时长 !! BPM !! 曲包 !! 备注')
		for _, item in ipairs(a.songs) do
			local s = item.song
			-- 备注: 参与(来自 模板:曲师参与.json) ;与…合作 ;名义:X
			local rawS = type(s['曲师']) == 'table' and s['曲师'][1] or s['曲师']
			local notes = {}
			local r = roles[key]                      -- 参与数据以「艺术家」为键(= 该行归属的主名义)
			if r and type(r[s['曲名']]) == 'table' then
				local rl = {}
				for _, x in ipairs(r[s['曲名']]) do rl[#rl + 1] = x end
				if #rl > 0 then table.insert(notes, table.concat(rl, '、')) end
			end
			local others = {}
			if item.other then
				for _, q in ipairs(item.other) do
					if parse.norm(q) ~= key then table.insert(others, q) end
				end
			else
				for _, pt in ipairs(parse.parts(rawS)) do
					if norm(pt.base) ~= key then table.insert(others, pt.base) end
				end
			end
			if #others > 0 then table.insert(notes, '与 ' .. table.concat(others, '、') .. ' 合作') end
			if item.ann ~= '' and item.ann ~= nil then table.insert(notes, item.ann) end
			local note = table.concat(notes, ';')
			table.insert(out, '|-')
			table.insert(out, '| [[' .. '曲目:' .. esc(pageName(s)) .. '|' .. langWrap(s) .. ']] || '
				.. esc(s['时长'] or '') .. ' || ' .. esc(s['BPM'] or '') .. ' || '
				.. '[[' .. '曲包列表#' .. esc(s['曲包'] or '') .. '|' .. esc(s['曲包'] or '') .. ']] || '
				.. esc(note))
		end
		table.insert(out, '|}')
	end

	table.insert(out, '\n[[Category:曲师]]')
	return frame:preprocess(table.concat(out, '\n'))
end

return p