Flash/Sbox/FlashStage.cs
using System;
using System.Collections.Generic;
using System.Text;
using Flash;
using Sandbox;
using Sandbox.UI;
using FMatrix = Flash.Matrix;
/// <summary>
/// Runs Swords and Sandals 2 (the original SWF, decoded into Assets/flash) inside s&box.
/// Put it on a GameObject with a ScreenPanel. The game keeps its original 640x420 shape inside a 4:3 frame.
/// Press F2 in game to move things around (see FlashLayout).
/// </summary>
[Title( "Flash Stage" ), Category( "Shoes and Daggers" ), Icon( "videogame_asset" )]
public sealed class FlashStage : PanelComponent, IFlashHost
{
/// <summary>Folder (inside Assets) with ss2.json, zooms.json, hitmasks.bin and the art.</summary>
[Property] public string DataPath { get; set; } = "flash";
/// <summary>Width:height of the frame the game sits in (black bars fill the rest).</summary>
[Property] public float FrameAspect { get; set; } = 4f / 3f;
/// <summary>Swap if images come out mirrored/rotated wrong (matrix order differs between engine versions).</summary>
[Property] public bool TransposeMatrix { get; set; }
/// <summary>s&box applies a transform matrix's translation in screen pixels; keep on unless things drift.</summary>
[Property] public bool ScaleMatrixTranslation { get; set; } = true;
/// <summary>Log ActionScript trace() output.</summary>
[Property] public bool ShowTraces { get; set; } = true;
[Property, Range( 0, 1 )] public float MasterVolume { get; set; } = 1f;
/// <summary>Write every sound start/stop to the console (for tracking down audio problems).</summary>
[Property] public bool LogSounds { get; set; } = true;
public static FlashStage Instance { get; private set; }
public Player Player { get; private set; }
public Library Library { get; private set; }
Panel stage;
readonly Dictionary<DisplayObject, Node> nodes = new();
readonly List<RenderItem> items = new();
readonly Dictionary<string, Texture> textures = new();
float scale = 1;
float stageLeft, stageTop;
Vector2 lastMouse = new( -1, -1 );
int frameStamp;
public FlashEditor Editor { get; private set; }
sealed class Node
{
public Panel Outer; // clip container (or the panel itself)
public Panel Panel;
public TextEntry Entry;
public int Stamp;
public string Image;
public string Mode;
public Texture Tex;
public readonly TexCache Cache = new();
// last values written to the panel's style (only write when something changed: style writes force re-layout)
public bool HasGeo;
public FMatrix LastM;
public double LastW, LastH;
public float LastScale = -1, LastOpacity = -1, LastBorder = -2, LastBlur = -2;
public Color LastBorderColor;
public int LastZ = int.MinValue;
public double LC0 = double.NaN, LC1, LC2, LC3;
public float LastStroke = -2;
public Color LastStrokeColor;
public int TextVersion = -1;
public string Text;
public string StyleKey;
public bool Clipped;
}
protected override void OnStart()
{
Instance = this;
Panel.Style.Position = PositionMode.Absolute;
Panel.Style.Left = 0;
Panel.Style.Top = 0;
Panel.Style.Width = Length.Percent( 100 );
Panel.Style.Height = Length.Percent( 100 );
Panel.Style.BackgroundColor = Color.Black;
Panel.Style.PointerEvents = PointerEvents.All;
stage = new Panel { Parent = Panel };
stage.Style.Position = PositionMode.Absolute;
stage.Style.Overflow = OverflowMode.Hidden;
stage.Style.BackgroundColor = Color.Black;
stage.Style.PointerEvents = PointerEvents.None;
try
{
foreach ( var f in new[] { "ss2.json", "zooms.json", "pack.json" } )
if ( !FileSystem.Mounted.FileExists( $"{DataPath}/{f}" ) )
throw new Exception( $"{DataPath}/{f} is missing from the game files. Add \"{DataPath}/*\" to Resource Files in the project settings and publish again." );
var json = FileSystem.Mounted.ReadAllText( $"{DataPath}/ss2.json" );
var zooms = FileSystem.Mounted.ReadAllText( $"{DataPath}/zooms.json" );
byte[] masks = null;
if ( FileSystem.Mounted.FileExists( $"{DataPath}/hitmasks.bin" ) ) masks = FileSystem.Mounted.ReadAllBytes( $"{DataPath}/hitmasks.bin" ).ToArray();
Library = Library.Load( json, zooms, masks );
}
catch ( Exception e )
{
Log.Error( $"FlashStage: couldn't load {DataPath}/ss2.json — {e.Message}" );
return;
}
LoadPacks();
Log.Info( $"FlashStage: loaded {Library.Chars.Count} symbols, {packIndex.Count} packed files" );
Player = new Player( Library, this );
Editor = new FlashEditor( this, Panel );
Editor.LoadLayout();
Player.Start();
}
protected override void OnDestroy()
{
if ( Instance == this ) Instance = null;
StopEverySound();
}
protected override void OnUpdate()
{
if ( Player == null ) return;
FitStage();
Editor?.Update();
if ( Editor == null || !Editor.Paused ) Player.Tick( Time.Delta );
UpdateAudio( Time.Delta );
PumpMouse();
Render();
}
// ======================================================================= layout
void FitStage()
{
var sp = Components.Get<ScreenPanel>();
float w = Screen.Width, h = Screen.Height;
float boxW = MathF.Min( w, h * FrameAspect );
scale = boxW / (float)Library.StageWidth;
if ( sp != null )
{
sp.AutoScreenScale = false;
sp.Scale = scale;
}
float cssW = w / scale, cssH = h / scale;
stageLeft = (cssW - (float)Library.StageWidth) / 2f;
stageTop = (cssH - (float)Library.StageHeight) / 2f;
stage.Style.Left = stageLeft;
stage.Style.Top = stageTop;
stage.Style.Width = (float)Library.StageWidth;
stage.Style.Height = (float)Library.StageHeight;
}
/// <summary>Screen pixels → Flash stage coordinates.</summary>
public Vector2 ScreenToStage( Vector2 screen ) => new( screen.x / scale - stageLeft, screen.y / scale - stageTop );
public Vector2 StageToScreen( Vector2 st ) => new( (st.x + stageLeft) * scale, (st.y + stageTop) * scale );
public float StageScale => scale;
public Panel StagePanel => stage;
// ======================================================================= input
void PumpMouse()
{
var m = Mouse.Position;
if ( m == lastMouse ) return;
lastMouse = m;
var s = ScreenToStage( m );
if ( Editor != null && Editor.Active ) { Editor.MouseMove( s ); return; }
Player.MouseMove( s.x, s.y );
}
protected override void OnMouseDown( MousePanelEvent e )
{
if ( Player == null || e.MouseButton != MouseButtons.Left ) return;
var s = ScreenToStage( Mouse.Position );
if ( Editor != null && Editor.Active ) { Editor.MouseDown( s ); return; }
Player.MouseMove( s.x, s.y );
Player.MouseDown();
FocusInputUnderMouse();
}
protected override void OnMouseUp( MousePanelEvent e )
{
if ( Player == null || e.MouseButton != MouseButtons.Left ) return;
if ( Editor != null && Editor.Active ) { Editor.MouseUp(); return; }
Player.MouseUp();
}
void FocusInputUnderMouse()
{
var t = Player.focusedText;
if ( t != null && nodes.TryGetValue( t, out var n ) && n.Entry != null ) n.Entry.Focus();
}
// ======================================================================= IFlashHost
public void Trace( string message ) { if ( ShowTraces ) Log.Info( $"[ss2] {message}" ); }
readonly Dictionary<int, SoundFile> soundFiles = new();
readonly List<SoundKey> sounds = new();
double audioClock;
float lastMaster = -1;
/// <summary>One Flash sound instance. Playing state is tracked by time (a fresh SoundHandle reports
/// IsPlaying = false until the mixer picks it up, which made loops restart and stack every frame).</summary>
sealed class SoundKey
{
public SoundDef Def;
public SoundFile File;
public SoundHandle Handle;
public int LoopsLeft;
public float Volume;
public double Length, IterStart;
public bool Stopped;
}
SoundFile LoadSoundFile( SoundDef sound )
{
if ( soundFiles.TryGetValue( sound.Id, out var sf ) ) return sf;
var mp3 = PackBytes( $"a/{sound.Id}.mp3" );
var wav = mp3 == null ? PackBytes( $"a/{sound.Id}.wav" ) : null;
if ( mp3 != null ) sf = SoundFile.FromMp3( $"ss2_{sound.Id}.mp3", mp3, new SoundFile.LoadOptions() );
else if ( wav != null ) { sf = SoundFile.FromWav( $"ss2_{sound.Id}.wav", wav, new SoundFile.LoadOptions() ); soundLengths[sound.Id] = WavSeconds( wav ); }
soundFiles[sound.Id] = sf;
return sf;
}
readonly Dictionary<int, double> soundLengths = new();
/// <summary>Exact length of a PCM wav (data bytes / byte rate).</summary>
static double WavSeconds( byte[] b )
{
if ( b.Length < 44 ) return 0;
int byteRate = BitConverter.ToInt32( b, 28 );
int pos = 12;
while ( pos + 8 <= b.Length )
{
int size = BitConverter.ToInt32( b, pos + 4 );
if ( b[pos] == 'd' && b[pos + 1] == 'a' && b[pos + 2] == 't' && b[pos + 3] == 'a' ) return byteRate > 0 ? Math.Min( size, b.Length - pos - 8 ) / (double)byteRate : 0;
pos += 8 + size + (size & 1);
}
return 0;
}
float Vol( float v ) => Math.Clamp( v, 0, 1 ) * MasterVolume;
public object PlaySound( SoundDef sound, double volume, double pan, int loops, double offsetSeconds )
{
try
{
var sf = LoadSoundFile( sound );
if ( sf == null ) return null;
var k = new SoundKey { Def = sound, File = sf, LoopsLeft = Math.Max( 1, loops ), Volume = (float)volume, IterStart = audioClock };
k.Length = soundLengths.TryGetValue( sound.Id, out var len ) && len > 0.01 ? len : sound.Seconds > 0.01 ? sound.Seconds : 1;
k.Handle = Play2D( sf, Vol( k.Volume ) );
sounds.Add( k );
if ( LogSounds ) Log.Info( $"[ss2 sound] play {sound.Id} {sound.ExportName} {k.Length:0.00}s x{k.LoopsLeft} vol {volume:0.##}" );
return k;
}
catch ( Exception e )
{
Log.Warning( $"FlashStage: sound {sound.Id} failed: {e.Message}" );
return null;
}
}
/// <summary>Called every frame: restart loops when an iteration ends, drop finished sounds.</summary>
void UpdateAudio( double dt )
{
audioClock += dt;
bool masterChanged = lastMaster != MasterVolume;
lastMaster = MasterVolume;
for ( int i = sounds.Count - 1; i >= 0; i-- )
{
var k = sounds[i];
if ( k.Stopped ) { sounds.RemoveAt( i ); continue; }
if ( masterChanged && k.Handle != null ) k.Handle.Volume = Vol( k.Volume );
if ( audioClock - k.IterStart < k.Length ) continue;
k.LoopsLeft--;
if ( k.LoopsLeft <= 0 )
{
// finished: stop the handle explicitly so nothing can keep ringing
k.Stopped = true;
k.Handle?.Stop( 0.05f );
k.Handle = null;
sounds.RemoveAt( i );
continue;
}
k.IterStart += k.Length;
if ( audioClock - k.IterStart > k.Length ) k.IterStart = audioClock; // fell behind (game paused / hitch)
k.Handle?.Stop( 0 );
k.Handle = Play2D( k.File, Vol( k.Volume ) );
}
}
/// <summary>Flash sounds are plain stereo: no 3D position, distance falloff, reverb, reflections or occlusion.</summary>
static SoundHandle Play2D( SoundFile file, float volume )
{
var h = Sound.PlayFile( file, volume, 1f, 0f, 0f );
if ( h == null ) return null;
h.ListenLocal = true;
h.SpacialBlend = 0f;
h.DistanceAttenuation = false;
h.ReverbEnabled = false;
h.Reflections = false;
h.OcclusionEnabled = false;
h.Occlusion = false;
h.AirAbsorption = false;
h.Transmission = false;
h.Position = Vector3.Zero;
return h;
}
void StopEverySound()
{
foreach ( var k in sounds ) { k.Handle?.Stop( 0 ); k.Stopped = true; }
sounds.Clear();
}
public void StopSound( object handle )
{
if ( handle is not SoundKey k || k.Stopped ) return;
if ( LogSounds ) Log.Info( $"[ss2 sound] stop {k.Def.Id} {k.Def.ExportName}" );
k.Stopped = true;
k.Handle?.Stop( 0 );
k.Handle = null;
sounds.Remove( k );
}
public bool IsSoundPlaying( object handle ) => handle is SoundKey k && !k.Stopped;
public double SoundPosition( object handle ) => handle is SoundKey k && !k.Stopped ? Math.Clamp( audioClock - k.IterStart, 0, k.Length ) : 0;
public void SetSoundVolume( object handle, double volume, double pan )
{
if ( handle is not SoundKey k ) return;
k.Volume = (float)volume;
if ( k.Handle != null ) k.Handle.Volume = Vol( k.Volume );
}
public string LoadShared( string name )
{
var p = $"ss2/{name}.json";
return FileSystem.Data.FileExists( p ) ? FileSystem.Data.ReadAllText( p ) : null;
}
public void SaveShared( string name, string data )
{
FileSystem.Data.CreateDirectory( "ss2" );
FileSystem.Data.WriteAllText( $"ss2/{name}.json", data );
}
public void GetUrl( string url, string window ) => Log.Info( $"[ss2] getURL {url}" );
public void SetFullScreen( bool on ) { }
// ======================================================================= packed art
int texLoaded, texFailures;
readonly List<byte[]> packs = new();
readonly Dictionary<string, (int pack, int offset, int length)> packIndex = new();
void LoadPacks()
{
var idx = $"{DataPath}/pack.json";
if ( !FileSystem.Mounted.FileExists( idx ) ) return;
var root = (Dictionary<string, object>)Flash.Json.Parse( FileSystem.Mounted.ReadAllText( idx ) );
foreach ( var f in (List<object>)root["files"] ) packs.Add( FileSystem.Mounted.ReadAllBytes( $"{DataPath}/{f}" ).ToArray() );
foreach ( var kv in (Dictionary<string, object>)root["entries"] )
{
var a = Flash.Json.Doubles( kv.Value );
packIndex[kv.Key] = ((int)a[0], (int)a[1], (int)a[2]);
}
}
/// <summary>Packed images are "RGBA" + width + height + raw-deflated RGBA8 pixels.</summary>
static (int w, int h, byte[] px) DecodeRaw( byte[] data )
{
if ( data.Length < 12 || data[0] != 'R' || data[1] != 'G' || data[2] != 'B' || data[3] != 'A' ) return (0, 0, null);
int w = BitConverter.ToInt32( data, 4 ), h = BitConverter.ToInt32( data, 8 );
var raw = new byte[w * h * 4];
using ( var ms = new System.IO.MemoryStream( data, 12, data.Length - 12 ) )
using ( var ds = new System.IO.Compression.DeflateStream( ms, System.IO.Compression.CompressionMode.Decompress ) )
{
int read = 0;
while ( read < raw.Length )
{
int n = ds.Read( raw, read, raw.Length - read );
if ( n <= 0 ) break;
read += n;
}
}
return (w, h, raw);
}
static Texture MakeTexture( int w, int h, byte[] raw, string name ) =>
Texture.Create( w, h, ImageFormat.RGBA8888 ).WithData( raw ).WithMips().WithName( "ss2_" + name ).Finish();
byte[] PackBytes( string path )
{
if ( !packIndex.TryGetValue( path, out var e ) || e.pack >= packs.Count ) return null;
var b = new byte[e.length];
Array.Copy( packs[e.pack], e.offset, b, 0, e.length );
return b;
}
// ======================================================================= rendering
Texture Tex( string path )
{
if ( textures.TryGetValue( path, out var t ) ) return t;
var bytes = PackBytes( path );
if ( bytes != null )
{
try
{
var (w, h, px) = DecodeRaw( bytes );
if ( px != null ) t = MakeTexture( w, h, px, path );
if ( t != null && texLoaded++ < 2 ) Log.Info( $"FlashStage: texture {path} {t.Width}x{t.Height} valid={t.IsValid}" );
}
catch ( Exception e ) { if ( texFailures++ < 5 ) Log.Warning( $"FlashStage: can't decode {path}: {e.Message}" ); }
}
else if ( FileSystem.Mounted.FileExists( $"{DataPath}/{path}" ) )
t = Texture.LoadFromFileSystem( $"{DataPath}/{path}", FileSystem.Mounted, false );
textures[path] = t;
return t;
}
// ---- colour-transformed copies of images (Flash colour transforms / colour-matrix filters done on the CPU)
readonly Dictionary<string, (int w, int h, byte[] px)> rawCache = new();
readonly Dictionary<string, Texture> variants = new();
long rawBytes;
/// <summary>Last frame each texture was on screen; textures unused for a while are freed.</summary>
readonly Dictionary<Texture, int> texUsed = new();
void EvictTextures()
{
if ( frameStamp % 120 != 0 ) return;
List<string> drop = null;
foreach ( var kv in variants )
if ( kv.Value == null || !texUsed.TryGetValue( kv.Value, out var f ) || frameStamp - f > 300 ) (drop ??= new()).Add( kv.Key );
if ( drop != null ) foreach ( var k in drop ) { var t = variants[k]; variants.Remove( k ); Free( t ); }
drop = null;
foreach ( var kv in textures )
if ( kv.Value != null && texUsed.TryGetValue( kv.Value, out var f ) && frameStamp - f > 1800 ) (drop ??= new()).Add( kv.Key );
if ( drop != null ) foreach ( var k in drop ) { var t = textures[k]; textures.Remove( k ); Free( t ); }
}
void Free( Texture t )
{
if ( t == null ) return;
texUsed.Remove( t );
foreach ( var n in nodes.Values ) if ( n.Cache.Tex == t ) { n.Cache.Tex = null; n.Cache.Image = null; }
t.Dispose();
}
(int w, int h, byte[] px) Raw( string path )
{
if ( rawCache.TryGetValue( path, out var r ) ) return r;
var bytes = PackBytes( path );
r = bytes != null ? DecodeRaw( bytes ) : (0, 0, null);
if ( r.px != null )
{
rawBytes += r.px.Length;
if ( rawBytes > 192_000_000 ) { rawCache.Clear(); rawBytes = r.px.Length; } // decoded pixels are only a cache
}
rawCache[path] = r;
return r;
}
static int Q( double v, double step ) => (int)Math.Round( v / step );
/// <summary>The image with colour transform cx (rgb part) and then any colour-matrix filters applied.</summary>
/// <summary>A mask shape that cuts an image: its picture, bounds, and the map from the image's local space to the mask's.</summary>
struct MaskRef { public string Image; public double X0, Y0, X1, Y1; public FMatrix R; }
Texture Variant( string image, ColorTransform cx, List<float[]> matrices, List<MaskRef> masks = null, double ix0 = 0, double iy0 = 0, double ix1 = 0, double iy1 = 0 )
{
bool cxIdentity = Math.Abs( cx.Rm - 1 ) < 0.004 && Math.Abs( cx.Gm - 1 ) < 0.004 && Math.Abs( cx.Bm - 1 ) < 0.004
&& Math.Abs( cx.Ra ) < 0.5 && Math.Abs( cx.Ga ) < 0.5 && Math.Abs( cx.Ba ) < 0.5 && Math.Abs( cx.Aa ) < 0.5;
if ( cxIdentity && (matrices == null || matrices.Count == 0) && masks == null ) return Tex( image );
// quantise so animated tints reuse textures
var sb = new StringBuilder( image );
sb.Append( '|' ).Append( Q( cx.Rm, 1 / 128.0 ) ).Append( ',' ).Append( Q( cx.Gm, 1 / 128.0 ) ).Append( ',' ).Append( Q( cx.Bm, 1 / 128.0 ) )
.Append( ',' ).Append( Q( cx.Ra, 2 ) ).Append( ',' ).Append( Q( cx.Ga, 2 ) ).Append( ',' ).Append( Q( cx.Ba, 2 ) ).Append( ',' ).Append( Q( cx.Aa, 2 ) );
if ( matrices != null )
foreach ( var m in matrices ) { sb.Append( '|' ); foreach ( var v in m ) sb.Append( Q( v, 1 / 256.0 ) ).Append( ',' ); }
if ( masks != null )
foreach ( var mr in masks )
sb.Append( "|m" ).Append( mr.Image ).Append( ',' ).Append( Q( mr.R.A, 0.002 ) ).Append( ',' ).Append( Q( mr.R.B, 0.002 ) ).Append( ',' )
.Append( Q( mr.R.C, 0.002 ) ).Append( ',' ).Append( Q( mr.R.D, 0.002 ) ).Append( ',' ).Append( Q( mr.R.Tx, 0.25 ) ).Append( ',' ).Append( Q( mr.R.Ty, 0.25 ) );
var key = sb.ToString();
if ( variants.TryGetValue( key, out var t ) ) return t;
var (w, h, src) = Raw( image );
if ( src == null ) return Tex( image );
// spread big recolours over frames (null = keep showing the previous texture this frame)
if ( pixelBudget < w * h && pixelBudget < PixelBudgetPerFrame ) return null;
pixelBudget -= w * h;
var px = new byte[src.Length];
double rm = Q( cx.Rm, 1 / 128.0 ) / 128.0, gm = Q( cx.Gm, 1 / 128.0 ) / 128.0, bm = Q( cx.Bm, 1 / 128.0 ) / 128.0;
double ra = Q( cx.Ra, 2 ) * 2.0, ga = Q( cx.Ga, 2 ) * 2.0, ba = Q( cx.Ba, 2 ) * 2.0, aa = Q( cx.Aa, 2 ) * 2.0;
for ( int i = 0; i < src.Length; i += 4 )
{
double a = src[i + 3];
if ( a == 0 && aa <= 0 ) continue;
double r = src[i] * rm + ra, g = src[i + 1] * gm + ga, b = src[i + 2] * bm + ba;
a = Math.Clamp( a + aa * (a > 0 ? 1 : 0), 0, 255 );
r = Math.Clamp( r, 0, 255 ); g = Math.Clamp( g, 0, 255 ); b = Math.Clamp( b, 0, 255 );
if ( matrices != null )
foreach ( var m in matrices )
{
double nr = m[0] * r + m[1] * g + m[2] * b + m[3] * a + m[4];
double ng = m[5] * r + m[6] * g + m[7] * b + m[8] * a + m[9];
double nb = m[10] * r + m[11] * g + m[12] * b + m[13] * a + m[14];
double na = m[15] * r + m[16] * g + m[17] * b + m[18] * a + m[19];
r = Math.Clamp( nr, 0, 255 ); g = Math.Clamp( ng, 0, 255 ); b = Math.Clamp( nb, 0, 255 ); a = Math.Clamp( na, 0, 255 );
}
px[i] = (byte)r; px[i + 1] = (byte)g; px[i + 2] = (byte)b; px[i + 3] = (byte)a;
}
if ( masks != null ) ApplyMasks( px, w, h, masks, ix0, iy0, ix1, iy1 );
t = MakeTexture( w, h, px, key.GetHashCode().ToString( "x" ) );
variants[key] = t;
return t;
}
readonly List<DisplayObject> deadNodes = new();
readonly List<Panel> orderNow = new(), orderLast = new();
readonly Dictionary<Panel, int> rank = new();
void Render()
{
Player.CollectRender( items );
frameStamp++;
pixelBudget = PixelBudgetPerFrame;
EvictTextures();
for ( int i = 0; i < items.Count; i++ )
{
var it = items[i];
if ( !nodes.TryGetValue( it.Obj, out var n ) ) { n = new Node(); nodes[it.Obj] = n; }
n.Stamp = frameStamp;
try { Draw( it, n, i ); }
catch ( Exception e ) { Log.Warning( $"FlashStage draw: {e.Message}" ); }
}
// keep the panels' real child order = Flash draw order (z-index alone isn't re-sorted when a new
// panel appears, e.g. a button's hover state, which then covered the text above it)
orderNow.Clear();
for ( int i = 0; i < items.Count; i++ ) if ( nodes.TryGetValue( items[i].Obj, out var on ) && on.Outer != null ) orderNow.Add( on.Outer );
bool same = orderNow.Count == orderLast.Count;
for ( int i = 0; same && i < orderNow.Count; i++ ) same = ReferenceEquals( orderNow[i], orderLast[i] );
if ( !same )
{
rank.Clear();
for ( int i = 0; i < orderNow.Count; i++ ) rank[orderNow[i]] = i;
stage.SortChildren( (Panel c) => rank.TryGetValue( c, out var r ) ? r : int.MaxValue );
orderLast.Clear();
orderLast.AddRange( orderNow );
}
// panels for anything not drawn this frame (removed, hidden, or now a lit sprite) go away
deadNodes.Clear();
foreach ( var kv in nodes ) if ( kv.Value.Stamp != frameStamp ) deadNodes.Add( kv.Key );
foreach ( var d in deadNodes ) { nodes[d].Outer?.Delete( true ); nodes.Remove( d ); }
Editor?.Draw( items );
}
PanelTransform MakeTransform( FMatrix m )
{
var t = new PanelTransform();
float k = ScaleMatrixTranslation ? scale : 1f;
m.Tx *= k; m.Ty *= k;
// row-vector layout: x' = x*M11 + y*M21 + M41
global::Matrix mat = TransposeMatrix
? new global::Matrix( (float)m.A, (float)m.C, 0, 0, (float)m.B, (float)m.D, 0, 0, 0, 0, 1, 0, (float)m.Tx, (float)m.Ty, 0, 1 )
: new global::Matrix( (float)m.A, (float)m.B, 0, 0, (float)m.C, (float)m.D, 0, 0, 0, 0, 1, 0, (float)m.Tx, (float)m.Ty, 0, 1 );
t.AddMatrix3D( mat );
return t;
}
static FMatrix Translate( double x, double y ) => new( 1, 0, 0, 1, x, y );
void EnsurePanels( Node n, bool clipped, bool entry )
{
if ( n.Panel != null && n.Clipped == clipped && (n.Entry != null) == entry ) return;
n.Outer?.Delete( true );
Panel p = entry ? new TextEntry() : (Panel)new Panel();
if ( entry )
{
n.Entry = (TextEntry)p;
p.Style.PointerEvents = PointerEvents.All;
}
else
{
n.Entry = null;
p.Style.PointerEvents = PointerEvents.None;
}
p.Style.Position = PositionMode.Absolute;
p.Style.Left = 0;
p.Style.Top = 0;
p.Style.TransformOriginX = 0;
p.Style.TransformOriginY = 0;
if ( clipped )
{
var c = new Panel { Parent = stage };
c.Style.Position = PositionMode.Absolute;
c.Style.Overflow = OverflowMode.Hidden;
c.Style.PointerEvents = entry ? PointerEvents.All : PointerEvents.None;
p.Parent = c;
n.Outer = c;
}
else
{
p.Parent = stage;
n.Outer = p;
}
n.Panel = p;
n.Clipped = clipped;
n.Image = null; n.Mode = null; n.Tex = null; n.HasGeo = false; n.LastOpacity = -1; n.LastBorder = -2; n.LastBlur = -2; n.LastZ = int.MinValue; n.LC0 = double.NaN; n.LastStroke = -2; n.StyleKey = null; n.TextVersion = -1;
}
void Draw( RenderItem it, Node n, int index )
{
var clip = it.Clip;
bool clipped = clip != null;
bool entry = it.Obj is EditText e0 && e0.Edit.Input;
EnsurePanels( n, clipped, entry );
var p = n.Panel;
FMatrix world = it.World;
if ( clipped )
{
var c = n.Outer;
if ( n.LC0 != clip[0] || n.LC1 != clip[1] || n.LC2 != clip[2] || n.LC3 != clip[3] )
{
n.LC0 = clip[0]; n.LC1 = clip[1]; n.LC2 = clip[2]; n.LC3 = clip[3];
c.Style.Left = (float)clip[0];
c.Style.Top = (float)clip[1];
c.Style.Width = (float)Math.Max( 0, clip[2] - clip[0] );
c.Style.Height = (float)Math.Max( 0, clip[3] - clip[1] );
}
world = Translate( -clip[0], -clip[1] ).Mul( world );
}
if ( n.LastZ != index ) { n.LastZ = index; n.Outer.Style.ZIndex = index; }
switch ( it.Obj )
{
case ShapeObj s: DrawImage( n, s.Shape.Image, s.Shape.X0, s.Shape.Y0, s.Shape.X1, s.Shape.Y1, world, it ); break;
case StaticTextObj t: DrawImage( n, t.Text.Image, t.Text.X0, t.Text.Y0, t.Text.X1, t.Text.Y1, world, it ); break;
case MorphObj mo:
{
bool end = mo.T >= 0.5;
var b = end ? mo.Morph.B1 : mo.Morph.B0;
DrawImage( n, end ? mo.Morph.ImageEnd : mo.Morph.ImageStart, b[0], b[1], b[2], b[3], world, it );
break;
}
case EditText et: DrawText( n, et, world, it ); break;
}
}
void DrawImage( Node n, string image, double x0, double y0, double x1, double y1, FMatrix world, RenderItem it )
{
var p = n.Panel;
var cx = it.Cx;
Filter glow = null;
float blur = 0;
List<float[]> matrices = null;
bool hasMatrix = false;
if ( it.Filters != null )
{
foreach ( var f in it.Filters )
{
switch ( f.Kind )
{
case "cmatrix": hasMatrix = true; break;
case "glow": if ( !f.Inner && !f.Knockout && glow == null ) glow = f; break;
case "blur": blur = (float)Math.Max( f.BlurX, f.BlurY ) / 3f; break;
}
}
}
// colour matrices are only needed when the cached texture can't be reused
if ( hasMatrix && !(n.Cache.Tex != null && n.Cache.Image == image && it.Masks == null && ReferenceEquals( n.Cache.LastFilters, it.Filters ) && SameCx( n.Cache.LastCx, cx )) )
matrices = ColorMatrices( it );
var tex = TextureFor( n.Cache, image, x0, y0, x1, y1, it, matrices );
if ( tex != null ) texUsed[tex] = frameStamp;
if ( n.Tex != tex )
{
n.Tex = tex;
n.Image = image;
p.Style.BackgroundImage = tex;
p.Style.BackgroundSizeX = Length.Percent( 100 );
p.Style.BackgroundSizeY = Length.Percent( 100 );
p.Style.BackgroundRepeat = BackgroundRepeat.NoRepeat;
}
SetGeometry( n, p, world.Mul( Translate( x0, y0 ) ), x1 - x0, y1 - y0 );
SetOpacity( n, p, (float)Math.Clamp( cx.Am, 0, 1 ) );
float border = -1; Color borderColor = default;
if ( glow != null && glow.Strength >= 1.5 && Math.Max( glow.BlurX, glow.BlurY ) <= 12 )
{
// a strong, tight glow is an outline
var scaleAvg = Math.Sqrt( Math.Abs( world.A * world.D - world.B * world.C ) );
border = (float)Math.Round( Math.Max( 0.5, Math.Max( glow.BlurX, glow.BlurY ) * 0.4 / Math.Max( 0.05, scaleAvg ) ), 2 );
borderColor = new Color( (float)(glow.Color[0] / 255), (float)(glow.Color[1] / 255), (float)(glow.Color[2] / 255), (float)(glow.Color[3] / 255) );
}
if ( border != n.LastBorder || borderColor != n.LastBorderColor )
{
n.LastBorder = border; n.LastBorderColor = borderColor;
if ( border > 0 ) { p.Style.FilterBorderWidth = border; p.Style.FilterBorderColor = borderColor; }
else { p.Style.FilterBorderWidth = null; p.Style.FilterBorderColor = null; }
}
if ( blur != n.LastBlur ) { n.LastBlur = blur; p.Style.FilterBlur = blur > 0 ? blur : null; }
}
/// <summary>Size + transform, written only when they change (or the UI scale changes).</summary>
void SetGeometry( Node n, Panel p, FMatrix m, double w, double h )
{
if ( n.HasGeo && n.LastScale == scale && n.LastW == w && n.LastH == h &&
n.LastM.A == m.A && n.LastM.B == m.B && n.LastM.C == m.C && n.LastM.D == m.D && n.LastM.Tx == m.Tx && n.LastM.Ty == m.Ty ) return;
if ( !n.HasGeo || n.LastW != w || n.LastH != h ) { p.Style.Width = (float)w; p.Style.Height = (float)h; }
n.HasGeo = true; n.LastScale = scale; n.LastW = w; n.LastH = h; n.LastM = m;
p.Style.Transform = MakeTransform( m );
}
static void SetOpacity( Node n, Panel p, float o )
{
if ( n.LastOpacity == o ) return;
n.LastOpacity = o;
p.Style.Opacity = o;
}
/// <summary>Flash masks: keep only the parts of the image covered by (any of) the mask shapes.</summary>
void ApplyMasks( byte[] px, int w, int h, List<MaskRef> masks, double ix0, double iy0, double ix1, double iy1 )
{
var cover = new byte[w * h];
double sx = (ix1 - ix0) / w, sy = (iy1 - iy0) / h;
foreach ( var mr in masks )
{
var (mw, mh, mpx) = Raw( mr.Image );
if ( mpx == null ) continue;
double kx = mw / Math.Max( 1e-6, mr.X1 - mr.X0 ), ky = mh / Math.Max( 1e-6, mr.Y1 - mr.Y0 );
var R = mr.R;
for ( int y = 0; y < h; y++ )
{
double ly = iy0 + (y + 0.5) * sy;
for ( int x = 0; x < w; x++ )
{
int i = y * w + x;
if ( cover[i] == 255 || px[i * 4 + 3] == 0 ) continue;
double lx = ix0 + (x + 0.5) * sx;
double mx = R.A * lx + R.C * ly + R.Tx, my = R.B * lx + R.D * ly + R.Ty;
int u = (int)((mx - mr.X0) * kx), v = (int)((my - mr.Y0) * ky);
if ( u < 0 || v < 0 || u >= mw || v >= mh ) continue;
byte a = mpx[(v * mw + u) * 4 + 3];
if ( a > cover[i] ) cover[i] = a;
}
}
}
for ( int i = 0; i < cover.Length; i++ )
px[i * 4 + 3] = (byte)(px[i * 4 + 3] * cover[i] / 255);
}
List<MaskRef> MaskRefs( RenderItem it )
{
if ( it.Masks == null || it.Masks.Count == 0 ) return null;
var list = new List<MaskRef>();
foreach ( var m in it.Masks )
{
string img; double[] b;
switch ( m.Obj )
{
case ShapeObj s: img = s.Shape.Image; b = new[] { s.Shape.X0, s.Shape.Y0, s.Shape.X1, s.Shape.Y1 }; break;
case MorphObj mo:
{
bool end = mo.T >= 0.5;
img = end ? mo.Morph.ImageEnd : mo.Morph.ImageStart;
b = end ? mo.Morph.B1 : mo.Morph.B0;
break;
}
default: return null; // can't express it: fall back to the rectangle clip
}
if ( img == null ) return null;
list.Add( new MaskRef { Image = img, X0 = b[0], Y0 = b[1], X1 = b[2], Y1 = b[3], R = m.World.Inverse().Mul( it.World ) } );
}
return list;
}
/// <summary>Remembers the last colour/mask variant an object used, so unchanged objects skip the lookup.</summary>
sealed class TexCache
{
public Texture Tex;
public string Image;
public ColorTransform LastCx;
public List<Filter> LastFilters;
public List<MaskRef> LastMasks;
public int MaskChurn, ColorChurn;
public bool NoMask;
}
const int PixelBudgetPerFrame = 2_500_000;
int pixelBudget = PixelBudgetPerFrame;
Texture TextureFor( TexCache c, string image, double x0, double y0, double x1, double y1, RenderItem it, List<float[]> matrices )
{
if ( c.Tex != null && c.Image == image && it.Masks == null && ReferenceEquals( c.LastFilters, it.Filters ) && SameCx( c.LastCx, it.Cx ) ) { c.ColorChurn = 0; return c.Tex; }
// a mask that moves every frame (scrolling, animated wipes) would need a new texture each frame: use the rectangle clip for those
var masks = c.NoMask ? null : MaskRefs( it );
// masked but nothing moved: same texture as last frame
if ( masks != null && c.Tex != null && c.Image == image && ReferenceEquals( c.LastFilters, it.Filters ) && SameCx( c.LastCx, it.Cx ) && SameMasks( c.LastMasks, masks ) ) { c.MaskChurn = 0; return c.Tex; }
// colour that animates every frame (flashes, fades to a tint): snap to coarse steps so a handful of textures get reused
var cx = it.Cx;
if ( c.Image == image && !SameCx( c.LastCx, cx ) ) c.ColorChurn++; else c.ColorChurn = 0;
if ( c.ColorChurn > 6 ) cx = Coarse( cx );
var tex = Variant( image, cx, matrices, masks, x0, y0, x1, y1 );
if ( tex == null ) return c.Tex ?? Tex( image );
if ( masks != null )
{
c.MaskChurn = tex != c.Tex && c.Tex != null ? c.MaskChurn + 1 : 0;
if ( c.MaskChurn > 20 ) c.NoMask = true;
}
c.Tex = tex; c.Image = image; c.LastCx = it.Cx; c.LastFilters = it.Filters; c.LastMasks = masks;
return tex;
}
static ColorTransform Coarse( ColorTransform c )
{
static double M( double v ) => Math.Round( v * 8 ) / 8;
static double A( double v ) => Math.Round( v / 32 ) * 32;
return new ColorTransform { Rm = M( c.Rm ), Gm = M( c.Gm ), Bm = M( c.Bm ), Am = c.Am, Ra = A( c.Ra ), Ga = A( c.Ga ), Ba = A( c.Ba ), Aa = A( c.Aa ) };
}
static List<float[]> ColorMatrices( RenderItem it )
{
List<float[]> matrices = null;
if ( it.Filters != null )
foreach ( var f in it.Filters )
if ( f.Kind == "cmatrix" && f.ColorMatrix != null && f.ColorMatrix.Length >= 20 ) (matrices ??= new()).Add( ToFloats( f.ColorMatrix ) );
return matrices;
}
static bool SameMasks( List<MaskRef> a, List<MaskRef> b )
{
if ( a == null || b == null || a.Count != b.Count ) return false;
for ( int i = 0; i < a.Count; i++ )
{
var x = a[i]; var y = b[i];
if ( x.Image != y.Image ) return false;
if ( Math.Abs( x.R.A - y.R.A ) > 0.001 || Math.Abs( x.R.B - y.R.B ) > 0.001 || Math.Abs( x.R.C - y.R.C ) > 0.001 || Math.Abs( x.R.D - y.R.D ) > 0.001 ) return false;
if ( Math.Abs( x.R.Tx - y.R.Tx ) > 0.1 || Math.Abs( x.R.Ty - y.R.Ty ) > 0.1 ) return false;
}
return true;
}
static bool SameCx( ColorTransform a, ColorTransform b ) =>
a.Rm == b.Rm && a.Gm == b.Gm && a.Bm == b.Bm && a.Ra == b.Ra && a.Ga == b.Ga && a.Ba == b.Ba && a.Aa == b.Aa;
static float[] ToFloats( double[] a )
{
var r = new float[a.Length];
for ( int i = 0; i < a.Length; i++ ) r[i] = (float)a[i];
return r;
}
void DrawText( Node n, EditText et, FMatrix world, RenderItem it )
{
var d = et.Edit;
var p = n.Panel;
const double gutter = 2;
double w = d.X1 - d.X0 - gutter * 2, h = d.Y1 - d.Y0 - gutter * 2;
if ( n.Entry != null )
{
if ( n.TextVersion != et.Version && !n.Entry.HasFocus )
{
n.Entry.Text = et.Text;
n.TextVersion = et.Version;
}
if ( n.Entry.OnTextEdited == null )
n.Entry.OnTextEdited = s => { et.UserInput( s ); n.TextVersion = et.Version; };
}
else if ( n.TextVersion != et.Version || n.Text == null )
{
// a Label child holds the text
if ( p.ChildrenCount == 0 ) new Label { Parent = p };
var lbl = p.GetChild( 0 ) as Label;
if ( lbl != null ) lbl.Text = d.Password ? new string( '*', et.Text.Length ) : et.Text;
n.TextVersion = et.Version;
n.Text = et.Text;
}
var col = et.TextColor;
var cx = it.Cx;
float r = (float)Math.Clamp( (col[0] * cx.Rm + cx.Ra) / 255, 0, 1 ), g = (float)Math.Clamp( (col[1] * cx.Gm + cx.Ga) / 255, 0, 1 ), b = (float)Math.Clamp( (col[2] * cx.Bm + cx.Ba) / 255, 0, 1 );
string key = $"{d.FontId}|{d.Size}|{r},{g},{b}|{d.Align}|{d.Multiline}{d.WordWrap}";
if ( n.StyleKey != key )
{
n.StyleKey = key;
var family = Library.Fonts.TryGetValue( d.FontId, out var fd ) ? fd.Family : "Arial";
Panel target = (Panel)n.Entry ?? (p.ChildrenCount > 0 ? p.GetChild( 0 ) : p);
foreach ( var s in new[] { p.Style, target.Style } )
{
s.FontFamily = family;
s.FontSize = (float)d.Size;
s.FontColor = new Color( r, g, b, 1 );
s.TextAlign = d.Align switch { 1 => TextAlign.Right, 2 => TextAlign.Center, _ => TextAlign.Left };
s.WhiteSpace = d.Multiline || d.WordWrap ? WhiteSpace.PreLine : WhiteSpace.NoWrap;
}
if ( target != p )
{
target.Style.Width = Length.Percent( 100 );
target.Style.Position = PositionMode.Absolute;
target.Style.Left = 0; target.Style.Top = 0;
}
if ( n.Entry != null ) { n.Entry.Style.BackgroundColor = null; }
}
Filter glow = null;
if ( it.Filters != null ) foreach ( var f in it.Filters ) if ( f.Kind == "glow" && !f.Inner ) { glow = f; break; }
Panel tp = (Panel)n.Entry ?? (p.ChildrenCount > 0 ? p.GetChild( 0 ) : p);
float stroke = -1; Color strokeColor = default;
if ( glow != null )
{
stroke = (float)Math.Clamp( Math.Max( glow.BlurX, glow.BlurY ) * Math.Min( glow.Strength, 4 ) * 0.35, 1, 6 );
strokeColor = new Color( (float)(glow.Color[0] / 255), (float)(glow.Color[1] / 255), (float)(glow.Color[2] / 255), (float)(glow.Color[3] / 255) );
}
if ( stroke != n.LastStroke || strokeColor != n.LastStrokeColor )
{
n.LastStroke = stroke; n.LastStrokeColor = strokeColor;
if ( stroke > 0 ) { tp.Style.TextStrokeWidth = stroke; tp.Style.TextStrokeColor = strokeColor; }
else { tp.Style.TextStrokeWidth = null; tp.Style.TextStrokeColor = null; }
}
SetGeometry( n, p, world.Mul( Translate( d.X0 + gutter, d.Y0 + gutter ) ), w, h );
SetOpacity( n, p, (float)Math.Clamp( cx.Am + cx.Aa / 255.0, 0, 1 ) );
}
}