The pack opening scene panel. Builds a private render scene with foil pack meshes, a camera, lights and particle layers, then animates tearing and reveal from Age and Tier and reports diagnostics.
using Sandbox;
using Sandbox.UI;
using System;
using System.Collections.Generic;
namespace Socer;
// A private theatre scene. It owns every model, light and particle and never touches the match scene.
public sealed class PackOpeningScene : ScenePanel
{
public float Age {get;set;}
public int Tier {get;set;}
public float RevealAt {get;set;}=5.25f;
public string PackArt {get;set;}="/textures/store/diamond-pack-v1.png";
CameraComponent camera;
DirectionalLight key;
ParticleEffect floodlights;
readonly Particle[] lightPoints=new Particle[4];
ModelRenderer body,cap;
ModelRenderer[] flakes=new ModelRenderer[84];
ParticleEffect glow,smoke,sparks;
Model box;
bool ready;
float previous=-1,ambientAt;
int bursts;
Color Accent=>Age<Math.Max(1.55f,RevealAt-1.65f)?new Color(.65f,.78f,.86f):Tier switch{4=>new Color(1,.30f,.12f),3=>new Color(.57f,.66f,1),2=>new Color(1,.73f,.31f),_=>new Color(.57f,.77f,.85f)};
static float Clamp(float value)=>Math.Clamp(value,0,1);
ModelRenderer Make(Model model,string name,Vector3 p,Vector3 size,Color tint)
{
var r=new GameObject(RenderScene,true,name).Components.Create<ModelRenderer>();r.Model=model;
r.WorldTransform=new Transform(p,Rotation.Identity,size);r.Tint=tint;return r;
}
void Initialize()
{
RenderScene=new Scene();
camera=new GameObject(RenderScene,true,"Pack camera").Components.Create<CameraComponent>();
camera.IsMainCamera=true;camera.FieldOfView=48;camera.FovAxis=CameraComponent.Axis.Horizontal;camera.ZNear=1;camera.ZFar=1800;
camera.BackgroundColor=Color.Transparent;
new GameObject(RenderScene,true,"Night fill").Components.Create<AmbientLight>().Color=new Color(.16f,.21f,.30f);
key=new GameObject(RenderScene,true,"Foil key light").Components.Create<DirectionalLight>();
key.WorldRotation=Rotation.From(new Angles(15,160,0));key.LightColor=new Color(1.8f,2.0f,2.4f);
box=FootballWorldPanel.MakeBox();
string texture=(PackArt??"/textures/store/diamond-pack-v1.png").TrimStart('/');
var mat=Material.Load("materials/default.vmat").CreateCopy("socer-pack-foil-"+GetHashCode());
mat.Set("g_tColor",Texture.Load(texture));mat.Set("g_flModelTintAmount",1f);mat.SetFeature("F_ALPHA_TEST",1);
bool svg=texture.EndsWith(".svg",StringComparison.OrdinalIgnoreCase);
body=Make(Foil(mat,svg?.13f:.15f,1,svg),"Foil body",new Vector3(0,0,100),Vector3.One,Color.White);
cap=Make(Foil(mat,0,svg?.13f:.15f,svg),"Torn foil strip",new Vector3(0,0,100),Vector3.One,Color.White);
var shard=FoilShard();
for(int i=0;i<flakes.Length;i++)flakes[i]=Make(shard,"Directional foil confetti",Vector3.Zero,Vector3.One,Color.White);
glow=Layer("Light accents",0,true,32);smoke=Layer("Alpha haze",1,false,48);sparks=Layer("Fine sparks",2,true,128);
floodlights=Layer("Floodlight activation",0,true,8);
for(int i=0;i<4;i++)
{
int side=i%2==0?-1:1;
var pos=i<2?new Vector3(-140,side*205,205):new Vector3(-270,side*160,155);
lightPoints[i]=Emit(floodlights,pos,Vector3.Zero,i<2?75:38,1000,.05f,0,Color.White);
}
ready=true;
}
static Model Foil(Material mat,float top,float bottom,bool full)
{
float left=full?0:.16f,right=full?1:.85f;
var v=new List<Vertex>();
Vertex At(float u,float w)=>new Vertex(new Vector3(0,(u-.5f)*90,(.5f-w)*132),Vector3.Forward,Vector3.Up,new Vector2(u,w));
var a=At(left,top);var b=At(right,top);var c=At(right,bottom);var d=At(left,bottom);
v.AddRange(new[]{a,b,c,a,c,d,c,b,a,d,c,a});
var mesh=new Mesh(mat);mesh.CreateVertexBuffer(v.Count,v);var bounds=new BBox(new Vector3(-2,-50,-70),new Vector3(2,50,70));mesh.Bounds=bounds;
return Model.Builder.AddMesh(mesh).WithViewBounds(bounds).Create();
}
static Model FoilShard()
{
using var white=new Bitmap(2,2);white.Clear(Color.White);
var material=Material.Load("materials/default.vmat").CreateCopy("socer-pack-confetti");material.Set("g_tColor",white.ToTexture());material.Set("g_flModelTintAmount",1f);
var points=new[]{new Vector3(0,-.5f,-.4f),new Vector3(0,.5f,-.5f),new Vector3(0,.4f,.5f),new Vector3(0,-.5f,.3f)};
var centre=new Vector3(.14f,0,0);var vertices=new List<Vertex>();
for(int i=0;i<4;i++)
{
var a=points[i];var b=points[(i+1)%4];var normal=Vector3.Cross(b-a,centre-a).Normal;
foreach(var p in new[]{a,b,centre,centre,b,a})vertices.Add(new Vertex(p,normal,Vector3.Up,Vector2.Zero));
}
var mesh=new Mesh(material);mesh.CreateVertexBuffer(vertices.Count,vertices);var bounds=new BBox(new Vector3(-1),new Vector3(1));mesh.Bounds=bounds;
return Model.Builder.AddMesh(mesh).WithViewBounds(bounds).Create();
}
static Sprite SpriteMask(int kind)
{
// Original analytic masks, generated directly for the native renderer (no imported VFX assets).
using var bitmap=new Bitmap(64,64);
for(int y=0;y<64;y++)for(int x=0;x<64;x++)
{
float u=(x-31.5f)/31.5f,v=(y-31.5f)/31.5f,r=MathF.Sqrt(u*u+v*v);
float alpha=kind==0?MathF.Pow(Clamp(1-r),2.6f):kind==1?MathF.Pow(Clamp(1-r),1.7f)*(.68f+.32f*MathF.Sin(u*9+MathF.Cos(v*8))):MathF.Pow(Clamp(1-Math.Abs(u)*3),2)*MathF.Pow(Clamp(1-Math.Abs(v)),.8f);
bitmap.SetPixel(x,y,new Color(1,1,1,alpha));
}
return Sprite.FromTexture(bitmap.ToTexture());
}
ParticleEffect Layer(string name,int mask,bool additive,int count)
{
var go=new GameObject(RenderScene,true,name);
var fx=go.Components.Create<ParticleEffect>();fx.MaxParticles=count;fx.Lifetime=1;fx.LocalSpace=0;fx.StartVelocity=0;fx.InitialVelocity=Vector3.Zero;fx.Damping=0;
fx.ApplyShape=false;fx.ApplyAlpha=false;fx.ApplyColor=false;fx.ApplyRotation=false;fx.Force=false;fx.Collision=false;fx.SheetSequence=false;fx.OnStep=Step;
var renderer=go.Components.Create<ParticleSpriteRenderer>();renderer.Sprite=SpriteMask(mask);renderer.Scale=1;renderer.Additive=additive;
renderer.Lighting=false;renderer.Shadows=false;renderer.Opaque=false;renderer.DepthFeather=2;renderer.FogStrength=0;
return fx;
}
static void Step(Particle p,float delta)
{
float a=Clamp(p.Age),life=p.Get<float>("life"),dt=Math.Clamp((a-p.Get<float>("prev"))*life,0,.05f);p.Set("prev",a);
p.Alpha=p.Get<float>("alpha")*MathF.Pow(1-a,1.6f);p.Size=p.Get<Vector3>("size")*(1+a*p.Get<float>("grow"));
p.Velocity+=Vector3.Down*p.Get<float>("gravity")*dt*life;
}
Particle Emit(ParticleEffect fx,Vector3 pos,Vector3 velocity,float size,float life,float alpha,float grow,Color color,float gravity=0)
{
if(fx.IsFull)return null;var p=fx.Emit(pos,0);if(p==null)return null;
p.Position=pos;p.Velocity=velocity*life;p.Size=new Vector3(size);p.StartScale=1;p.TimeScale=1/life;p.Color=color;p.Alpha=alpha;p.DeathTime=p.BornTime+life;
p.Set("size",p.Size);p.Set("life",life);p.Set("alpha",alpha);p.Set("grow",grow);p.Set("prev",0f);p.Set("gravity",gravity);return p;
}
void Burst(bool reveal)
{
bursts++;var color=Accent;var at=new Vector3(20,0,reveal?94:152);
Emit(glow,at,Vector3.Zero,reveal?210:95,.48f,.8f,1.4f,color);
for(int i=0;i<(reveal?44:22);i++)
{
float theta=i*2.39996f;var velocity=new Vector3(MathF.Sin(theta)*15,MathF.Cos(theta)*(reveal?110:48),20+(i%9)*7);
Emit(sparks,at,velocity,3+(i%3),.6f+(i%7)*.11f,.8f,.2f,color,35);
}
for(int i=0;i<10;i++)Emit(smoke,new Vector3(4,(i-4.5f)*15,4),new Vector3(-5,(i-4.5f)*6,12),45,1.7f,.12f,1.8f,new Color(.23f,.32f,.44f));
}
public void ClearTransientEffects(){if(!ready)return;glow.Clear();smoke.Clear();sparks.Clear();ambientAt=float.PositiveInfinity;previous=Age;}
public override void Tick()
{
base.Tick();if(!ready)Initialize();
float age=Age,tear=Clamp((age-.92f)/.55f),exit=Clamp((age-1.6f)/.4f),signal=Clamp((age-Math.Max(1.55f,RevealAt-1.65f))/.5f);
if(age<previous-.1f){glow.Clear();smoke.Clear();sparks.Clear();ambientAt=0;}
float settle=Clamp(age/1.1f),push=Tier>=2?signal:0;
camera.WorldPosition=new Vector3(386-push*22,MathF.Sin(age*.3f)*1.3f,143);
camera.WorldRotation=Rotation.LookAt(new Vector3(0,0,80)-camera.WorldPosition);
// A finite, decaying impulse accents the reveal, rather than perpetual camera shake.
float kick=age-RevealAt;if(kick>=0 && kick<.18f)camera.WorldPosition+=new Vector3(0,MathF.Sin(kick*110)*1.8f,MathF.Cos(kick*90)*.6f)*(1-kick/.18f);
body.Enabled=exit<1;cap.Enabled=age<1.95f;
body.WorldTransform=new Transform(new Vector3(-exit*30,exit*34,86+settle*14-exit*95),Rotation.From(new Angles(0,-4+MathF.Sin(age*1.8f)*2+exit*15,-4+exit*16)),Vector3.One);
cap.WorldTransform=new Transform(new Vector3(tear*12,-tear*60,86+settle*14+tear*70),Rotation.From(new Angles(tear*30,-4-tear*25,-4-tear*65)),Vector3.One);
for(int i=0;i<lightPoints.Length;i++)
{
float u=i<2?(i%2==0?.065f:.935f):(i%2==0?.25f:.75f);
var ray=camera.ScreenNormalToRay(new Vector3(u,i<2?.12f:.29f,0));
lightPoints[i].Position=ray.Position+ray.Forward*(i<2?540:700);
float on=i<2?.10f+signal*.5f:Tier>=3?signal*.7f:0;
if(Tier==4 && age>RevealAt-1.8f && age<RevealAt-.7f)on*=.02f;
lightPoints[i].Set("alpha",on);lightPoints[i].Color=Accent;
}
key.WorldRotation=Rotation.From(new Angles(15,160+MathF.Sin(age*1.1f)*12,0));
key.LightColor=new Color(1.0f+signal*.2f,1.1f+signal*.2f,1.3f+signal*.2f);
for(int i=0;i<flakes.Length;i++)
{
bool final=i>=18;float born=final?RevealAt:.94f,t=age-born;bool enabled=t>=0 && t<(final?2.1f:1.0f);
var flake=flakes[i];flake.Enabled=enabled;if(!enabled)continue;
float seed=i*2.39996f,side=i%2==0?-1:1,speed=final?50+i%9*8:20+i%7*5;
var p=new Vector3(15+MathF.Sin(seed)*t*25,side*(12+speed*t),final?45+MathF.Sin(seed)*22+(70+i%11*6)*t-75*t*t:150+(35+i%7*8)*t-110*t*t);
flake.WorldTransform=new Transform(p,Rotation.From(new Angles(t*(120+i*5),seed*40+t*110,t*220)),new Vector3(1,(final?3:4)+i%4,2+i%3));
flake.Tint=Color.Lerp(Accent,Color.White,.55f+MathF.Sin(age*9+i)*.35f)*Math.Max(.1f,1-t/(final?2.1f:1));
}
if(age>=.92f && previous<.92f && age<1.4f)Burst(false);
if(age>=RevealAt && previous<RevealAt && age<RevealAt+.3f)Burst(true);
if(age<RevealAt+1.8f && age>=ambientAt)
{
ambientAt=age+.14f;float seed=age*21;
Emit(sparks,new Vector3(-30,MathF.Sin(seed)*170,40+MathF.Cos(seed*.7f)*65),new Vector3(0,1,4),1.3f,1.4f,.2f,0,Accent);
}
previous=age;
}
public object Diagnostics()=>new{Ready=ready,NativeScene=RenderScene!=null,NativeParticles=ready?glow.ParticleCount+smoke.ParticleCount+sparks.ParticleCount:0,Bursts=bursts,Lamps=lightPoints.Length,FoilObjects=flakes.Length+2,Age,RevealAt};
public override void OnDeleted(){RenderScene?.Destroy();ready=false;base.OnDeleted();}
}