652 lines
32 KiB
Lua
652 lines
32 KiB
Lua
-- Resource-fed per-character Warp tabs. This file is embedded behind the
|
|
-- native_presentation policy; it is deliberately outside heartbeat scripts/.
|
|
-- It touches only cloned Gacha models and the content of their own Warp tab.
|
|
do
|
|
local state=_G.__GAC_GACHA_PRESENTATION_STATE
|
|
if not state then
|
|
state={installed={},failures={},originals={},next_tick=0}
|
|
_G.__GAC_GACHA_PRESENTATION_STATE=state
|
|
end
|
|
local function log(message)
|
|
CS.UnityEngine.Debug.Log('[GaC Character Warp] '..tostring(message))
|
|
end
|
|
local function config()
|
|
return _G.__GAC_GACHA_PRESENTATION
|
|
end
|
|
local function entry(pool)
|
|
if not pool then return nil end
|
|
local installed=state.installed[pool.GachaID]
|
|
if not installed or installed.pool~=pool then return nil end
|
|
return state.by_id and state.by_id[pool.GachaID]
|
|
end
|
|
local function warp_open()
|
|
local page=CS.RPG.Client.GlobalVars.s_UIManager:GetPageByName('GachaMainPage')
|
|
return page and page.Trans and page.Trans.gameObject.activeInHierarchy
|
|
end
|
|
local function native_reflection()
|
|
if state.reflection then return state.reflection end
|
|
local flags=CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags),60)
|
|
state.reflection={
|
|
flags=flags,
|
|
invoke=CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags),0),
|
|
clone=typeof(CS.System.Object):GetMethod('MemberwiseClone',flags),
|
|
empty=CS.System.Array.CreateInstance(typeof(CS.System.Object),0)
|
|
}
|
|
return state.reflection
|
|
end
|
|
local function clone(value)
|
|
local r=native_reflection()
|
|
return r.clone:Invoke(value,r.invoke,nil,r.empty,nil)
|
|
end
|
|
local function stretch(rect,minx,miny,maxx,maxy)
|
|
rect.anchorMin=CS.UnityEngine.Vector2(minx,miny)
|
|
rect.anchorMax=CS.UnityEngine.Vector2(maxx,maxy)
|
|
rect.offsetMin=CS.UnityEngine.Vector2.zero
|
|
rect.offsetMax=CS.UnityEngine.Vector2.zero
|
|
rect.localScale=CS.UnityEngine.Vector3.one
|
|
end
|
|
local function image_object(name,parent)
|
|
-- The generated xLua constructor unwraps params Type[] from separate Lua
|
|
-- arguments. Passing System.Array as one argument fails overload binding.
|
|
local obj=CS.UnityEngine.GameObject(name,typeof(CS.UnityEngine.RectTransform),typeof(CS.UnityEngine.UI.Image))
|
|
obj.transform:SetParent(parent,false)
|
|
local image=obj:GetComponent(typeof(CS.UnityEngine.UI.Image))
|
|
return obj,image,obj:GetComponent(typeof(CS.UnityEngine.RectTransform))
|
|
end
|
|
local function text_object(source,parent,name,minx,miny,maxx,maxy)
|
|
local obj=CS.UnityEngine.Object.Instantiate(source.gameObject)
|
|
obj.name=name
|
|
obj.transform:SetParent(parent,false)
|
|
stretch(obj:GetComponent(typeof(CS.UnityEngine.RectTransform)),minx,miny,maxx,maxy)
|
|
local text=obj:GetComponent(source:GetType())
|
|
text.raycastTarget=false
|
|
text.color=CS.UnityEngine.Color.white
|
|
obj:SetActive(true)
|
|
return text
|
|
end
|
|
local function render(tab,banner)
|
|
if tab.__gac_character_banner and tab.__gac_layout_revision==5 and tab.__gac_banner_id==banner.gacha_id then
|
|
if not tab.__gac_sidebar_sprites then
|
|
state.sidebar_jobs=state.sidebar_jobs or setmetatable({},{__mode='k'})
|
|
state.sidebar_jobs[tab]=true
|
|
end
|
|
return
|
|
end
|
|
if tab.__gac_character_banner then
|
|
CS.UnityEngine.Object.Destroy(tab.__gac_character_banner)
|
|
tab.__gac_character_banner=nil
|
|
for _,obj in ipairs(tab.__gac_owned_texts or {})do CS.UnityEngine.Object.Destroy(obj)end
|
|
tab.__gac_owned_texts=nil
|
|
for _,item in ipairs(tab.__gac_replaced_visuals or {})do pcall(function()item.object:SetActive(item.active)end)end
|
|
tab.__gac_replaced_visuals=nil
|
|
for _,sprite in ipairs(tab.__gac_sidebar_sprites or {})do CS.UnityEngine.Object.Destroy(sprite)end
|
|
tab.__gac_sidebar_sprites=nil
|
|
if state.sidebar_jobs then state.sidebar_jobs[tab]=nil end
|
|
if state.pity_labels then state.pity_labels[tab]=nil end
|
|
tab.__gac_banner_id=nil
|
|
end
|
|
local content=tab._binder and tab._binder.node_content
|
|
local page=CS.RPG.Client.GlobalVars.s_UIManager:GetPageByName('GachaMainPage')
|
|
local binder=page and page.LuaTable and page.LuaTable._binder
|
|
local source=binder and binder.txt_pool_name
|
|
if not content or not source then error('known Warp content/name binder is unavailable') end
|
|
local avatar=CS.RPG.GameCore.AvatarExcelTable.GetData(banner.avatar_id)
|
|
if not avatar then error('mapped avatar row vanished') end
|
|
local avatar_panel=content.transform:Find('AvatarPanel')
|
|
local title_panel=content.transform:Find('TitlePanel')
|
|
local title_slot=content.transform:Find('TitlePanel/Content/CardPoolNamePanel/CardPoolName')
|
|
local subtitle=content.transform:Find('TitlePanel/SubTitlePanel/TextPanel/SubTitleText')
|
|
if not avatar_panel or not title_panel or not title_slot or not subtitle then error('captured native banner layout is unavailable') end
|
|
local root,background,rect=image_object('GaCCharacterBanner',avatar_panel)
|
|
local texts={}
|
|
local replaced={}
|
|
local function hide(path)
|
|
local node=content.transform:Find(path)
|
|
if node then replaced[#replaced+1]={object=node.gameObject,active=node.gameObject.activeSelf};node.gameObject:SetActive(false)end
|
|
end
|
|
-- Rendering is transactional. An exception after the opaque root exists
|
|
-- must not leave an untracked overlay covering the tab or add one on every
|
|
-- subsequent refresh.
|
|
local ok,err=pcall(function()
|
|
stretch(rect,0,0,1,1)
|
|
-- Reuse the native white title panel/frame. Replace the template's actual
|
|
-- illustration nodes, so no opaque rectangle sits over a second banner.
|
|
background.color=CS.UnityEngine.Color(1,1,1,0)
|
|
background.raycastTarget=false
|
|
-- CutinBg is a different illustration and can repeat the character art.
|
|
-- Leave the native frame visible rather than layering that asset here.
|
|
local art,image,art_rect=image_object('FeaturedAvatar',root.transform)
|
|
stretch(art_rect,0.32,0.02,0.99,0.98)
|
|
image.color=CS.UnityEngine.Color.white
|
|
image.preserveAspect=true
|
|
image.raycastTarget=false
|
|
local title=text_object(source,title_slot,'FeaturedName',0,0,1,1)
|
|
texts[#texts+1]=title.gameObject
|
|
title:SafeSetTextID(avatar.AvatarName)
|
|
title.fontSize=48;title.color=CS.UnityEngine.Color(0.1,0.1,0.1,1)
|
|
local description=text_object(source,title_panel,'FeaturedDescription',0.08,0.51,0.92,0.73)
|
|
texts[#texts+1]=description.gameObject
|
|
local rules=config().rules
|
|
if rules then
|
|
description.text='Every '..rules.four_star_pity..' Warps guarantees a 4-star or higher.\nFeatured 5-star guaranteed within '..rules.five_star_pity..' Warps.'
|
|
else description.text='Character Warp' end
|
|
description.fontSize=23;description.color=CS.UnityEngine.Color(0.1,0.1,0.1,1)
|
|
local four_stars=banner.four_stars or {}
|
|
if #four_stars>0 then
|
|
local label=text_object(source,title_panel,'FeaturedFourStarLabel',0.08,0.44,0.92,0.50)
|
|
texts[#texts+1]=label.gameObject
|
|
label.text='Featured 4-star characters';label.fontSize=20;label.color=CS.UnityEngine.Color(0.3,0.3,0.3,1)
|
|
local cards,card_bg,card_rect=image_object('FeaturedFourStarCards',title_panel)
|
|
texts[#texts+1]=cards;card_bg.color=CS.UnityEngine.Color(1,1,1,0);card_bg.raycastTarget=false
|
|
stretch(card_rect,0.08,0.14,0.92,0.43)
|
|
for index,four in ipairs(four_stars)do
|
|
local panel,panel_image,panel_rect=image_object('FourStarCard'..index,cards.transform)
|
|
stretch(panel_rect,(index-1)/#four_stars+0.01,0,index/#four_stars-0.01,1)
|
|
panel_image.color=CS.UnityEngine.Color(0.57,0.49,0.72,0.12);panel_image.raycastTarget=false
|
|
local portrait,portrait_image,portrait_rect=image_object('Portrait',panel.transform)
|
|
stretch(portrait_rect,0,0.16,1,1);portrait_image.preserveAspect=true;portrait_image.raycastTarget=false
|
|
if CS.RPG.Client.GlobalVars.s_AssetMgr:Exists(four.portrait_path)then
|
|
tab:_async_load_sprite_to(portrait_image,four.portrait_path)
|
|
else
|
|
-- One unavailable supporting portrait must not erase the whole featured
|
|
-- character card; keep the text and the other portraits usable.
|
|
portrait:SetActive(false)
|
|
log('FOUR_STAR_PORTRAIT_MISSING banner='..banner.gacha_id..' avatar='..four.avatar_id)
|
|
end
|
|
local four_row=CS.RPG.GameCore.AvatarExcelTable.GetData(four.avatar_id)
|
|
local name=text_object(source,panel.transform,'AvatarName',0,0,1,0.16)
|
|
name:SafeSetTextID(four_row.AvatarName);name.fontSize=16;name.color=CS.UnityEngine.Color(0.15,0.15,0.15,1)
|
|
end
|
|
end
|
|
local pity_label=text_object(source,title_panel,'WarpGuaranteeProgress',0.08,0.035,0.92,0.12)
|
|
texts[#texts+1]=pity_label.gameObject;pity_label.fontSize=18;pity_label.color=CS.UnityEngine.Color(0.3,0.3,0.3,1)
|
|
local initial_pity=config().pity or {since_five_star=0,since_four_star=0}
|
|
if rules then
|
|
pity_label.text='5-star: '..initial_pity.since_five_star..'/'..rules.five_star_pity..' 4-star: '..initial_pity.since_four_star..'/'..rules.four_star_pity
|
|
else pity_label.text='' end
|
|
state.pity_labels=state.pity_labels or setmetatable({},{__mode='k'})
|
|
state.pity_labels[tab]=pity_label
|
|
tab:_async_load_sprite_to(image,banner.art_path)
|
|
root.transform:SetAsLastSibling()
|
|
hide('AvatarPanel/MianAvatarPanel')
|
|
hide('AvatarPanel/MianAvatarPanelFront')
|
|
hide('AvatarPanel/SubLightConePanel')
|
|
hide('DrawCardInfo')
|
|
hide('BtnInvitation')
|
|
hide('TitlePanel/Content/ScrollView')
|
|
for i=0,title_slot.childCount-1 do
|
|
local child=title_slot:GetChild(i)
|
|
if child.gameObject~=title.gameObject then replaced[#replaced+1]={object=child.gameObject,active=child.gameObject.activeSelf};child.gameObject:SetActive(false)end
|
|
end
|
|
local subtitle_text=subtitle.gameObject:GetComponent(typeof(CS.RPG.Client.LocalizedText))
|
|
if subtitle_text then subtitle_text.text='Character Event Warp' end
|
|
end)
|
|
if not ok then
|
|
CS.UnityEngine.Object.Destroy(root)
|
|
for _,obj in ipairs(texts)do CS.UnityEngine.Object.Destroy(obj)end
|
|
for _,item in ipairs(replaced)do item.object:SetActive(item.active)end
|
|
error(err)
|
|
end
|
|
tab.__gac_character_banner=root
|
|
tab.__gac_layout_revision=5;tab.__gac_banner_id=banner.gacha_id;tab.__gac_owned_texts=texts;tab.__gac_replaced_visuals=replaced
|
|
state.sidebar_jobs=state.sidebar_jobs or setmetatable({},{__mode='k'})
|
|
state.sidebar_jobs[tab]=true
|
|
log('ART_REQUESTED banner='..banner.gacha_id..' avatar='..banner.avatar_id)
|
|
end
|
|
state.render_current=render
|
|
local function update_sidebars()
|
|
local processed=0
|
|
for tab in pairs(state.sidebar_jobs or {})do
|
|
if processed>=2 then break end;processed=processed+1
|
|
local root=tab.__gac_character_banner
|
|
local source=root and root.transform:Find('FeaturedAvatar')
|
|
local source_image=source and source.gameObject:GetComponent(typeof(CS.UnityEngine.UI.Image))
|
|
local sprite=source_image and source_image.sprite
|
|
local button=tab._tab_button_panel and tab._tab_button_panel._binder
|
|
if sprite and button then
|
|
local rect=sprite.textureRect
|
|
local owned={}
|
|
local ok,err=pcall(function()
|
|
for _,name in ipairs({'img_selected_icon','img_unselected_icon'})do
|
|
local image=button[name]
|
|
-- The template's selected image is taller than its tab slot. A full
|
|
-- character splash has transparent edges unlike the native banner icon,
|
|
-- so keep each custom image within its own measured parent bounds.
|
|
local transform=image.rectTransform
|
|
local parent=transform.parent
|
|
local bounds=parent and parent.rect
|
|
if bounds and transform.rect.height>bounds.height and bounds.height>0 then
|
|
local size=transform.sizeDelta
|
|
transform.sizeDelta=CS.UnityEngine.Vector2(size.x,size.y-(transform.rect.height-bounds.height))
|
|
end
|
|
local target=image.rectTransform.rect
|
|
local ratio=target.width/target.height
|
|
local width=math.min(rect.width,rect.height*ratio)
|
|
local height=width/ratio
|
|
local crop=CS.UnityEngine.Rect(rect.x+(rect.width-width)*0.5,rect.y+(rect.height-height)*0.68,width,height)
|
|
local cropped=CS.UnityEngine.Sprite.Create(sprite.texture,crop,CS.UnityEngine.Vector2(0.5,0.5),sprite.pixelsPerUnit)
|
|
owned[#owned+1]=cropped
|
|
image.sprite=cropped;image.preserveAspect=false
|
|
end
|
|
end)
|
|
if ok then
|
|
tab.__gac_sidebar_sprites=owned
|
|
state.sidebar_jobs[tab]=nil
|
|
log('SIDEBAR_READY banner='..tab._gacha_pool_data.GachaID)
|
|
else
|
|
for _,item in ipairs(owned)do CS.UnityEngine.Object.Destroy(item)end
|
|
state.sidebar_jobs[tab]=nil;log('SIDEBAR_ERROR '..tostring(err))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
local function ensure_hooks()
|
|
if state.hooked then return end
|
|
-- These named modules define controllers; requiring them does not open a UI
|
|
-- page or execute a scene/task. Hook before any new pool is made visible.
|
|
local class=require('Ui.Gacha.GachaPool.DefaultGachaPoolTabItem')
|
|
if type(class)~='table' or type(class._refresh)~='function' then
|
|
error('named native Gacha tab controller is unavailable')
|
|
end
|
|
local original_refresh=class._refresh
|
|
local original_dispose=class._on_dispose
|
|
state.originals.refresh=original_refresh
|
|
state.originals.dispose=original_dispose
|
|
local refresh_hook=function(self,...)
|
|
local banner=entry(self._gacha_pool_data)
|
|
if not banner then return original_refresh(self,...) end
|
|
-- Do not bind Standard's multi-character list or 300-draw reward panel to
|
|
-- the custom card. Its native draw controls continue to use the clone ID.
|
|
self._item_panels=self._item_panels or {}
|
|
self._up_item_panels=self._up_item_panels or {}
|
|
local ok,err=pcall(state.render_current,self,banner)
|
|
if not ok then
|
|
log('ART_ERROR banner='..banner.gacha_id..' '..tostring(err))
|
|
-- Preserve the known working page lifecycle even when a binding differs.
|
|
-- No world camera, page Canvas or global UI state is disabled here.
|
|
end
|
|
end
|
|
local dispose_hook=function(self,...)
|
|
if self.__gac_character_banner then CS.UnityEngine.Object.Destroy(self.__gac_character_banner) end
|
|
self.__gac_character_banner=nil;self.__gac_banner_id=nil
|
|
for _,obj in ipairs(self.__gac_owned_texts or {})do CS.UnityEngine.Object.Destroy(obj)end
|
|
self.__gac_owned_texts=nil
|
|
for _,sprite in ipairs(self.__gac_sidebar_sprites or {})do CS.UnityEngine.Object.Destroy(sprite)end
|
|
self.__gac_sidebar_sprites=nil
|
|
if state.sidebar_jobs then state.sidebar_jobs[self]=nil end
|
|
if state.pity_labels then state.pity_labels[self]=nil end
|
|
for _,item in ipairs(self.__gac_replaced_visuals or {})do pcall(function()item.object:SetActive(item.active)end)end
|
|
self.__gac_replaced_visuals=nil
|
|
if original_dispose then return original_dispose(self,...) end
|
|
end
|
|
class._refresh=refresh_hook
|
|
class._on_dispose=dispose_hook
|
|
state.hook_class=class
|
|
state.refresh_hook=refresh_hook
|
|
state.dispose_hook=dispose_hook
|
|
-- GetInfo refresh replaces the module's lists immediately before this
|
|
-- callback. Reconcile the projection before native tabs take ownership,
|
|
-- rather than racing the already-open page on a later render tick.
|
|
local main=require('Ui.Gacha.GachaMainPage')
|
|
local original_create=main._create_pools_panel
|
|
local create_hook=function(self,...)
|
|
if _G.__GAC_GACHA_PRESENTATION_TICK then
|
|
state.next_tick=0
|
|
_G.__GAC_GACHA_PRESENTATION_TICK(true)
|
|
end
|
|
return original_create(self,...)
|
|
end
|
|
main._create_pools_panel=create_hook
|
|
state.main_class=main;state.original_create=original_create;state.create_hook=create_hook
|
|
state.hooked=true
|
|
end
|
|
local remove_installed
|
|
local function register(module,template,banner)
|
|
local asset_mgr=CS.RPG.Client.GlobalVars.s_AssetMgr
|
|
if not asset_mgr:Exists(banner.art_path) or not asset_mgr:Exists(banner.icon_path) then
|
|
log('ASSET_MISSING banner='..banner.gacha_id..' avatar='..banner.avatar_id)
|
|
return false
|
|
end
|
|
local avatar=CS.RPG.GameCore.AvatarExcelTable.GetData(banner.avatar_id)
|
|
if not avatar then log('ROW_MISSING avatar='..banner.avatar_id);return false end
|
|
-- A native GetInfo refresh can drop a clone from lookup while its old group
|
|
-- remains cached. Remove only that previously owned reference before retry.
|
|
local previous=state.installed[banner.gacha_id]
|
|
if previous then remove_installed(previous);state.installed[banner.gacha_id]=nil end
|
|
local r=native_reflection()
|
|
local pool=clone(template)
|
|
local row=clone(template.Row)
|
|
row.GachaID=banner.gacha_id
|
|
row.SortID=template.Row.SortID+banner.sort_order
|
|
row.PoolName=avatar.AvatarName
|
|
row.PoolLabelIcon=banner.icon_path
|
|
row.PoolLabelIconSelected=banner.icon_path
|
|
local base=template:GetType().BaseType
|
|
base:GetField('<Row>k__BackingField',r.flags):SetValue(pool,row)
|
|
-- ItemHelper.GetItem is the same named API used by the captured native
|
|
-- GachaMainPage obtain dialog. Keep detail/prize metadata aligned with the
|
|
-- featured avatar, rather than inheriting the Standard pool's item array.
|
|
local prize_field=base:GetField('<PrizeItemDatas>k__BackingField',r.flags)
|
|
local detail_field=base:GetField('<DetailItemDatas>k__BackingField',r.flags)
|
|
if not prize_field or not detail_field then error('native Gacha item array fields are unavailable') end
|
|
local item_type=prize_field.FieldType:GetElementType()
|
|
local helper=typeof(CS.RPG.Client.ItemHelper)
|
|
local parameters=CS.System.Array.CreateInstance(typeof(CS.System.Type),1)
|
|
parameters[0]=typeof(CS.System.UInt32)
|
|
local get_item=helper:GetMethod('GetItem',r.flags,nil,parameters,nil)
|
|
-- Inspect the exact overload before invoking it. A similarly named helper
|
|
-- returning an ItemConfig/tuple cannot be inserted into an ItemData array.
|
|
if not get_item or not get_item.IsStatic or not item_type:IsAssignableFrom(get_item.ReturnType) then
|
|
error('ItemHelper.GetItem(UInt32) return type is not the captured ItemData array element')
|
|
end
|
|
local native_id=CS.System.Array.CreateInstance(typeof(CS.System.UInt32),1)
|
|
native_id[0]=banner.avatar_id
|
|
local item_args=CS.System.Array.CreateInstance(typeof(CS.System.Object),1)
|
|
CS.System.Array.Copy(native_id,item_args,1)
|
|
local item=get_item:Invoke(nil,r.invoke,nil,item_args,nil)
|
|
if not item or not item_type:IsInstanceOfType(item) or item.ConfigID~=banner.avatar_id then
|
|
error('mapped avatar item metadata is unavailable')
|
|
end
|
|
local prizes=CS.System.Array.CreateInstance(item_type,1);prizes:SetValue(item,0);prize_field:SetValue(pool,prizes)
|
|
local four_stars=banner.four_stars or {}
|
|
local details=CS.System.Array.CreateInstance(detail_field.FieldType:GetElementType(),1+#four_stars)
|
|
details:SetValue(item,0)
|
|
for index,four in ipairs(four_stars)do
|
|
native_id[0]=four.avatar_id;CS.System.Array.Copy(native_id,item_args,1)
|
|
local four_item=get_item:Invoke(nil,r.invoke,nil,item_args,nil)
|
|
if not four_item or not item_type:IsInstanceOfType(four_item) or four_item.ConfigID~=four.avatar_id then error('four-star item metadata is unavailable')end
|
|
details:SetValue(four_item,index)
|
|
end
|
|
detail_field:SetValue(pool,details)
|
|
pool.SingleDrawData=clone(template.SingleDrawData)
|
|
pool.MultiDrawData=clone(template.MultiDrawData)
|
|
-- The mutable draw records are distinct; the original Standard pool is
|
|
-- never renamed, modified or used as the custom pool's identity.
|
|
local t=module:GetType()
|
|
local list=t:GetField('_GachaPoolDatasThisVersion',r.flags):GetValue(module)
|
|
local groups=t:GetField('_GachaGroupDataDict',r.flags):GetValue(module)
|
|
local before={}
|
|
local it=groups.Keys:GetEnumerator()
|
|
while it:MoveNext() do before[tostring(it.Current)]=true end
|
|
it:Dispose()
|
|
local ok,err=pcall(function()
|
|
list:Add(pool)
|
|
local args=CS.System.Array.CreateInstance(typeof(CS.System.Object),1)
|
|
args[0]=pool
|
|
t:GetMethod('_AddGachaPoolDataToGroupDict',r.flags):Invoke(module,r.invoke,nil,args,nil)
|
|
if module:GetGachaPoolData(banner.gacha_id)~=pool then error('registered clone lookup mismatch') end
|
|
end)
|
|
if not ok then
|
|
list:Remove(pool)
|
|
local remove={}
|
|
local iter=groups.Keys:GetEnumerator()
|
|
while iter:MoveNext() do
|
|
if not before[tostring(iter.Current)] then remove[#remove+1]=iter.Current end
|
|
end
|
|
iter:Dispose()
|
|
local cached_field=t:GetField('_GachaGroupDatasThisVersion',r.flags)
|
|
local cached=cached_field and cached_field:GetValue(module)
|
|
for _,id in ipairs(remove) do
|
|
local group=groups[id]
|
|
if cached then cached:Remove(group) end
|
|
groups:Remove(id)
|
|
end
|
|
error('native registration rolled back: '..tostring(err))
|
|
end
|
|
local new_groups={}
|
|
local retained_groups={}
|
|
local iter=groups.Keys:GetEnumerator()
|
|
while iter:MoveNext() do
|
|
if not before[tostring(iter.Current)] then
|
|
new_groups[#new_groups+1]=iter.Current
|
|
retained_groups[#retained_groups+1]={id=iter.Current,value=groups[iter.Current]}
|
|
end
|
|
end
|
|
iter:Dispose()
|
|
state.installed[banner.gacha_id]={pool=pool,module=module,group_ids=new_groups,groups=retained_groups}
|
|
log('REGISTERED banner='..banner.gacha_id..' avatar='..banner.avatar_id)
|
|
return true
|
|
end
|
|
remove_installed=function(installed)
|
|
local r=native_reflection()
|
|
local module=installed.module
|
|
local t=module:GetType()
|
|
local list=t:GetField('_GachaPoolDatasThisVersion',r.flags):GetValue(module)
|
|
local groups=t:GetField('_GachaGroupDataDict',r.flags):GetValue(module)
|
|
local cached_groups_field=t:GetField('_GachaGroupDatasThisVersion',r.flags)
|
|
local cached_groups=cached_groups_field and cached_groups_field:GetValue(module)
|
|
list:Remove(installed.pool)
|
|
-- Track the actual keys created by native registration; do not derive
|
|
-- group IDs from banner IDs or enum values.
|
|
for _,group_id in ipairs(installed.group_ids) do
|
|
if groups:ContainsKey(group_id) then
|
|
local group=groups[group_id]
|
|
if group.IsSinglePoolGachaGroup and group.DefaultGachaPoolData==installed.pool then
|
|
groups:Remove(group_id)
|
|
if cached_groups then cached_groups:Remove(group) end
|
|
end
|
|
end
|
|
end
|
|
local cache_field=t:GetField('<GachaPoolDataCache>k__BackingField',r.flags)
|
|
if cache_field and cache_field:GetValue(module)==installed.pool then cache_field:SetValue(module,nil) end
|
|
end
|
|
local function remove_when_closed()
|
|
if warp_open() then return false end
|
|
for id,installed in pairs(state.installed) do
|
|
remove_installed(installed)
|
|
state.installed[id]=nil
|
|
end
|
|
if state.hook_class then
|
|
-- Do not overwrite a newer hook installed by another extension.
|
|
if state.hook_class._refresh==state.refresh_hook then state.hook_class._refresh=state.originals.refresh end
|
|
if state.hook_class._on_dispose==state.dispose_hook then state.hook_class._on_dispose=state.originals.dispose end
|
|
end
|
|
if state.main_class and state.main_class._create_pools_panel==state.create_hook then
|
|
state.main_class._create_pools_panel=state.original_create
|
|
end
|
|
state.main_class=nil;state.original_create=nil;state.create_hook=nil
|
|
state.hooked=false;state.hook_class=nil;state.refresh_hook=nil;state.dispose_hook=nil
|
|
state.by_id=nil;state.digest=nil;state.cursor=nil
|
|
return true
|
|
end
|
|
local function restore_before_binding(module)
|
|
-- Reuse fully initialized objects prepared in the bounded render batches.
|
|
-- A GetInfo refresh clears module collections; reconstructing every prefab
|
|
-- or reflected model synchronously here would delay the page unnecessarily.
|
|
local r=native_reflection()
|
|
local t=module:GetType()
|
|
local list=t:GetField('_GachaPoolDatasThisVersion',r.flags):GetValue(module)
|
|
local groups=t:GetField('_GachaGroupDataDict',r.flags):GetValue(module)
|
|
local cached_field=t:GetField('_GachaGroupDatasThisVersion',r.flags)
|
|
local cached=cached_field and cached_field:GetValue(module)
|
|
local restored=0
|
|
for id,owned in pairs(state.installed)do
|
|
if owned.module==module and state.by_id[id] and owned.groups and not module:GetGachaPoolData(id) then
|
|
local safe=true
|
|
for _,group in ipairs(owned.groups)do
|
|
if groups:ContainsKey(group.id) and groups[group.id]~=group.value then safe=false;break end
|
|
end
|
|
if safe then
|
|
if not list:Contains(owned.pool)then list:Add(owned.pool)end
|
|
for _,group in ipairs(owned.groups)do
|
|
if not groups:ContainsKey(group.id)then groups:Add(group.id,group.value)end
|
|
if cached and not cached:Contains(group.value)then cached:Add(group.value)end
|
|
end
|
|
restored=restored+1
|
|
end
|
|
end
|
|
end
|
|
if restored>0 then log('RESTORED_BEFORE_PAGE count='..restored)end
|
|
end
|
|
-- Reproduce only the native single-pool branch of GachaMainPage's captured
|
|
-- _create_pools_panel. The existing tab controller keeps its current selection;
|
|
-- neither _refresh nor click_item_by_uid is called. One prefab/tab per tick.
|
|
local function append_open_page(data)
|
|
local page=CS.RPG.Client.GlobalVars.s_UIManager:GetPageByName('GachaMainPage')
|
|
local ui=page and page.LuaTable
|
|
if not ui or not ui._binder or not ui._tab_control or type(ui._gacha_group_datas)~='table' or type(ui._gacha_tab_items)~='table' then return end
|
|
if state.page~=ui then state.page=ui;state.page_cursor=1;state.page_failures={} end
|
|
local count=#data.banners
|
|
for n=1,math.min(8,count)do
|
|
if state.page_cursor>count then state.page_cursor=1 end
|
|
local banner=data.banners[state.page_cursor];state.page_cursor=state.page_cursor+1
|
|
local installed=state.installed[banner.gacha_id]
|
|
if installed and not state.page_failures[banner.gacha_id] then
|
|
local present=false
|
|
for _,group in ipairs(ui._gacha_group_datas)do
|
|
if group and group.DefaultGachaPoolData==installed.pool then present=true;break end
|
|
end
|
|
if not present then
|
|
local group
|
|
for _,ref in ipairs(installed.groups or {})do
|
|
if ref.value and ref.value.IsSinglePoolGachaGroup and ref.value:IsSinglePoolGachaGroup() and ref.value.DefaultGachaPoolData==installed.pool then group=ref.value;break end
|
|
end
|
|
if group then
|
|
local prefab,tab,added,owned_root
|
|
local ok,err=pcall(function()
|
|
local types=ui._gachaType2tabItem[group.GachaType]
|
|
if not types or not types[1] or not types[2] then error('native single-pool tab type unavailable')end
|
|
local container=ui._binder.node_gacha_panel_container
|
|
local old_children=container.childCount
|
|
prefab=ui:sync_load_prefab(installed.pool.PrefabPath,container)
|
|
if container.childCount~=old_children+1 then error('native prefab did not add exactly one panel')end
|
|
owned_root=container:GetChild(old_children).gameObject
|
|
tab=ui:create_panel(types[1],types[2])
|
|
tab:set_tab_data(installed.pool)
|
|
tab.button_prefab_index=0
|
|
tab:bind(prefab)
|
|
-- A new panel must not cover the user's selected one while it is added.
|
|
owned_root:SetActive(false)
|
|
tab:set_time_over_callback(ui._on_gacha_pool_time_out,ui)
|
|
-- Track before add_item so page disposal owns the panel even if a
|
|
-- later native callback throws; it must not become an orphan object.
|
|
table.insert(ui._gacha_group_datas,group)
|
|
table.insert(ui._gacha_tab_items,tab)
|
|
added=true
|
|
ui._tab_control:add_item(tab)
|
|
tab:setup_group_tab_item()
|
|
ui._tab_control:update_layout()
|
|
end)
|
|
if not ok then
|
|
state.page_failures[banner.gacha_id]=true
|
|
if not added then
|
|
if tab and tab.dispose then pcall(function()tab:dispose()end)end
|
|
if owned_root then pcall(function()CS.UnityEngine.Object.Destroy(owned_root)end)end
|
|
end
|
|
log('PAGE_APPEND_ERROR banner='..banner.gacha_id..' '..tostring(err)..' tracked='..tostring(added))
|
|
else log('PAGE_APPENDED banner='..banner.gacha_id)end
|
|
return
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
_G.__GAC_GACHA_PRESENTATION_TICK=function(before_page_bind)
|
|
local data=config()
|
|
local now=CS.UnityEngine.Time.unscaledTime
|
|
if now<state.next_tick then return end
|
|
state.next_tick=now+0.1
|
|
local ok,err=pcall(function()
|
|
local r=native_reflection()
|
|
local manager=CS.RPG.Client.GlobalVars.s_GamePhaseManager
|
|
local phase=manager and typeof(CS.RPG.Client.GamePhaseManager):GetField('_CurrentPhase',r.flags):GetValue(manager)
|
|
if not phase or typeof(CS.RPG.Client.BattleGamePhase):IsInstanceOfType(phase) then return end
|
|
if not data or not data.enabled then remove_when_closed();state.next_tick=now+2;return end
|
|
local sidebar_ok,sidebar_error=pcall(update_sidebars)
|
|
if not sidebar_ok then
|
|
local message=tostring(sidebar_error)
|
|
if state.sidebar_last_error~=message then log('SIDEBAR_UPDATE_ERROR '..message);state.sidebar_last_error=message end
|
|
else state.sidebar_last_error=nil end
|
|
local pity=data.pity or {since_five_star=0,since_four_star=0}
|
|
if data.rules then
|
|
for tab,label in pairs(state.pity_labels or {})do
|
|
pcall(function()label.text='5-star: '..pity.since_five_star..'/'..data.rules.five_star_pity..' 4-star: '..pity.since_four_star..'/'..data.rules.four_star_pity end)
|
|
end
|
|
end
|
|
local opened=warp_open()
|
|
if not opened then state.page=nil;state.page_failures=nil end
|
|
-- A policy edit cannot replace pools owned by the open page. The same
|
|
-- digest may add missing independent pools and their tabs incrementally.
|
|
if opened and state.digest and state.digest~=data.digest then state.next_tick=now+0.25;return end
|
|
local module=CS.RPG.Client.GlobalVars.s_ModuleManager.GachaModule
|
|
if not module then return end
|
|
local template=module:GetGachaPoolData(data.template_id)
|
|
if not template then return end
|
|
if template:GetType().FullName~='RPG.Client.StandardGachaPoolData' or template.PrefabPath~=data.template_prefab then
|
|
error('captured initialized Standard template identity mismatch')
|
|
end
|
|
if not CS.RPG.Client.GlobalVars.s_AssetMgr:Exists(data.template_prefab) then
|
|
error('verified Standard template is no longer installed')
|
|
end
|
|
if state.digest~=data.digest then
|
|
-- A new server payload may arrive while a page still owns old tabs.
|
|
-- Keep its established projection until that page closes; tearing down
|
|
-- or mixing two digests here can change an existing selected pool.
|
|
if state.digest and warp_open() then
|
|
if before_page_bind then restore_before_binding(module) end
|
|
return
|
|
end
|
|
remove_when_closed()
|
|
state.by_id={};state.failures={};state.cursor=1
|
|
for _,banner in ipairs(data.banners) do state.by_id[banner.gacha_id]=banner end
|
|
state.digest=data.digest
|
|
end
|
|
ensure_hooks()
|
|
if before_page_bind then restore_before_binding(module)end
|
|
-- Prewarm two entries per frame. Page binding uses the same bounded work;
|
|
-- it must never create all clones in one frame and stall the game.
|
|
-- There is no assembly-wide reflection; each pass visits only configured
|
|
-- banner rows, and a failed registration never mutates another pool.
|
|
local count=#data.banners
|
|
local budget=math.min(2,count)
|
|
local processed=0
|
|
local completed_pass=false
|
|
while processed<budget and count>0 do
|
|
if state.cursor>count then state.cursor=1;completed_pass=true end
|
|
local banner=data.banners[state.cursor]
|
|
state.cursor=state.cursor+1;processed=processed+1
|
|
local failure=state.failures[banner.gacha_id]
|
|
if not module:GetGachaPoolData(banner.gacha_id) and (not opened or not state.installed[banner.gacha_id]) and (not failure or now>=failure.retry_at) then
|
|
local installed,reason=pcall(register,module,template,banner)
|
|
if not installed or not reason then
|
|
local message=installed and 'asset or avatar row unavailable' or tostring(reason)
|
|
if not failure or failure.message~=message then log('REGISTER_ERROR banner='..banner.gacha_id..' '..message)end
|
|
-- Item/asset providers may not be ready on the first post-login tick.
|
|
-- Retry on a later bounded pass instead of permanently losing the tab.
|
|
state.failures[banner.gacha_id]={retry_at=now+5,message=message}
|
|
else
|
|
state.failures[banner.gacha_id]=nil
|
|
end
|
|
end
|
|
end
|
|
-- GetGachaInfo can refresh native module lists. Reconcile from the real
|
|
-- lookup on a new pass instead of assuming a stale pool remains installed.
|
|
if state.cursor>count then state.cursor=1;completed_pass=true end
|
|
if completed_pass and not before_page_bind then state.next_tick=now+2 end
|
|
if opened and not before_page_bind then append_open_page(data) end
|
|
if before_page_bind then
|
|
local ready=0
|
|
for _,banner in ipairs(data.banners)do
|
|
if module:GetGachaPoolData(banner.gacha_id)then ready=ready+1 end
|
|
end
|
|
log('PAGE_BIND ready='..ready..' configured='..count)
|
|
end
|
|
end)
|
|
if not ok then
|
|
local message=tostring(err)
|
|
if message~=state.last_error then log('ERROR '..message);state.last_error=message end
|
|
state.next_tick=now+5
|
|
end
|
|
end
|
|
if not state.callback_registered then
|
|
local ok,err=pcall(function()
|
|
CS.UnityEngine.Application.onBeforeRender('+',function()
|
|
if _G.__GAC_GACHA_PRESENTATION_TICK then _G.__GAC_GACHA_PRESENTATION_TICK() end
|
|
end)
|
|
end)
|
|
state.callback_registered=ok
|
|
if not ok then log('CALLBACK_ERROR '..tostring(err)) end
|
|
end
|
|
_G.__GAC_GACHA_PRESENTATION_TICK()
|
|
end
|