PackOpeningScene.cs

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.

Rough Code
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();}
}