Flash/Core/Player.cs
using System;
using System.Collections.Generic;
namespace Flash;
/// <summary>What the engine needs from whoever hosts it (s&box, or the test harness).</summary>
public interface IFlashHost
{
void Trace( string message );
/// <summary>Plays a sound; returns a handle (or null).</summary>
object PlaySound( SoundDef sound, double volume, double pan, int loops, double offsetSeconds );
void StopSound( object handle );
bool IsSoundPlaying( object handle );
double SoundPosition( object handle );
void SetSoundVolume( object handle, double volume, double pan );
string LoadShared( string name );
void SaveShared( string name, string data );
void GetUrl( string url, string window );
void SetFullScreen( bool on );
}
/// <summary>One thing to draw, in stage coordinates.</summary>
public sealed class RenderItem
{
public DisplayObject Obj;
public Matrix World;
public ColorTransform Cx;
public List<Filter> Filters; // own + inherited
public double[] Clip; // stage-space rect x0,y0,x1,y1 or null (bounding box of the masks)
public List<RenderItem> Masks; // mask shapes (stage space) that clip this item, or null
public int Index;
}
/// <summary>Runs a SWF: timelines, ActionScript, input, sound.</summary>
public sealed class Player
{
public readonly Library Lib;
public readonly Avm1 Vm;
public MovieClip Root;
public IFlashHost Host;
public double TimeMs;
public long FrameNumber;
double accum;
MovieClip execHead;
readonly List<(DisplayObject target, byte[] code, AsFunction fn, Value thisV, Value[] args)> queue = new();
int instanceCounter;
public readonly Dictionary<string, AsFunction> RegisteredClasses = new();
public Player( Library lib, IFlashHost host )
{
Lib = lib;
Host = host;
Vm = new Avm1( this ) { SwfVersion = lib.Version };
}
public string NextInstanceName() => "instance" + (++instanceCounter);
/// <summary>Layout tweaks by StablePath (from the editor).</summary>
public readonly Dictionary<string, LayoutAdjust> Layout = new();
public void ApplyLayout( DisplayObject d )
{
if ( Layout.Count == 0 ) { d.Adjust = null; return; }
d.Adjust = Layout.TryGetValue( d.StablePath, out var a ) ? a : null;
}
/// <summary>Re-match every object against the layout table (after loading or editing).</summary>
public void ReapplyLayout()
{
if ( Root == null ) return;
void Walk( DisplayObject d )
{
if ( d != Root ) ApplyLayout( d );
if ( d is MovieClip mc ) foreach ( var c in mc.Children ) Walk( c );
if ( d is ButtonObj b ) foreach ( var l in b.StateChildren.Values ) foreach ( var c in l ) Walk( c );
}
Walk( Root );
}
// ======================================================================= lifecycle
public void Start()
{
Root = new MovieClip( this, Lib.Main ) { Name = "" };
Root.Def = null;
// DoInitAction blocks define classes/prototypes before anything else runs
foreach ( var ia in Lib.Init ) Vm.RunScript( Root, ia.Code );
Construct( Root );
RunQueue();
SyncTexts( Root );
}
/// <summary>Advance by real time; runs as many SWF frames as are due.</summary>
public void Tick( double dtSeconds )
{
TimeMs += dtSeconds * 1000.0;
RunIntervals();
accum += dtSeconds;
double frameTime = 1.0 / Lib.FrameRate;
int n = 0;
while ( accum >= frameTime && n < 4 )
{
accum -= frameTime;
Frame();
n++;
}
if ( accum > frameTime * 4 ) accum = 0;
}
/// <summary>One SWF frame.</summary>
public void Frame()
{
FrameNumber++;
// newest clips first (Flash/Ruffle AVM1 execution order)
var list = new List<MovieClip>();
MovieClip prev = null;
for ( var c = execHead; c != null; c = c.NextExec )
{
if ( c.Removed )
{
if ( prev == null ) execHead = c.NextExec; else prev.NextExec = c.NextExec;
c.InExecList = false;
continue;
}
list.Add( c );
prev = c;
}
foreach ( var c in list )
{
if ( c.Removed ) continue;
if ( !c.Initialized ) { DispatchEvent( c, "Load" ); c.Initialized = true; }
else DispatchEvent( c, "EnterFrame" );
if ( c.Playing && !c.Removed ) c.AdvanceFrame();
}
Builtins.UpdateSounds( this );
RunQueue();
UpdateDrag();
SyncTexts( Root );
RecheckHover();
}
// ======================================================================= creation
public DisplayObject Instantiate( int charId, MovieClip parent )
{
var def = Lib.Get( charId );
switch ( def )
{
case SpriteDef sd:
{
var mc = new MovieClip( this, sd );
if ( sd.ExportName != null && RegisteredClasses.TryGetValue( sd.ExportName, out var cls ) )
{
mc.RegisteredClass = cls;
var proto = cls.Get( "prototype", Vm ).AsObj;
if ( proto != null ) mc.Proto = proto;
}
return mc;
}
case ShapeDef s: return new ShapeObj( this, s );
case MorphDef m: return new MorphObj( this, m );
case StaticTextDef t: return new StaticTextObj( this, t );
case EditTextDef e: return new EditText( this, e );
case ButtonDef b: return new ButtonObj( this, b );
}
return null;
}
/// <summary>A newly placed/attached object starts running (load event + first frame).</summary>
public void Construct( DisplayObject d, AsObject initObject = null, bool byScript = false )
{
if ( d is not MovieClip mc ) return;
if ( !mc.InExecList )
{
mc.NextExec = execHead;
execHead = mc;
mc.InExecList = true;
}
// a registered AS2 class's constructor runs first for timeline-placed clips
if ( mc.RegisteredClass != null && !byScript ) QueueCall( mc.RegisteredClass, Value.From( mc ), Array.Empty<Value>() );
// Load event fires before the first frame's scripts
DispatchEvent( mc, "Load" );
mc.Initialized = true;
if ( mc.Playing ) mc.Seek( 1, true );
else if ( mc.CurrentFrame == 0 ) mc.Seek( 1, true );
if ( initObject != null )
{
var keys = initObject.EnumerableKeys();
for ( int i = keys.Count - 1; i >= 0; i-- ) mc.Set( keys[i], initObject.Get( keys[i], Vm ), Vm );
}
if ( mc.RegisteredClass != null && byScript ) SafeCall( mc.RegisteredClass, Value.From( mc ), Array.Empty<Value>() );
}
public void Unload( DisplayObject d, bool recursiveOnly = false )
{
if ( d.Removed ) return;
if ( d is MovieClip mc ) DispatchEvent( mc, "Unload" );
d.OnRemoved();
if ( ReferenceEquals( hover, d ) ) hover = null;
if ( ReferenceEquals( pressed, d ) ) pressed = null;
if ( ReferenceEquals( dragTarget, d ) ) dragTarget = null;
focusedText = focusedText != null && focusedText.Removed ? null : focusedText;
}
// ======================================================================= actions
public void QueueAction( DisplayObject target, byte[] code ) => queue.Add( (target, code, null, default, null) );
public void QueueCall( AsFunction fn, Value thisV, Value[] args ) => queue.Add( (null, null, fn, thisV, args) );
public void RunQueue()
{
int guard = 0;
while ( queue.Count > 0 && guard++ < 100000 )
{
var item = queue[0];
queue.RemoveAt( 0 );
if ( item.code != null )
{
if ( item.target is { Removed: true } ) continue;
Vm.RunScript( item.target, item.code );
}
else if ( item.fn != null )
{
SafeCall( item.fn, item.thisV, item.args );
}
}
}
public Value SafeCall( AsFunction fn, Value thisV, Value[] args )
{
try { return Vm.CallFunction( fn, thisV, args ); }
catch ( AsThrow t ) { Vm.Trace( "Uncaught exception: " + t.Thrown.ToStr( Vm ) ); }
catch ( ScriptAbort a ) { Vm.Trace( "Script aborted: " + a.Message ); }
return Value.Undefined;
}
/// <summary>Clip event (onClipEvent handlers on the placement + the onX method).</summary>
public void DispatchEvent( MovieClip mc, string ev )
{
if ( mc.ClipActions != null )
foreach ( var ca in mc.ClipActions )
if ( ca.Events.Contains( ev ) ) QueueAction( mc, ca.Code );
string method = ev switch
{
"Load" => "onLoad", "EnterFrame" => "onEnterFrame", "Unload" => "onUnload", "MouseDown" => "onMouseDown",
"MouseUp" => "onMouseUp", "MouseMove" => "onMouseMove", "KeyDown" => "onKeyDown", "KeyUp" => "onKeyUp", "Data" => "onData",
_ => null
};
if ( method == null ) return;
var f = mc.Get( method, Vm ).AsObj as AsFunction;
if ( f != null ) QueueCall( f, Value.From( mc ), Array.Empty<Value>() );
}
void BroadcastClipEvent( MovieClip root, string ev )
{
for ( var c = execHead; c != null; c = c.NextExec )
if ( !c.Removed ) DispatchEvent( c, ev );
}
// ======================================================================= text fields
void SyncTexts( MovieClip mc )
{
foreach ( var c in mc.Children )
{
if ( c is EditText et ) et.Sync();
else if ( c is MovieClip m ) SyncTexts( m );
else if ( c is ButtonObj b )
foreach ( var l in b.StateChildren.Values ) foreach ( var x in l ) if ( x is EditText bt ) { bt.Parent = null; }
}
}
public EditText focusedText;
// ======================================================================= mouse
public double MouseX, MouseY;
public bool MouseIsDown;
DisplayObject hover, pressed;
public DisplayObject Hovered => hover;
public DisplayObject DropTarget;
public bool HandCursor => hover is ButtonObj b ? b.UseHandCursor : hover is MovieClip m && m.UseHandCursor && hover != null;
public void MouseMove( double x, double y )
{
MouseX = x; MouseY = y;
BroadcastClipEvent( Root, "MouseMove" );
BroadcastListeners( "Mouse", "onMouseMove" );
UpdateDrag();
RecheckHover();
RunQueue();
}
public void MouseDown()
{
MouseIsDown = true;
BroadcastClipEvent( Root, "MouseDown" );
BroadcastListeners( "Mouse", "onMouseDown" );
RecheckHover();
var target = hover;
pressed = target;
if ( target is ButtonObj b ) { b.State = "d"; RunButtonActions( b, "press" ); }
else if ( target is MovieClip mc ) CallHandler( mc, "onPress" );
// clicking an input field focuses it
var t = FindTextField( Root, MouseX, MouseY );
focusedText = t != null && t.Edit.Input ? t : null;
RunQueue();
}
public void MouseUp()
{
MouseIsDown = false;
BroadcastClipEvent( Root, "MouseUp" );
BroadcastListeners( "Mouse", "onMouseUp" );
RecheckHover();
var p = pressed;
pressed = null;
if ( p != null && !p.Removed )
{
if ( ReferenceEquals( p, hover ) )
{
if ( p is ButtonObj b ) { b.State = "o"; RunButtonActions( b, "release" ); }
else if ( p is MovieClip mc ) CallHandler( mc, "onRelease" );
}
else
{
if ( p is ButtonObj b ) { b.State = "u"; RunButtonActions( b, "releaseOutside" ); }
else if ( p is MovieClip mc ) CallHandler( mc, "onReleaseOutside" );
}
}
RunQueue();
}
void RecheckHover()
{
if ( Root == null ) return;
var now = FindInteractive( Root, MouseX, MouseY );
if ( ReferenceEquals( now, hover ) ) return;
var old = hover;
hover = now;
if ( old != null && !old.Removed )
{
if ( old is ButtonObj ob ) { ob.State = "u"; RunButtonActions( ob, MouseIsDown && ReferenceEquals( pressed, old ) ? "dragOut" : "rollOut" ); }
else if ( old is MovieClip om ) CallHandler( om, MouseIsDown && ReferenceEquals( pressed, old ) ? "onDragOut" : "onRollOut" );
}
if ( now != null )
{
if ( now is ButtonObj nb ) { nb.State = MouseIsDown && ReferenceEquals( pressed, now ) ? "d" : "o"; RunButtonActions( nb, MouseIsDown && ReferenceEquals( pressed, now ) ? "dragOver" : "rollOver" ); }
else if ( now is MovieClip nm ) CallHandler( nm, MouseIsDown && ReferenceEquals( pressed, now ) ? "onDragOver" : "onRollOver" );
}
}
DisplayObject FindInteractive( MovieClip mc, double x, double y )
{
if ( !mc.Visible || mc.Removed ) return null;
for ( int i = mc.Children.Count - 1; i >= 0; i-- )
{
var c = mc.Children[i];
if ( c.Removed || !c.Visible || c.ClipDepth > 0 || c.MaskFor != null ) continue;
if ( c is ButtonObj b )
{
if ( b.Enabled && b.HitTestPoint( x, y, true ) ) return b;
}
else if ( c is MovieClip m )
{
if ( m.Enabled && m.HasButtonHandlers )
{
if ( m.HitTestPoint( x, y, true ) ) return m;
continue;
}
var r = FindInteractive( m, x, y );
if ( r != null ) return r;
}
}
return null;
}
EditText FindTextField( MovieClip mc, double x, double y )
{
for ( int i = mc.Children.Count - 1; i >= 0; i-- )
{
var c = mc.Children[i];
if ( c.Removed || !c.Visible ) continue;
if ( c is EditText e && e.HitTestPoint( x, y, false ) ) return e;
if ( c is MovieClip m ) { var r = FindTextField( m, x, y ); if ( r != null ) return r; }
}
return null;
}
void RunButtonActions( ButtonObj b, string cond )
{
var target = b.Parent ?? Root;
foreach ( var a in b.Button.Actions )
if ( a.On.Contains( cond ) ) QueueAction( target, a.Code );
// AS2 handlers assigned by script: myButton.onRelease = function() {...}
string handler = cond switch
{
"press" => "onPress", "release" => "onRelease", "releaseOutside" => "onReleaseOutside", "rollOver" => "onRollOver",
"rollOut" => "onRollOut", "dragOut" => "onDragOut", "dragOver" => "onDragOver", _ => null
};
if ( handler == null ) return;
var f = b.Get( handler, Vm ).AsObj as AsFunction;
if ( f != null ) QueueCall( f, Value.From( b ), Array.Empty<Value>() );
}
void CallHandler( MovieClip mc, string name )
{
var f = mc.Get( name, Vm ).AsObj as AsFunction;
if ( f != null ) QueueCall( f, Value.From( mc ), Array.Empty<Value>() );
}
// ======================================================================= keyboard
public readonly HashSet<int> KeysDown = new();
public int LastKey, LastAscii;
public void KeyDown( int keyCode, int ascii )
{
KeysDown.Add( keyCode );
LastKey = keyCode; LastAscii = ascii;
BroadcastClipEvent( Root, "KeyDown" );
BroadcastListeners( "Key", "onKeyDown" );
RunQueue();
}
public void KeyUp( int keyCode, int ascii )
{
KeysDown.Remove( keyCode );
LastKey = keyCode; LastAscii = ascii;
BroadcastClipEvent( Root, "KeyUp" );
BroadcastListeners( "Key", "onKeyUp" );
RunQueue();
}
void BroadcastListeners( string globalName, string method )
{
var obj = Vm.Global.Get( globalName, Vm );
if ( !obj.IsObject ) return;
var listeners = obj.Obj.Get( "_listeners", Vm ).AsObj as AsArray;
if ( listeners == null ) return;
foreach ( var l in listeners.Items.ToArray() )
{
var f = Vm.GetMember( l, method ).AsObj as AsFunction;
if ( f != null ) QueueCall( f, l, Array.Empty<Value>() );
}
}
// ======================================================================= drag
DisplayObject dragTarget;
bool dragLock;
double[] dragRect;
double dragOffX, dragOffY;
public void StartDrag( DisplayObject d, bool lockCenter, double[] rect )
{
dragTarget = d;
dragLock = lockCenter;
dragRect = rect;
var (px, py) = ParentMouse( d );
dragOffX = lockCenter ? 0 : d.X - px;
dragOffY = lockCenter ? 0 : d.Y - py;
UpdateDrag();
}
public void StopDrag()
{
dragTarget = null;
}
(double, double) ParentMouse( DisplayObject d )
{
var pm = d.Parent?.WorldMatrix ?? Matrix.Identity;
return pm.Inverse().Apply( MouseX, MouseY );
}
void UpdateDrag()
{
if ( dragTarget == null || dragTarget.Removed ) return;
var (px, py) = ParentMouse( dragTarget );
double x = px + dragOffX, y = py + dragOffY;
if ( dragRect != null )
{
x = Math.Clamp( x, Math.Min( dragRect[0], dragRect[2] ), Math.Max( dragRect[0], dragRect[2] ) );
y = Math.Clamp( y, Math.Min( dragRect[1], dragRect[3] ), Math.Max( dragRect[1], dragRect[3] ) );
}
dragTarget.X = x; dragTarget.Y = y;
}
// ======================================================================= intervals
sealed class Interval { public int Id; public AsFunction Fn; public Value This; public Value[] Args; public double Every, Next; public bool Once; }
readonly List<Interval> intervals = new();
int intervalId;
public int SetInterval( AsFunction fn, Value thisV, double ms, Value[] args, bool once = false )
{
var iv = new Interval { Id = ++intervalId, Fn = fn, This = thisV, Args = args, Every = Math.Max( 1, ms ), Next = TimeMs + Math.Max( 1, ms ), Once = once };
intervals.Add( iv );
return iv.Id;
}
public void ClearInterval( int id ) => intervals.RemoveAll( i => i.Id == id );
void RunIntervals()
{
if ( intervals.Count == 0 ) return;
foreach ( var iv in intervals.ToArray() )
{
if ( TimeMs < iv.Next ) continue;
iv.Next = TimeMs + iv.Every;
if ( iv.Once ) intervals.Remove( iv );
SafeCall( iv.Fn, iv.This, iv.Args );
RunQueue();
}
}
// ======================================================================= sound / url
public readonly List<(SoundDef def, object handle, DisplayObject owner)> TimelineSounds = new();
/// <summary>Volume set by a Sound object with no target (or _root): scales every sound, 0..1.</summary>
public double GlobalVolume = 1;
public void SetGlobalVolume( double v )
{
GlobalVolume = Math.Clamp( v, 0, 1 );
foreach ( var s in TimelineSounds ) Host?.SetSoundVolume( s.handle, GlobalVolume, 0 );
Builtins.RefreshSoundVolumes( this );
}
public void StartSound( int soundId, bool stop, int loops, DisplayObject owner, bool noMultiple = false )
{
if ( Lib.Get( soundId ) is not SoundDef sd ) return;
// forget finished ones
for ( int i = TimelineSounds.Count - 1; i >= 0; i-- )
if ( Host == null || !Host.IsSoundPlaying( TimelineSounds[i].handle ) ) TimelineSounds.RemoveAt( i );
if ( stop )
{
for ( int i = TimelineSounds.Count - 1; i >= 0; i-- )
if ( TimelineSounds[i].def == sd ) { Host?.StopSound( TimelineSounds[i].handle ); TimelineSounds.RemoveAt( i ); }
return;
}
if ( noMultiple )
foreach ( var t in TimelineSounds ) if ( t.def == sd ) return;
var h = Host?.PlaySound( sd, GlobalVolume, 0, Math.Max( 1, loops ), 0 );
if ( h != null ) TimelineSounds.Add( (sd, h, owner) );
}
public void StopAllSounds()
{
foreach ( var s in TimelineSounds ) Host?.StopSound( s.handle );
TimelineSounds.Clear();
Builtins.StopAllSoundObjects( this );
}
public void GetUrl( string url, string window )
{
if ( url.StartsWith( "FSCommand:", StringComparison.OrdinalIgnoreCase ) )
{
var cmd = url.Substring( 10 );
if ( cmd == "fullscreen" ) Host?.SetFullScreen( window == "true" );
return;
}
Host?.GetUrl( url, window );
}
// ======================================================================= rendering
/// <summary>Flattens the display list into draw items, back to front.</summary>
public void CollectRender( List<RenderItem> output )
{
output.Clear();
if ( Root == null ) return;
Collect( Root, Matrix.Identity, ColorTransform.Identity, null, null, output );
for ( int i = 0; i < output.Count; i++ ) output[i].Index = i;
}
void Collect( DisplayObject d, Matrix parentM, ColorTransform parentCx, List<Filter> parentFilters, List<RenderItem> masks, List<RenderItem> output )
{
if ( d.Removed || !d.Visible || (d.Adjust != null && d.Adjust.Hidden) ) return;
var m = parentM.Mul( d.EffectiveMatrix );
var cx = parentCx.Mul( d.Cx );
if ( cx.Am <= 0 && cx.Aa <= 0 ) return;
var filters = parentFilters;
if ( d.Filters != null && d.Filters.Count > 0 )
{
filters = parentFilters == null ? new List<Filter>( d.Filters ) : new List<Filter>( parentFilters );
if ( parentFilters != null ) filters.AddRange( d.Filters );
}
if ( d.MaskedBy != null && !d.MaskedBy.Removed )
{
var mi = new List<RenderItem>();
CollectMask( d.MaskedBy, d.MaskedBy.RenderParent?.WorldMatrix ?? Matrix.Identity, mi );
masks = masks == null ? mi : Concat( masks, mi );
}
switch ( d )
{
case MovieClip mc:
{
List<RenderItem> layerMask = null;
int maskUntil = int.MinValue;
foreach ( var c in mc.Children )
{
if ( c.Depth > maskUntil ) { layerMask = null; maskUntil = int.MinValue; }
if ( c.ClipDepth > 0 )
{
layerMask = new List<RenderItem>();
CollectMask( c, m, layerMask );
maskUntil = c.TimelineDepth >= 0 ? c.ClipDepth + MovieClip.TimelineDepthOffset : c.ClipDepth;
continue;
}
if ( c.MaskFor != null ) continue; // used as a setMask mask
Collect( c, m, cx, filters, layerMask != null ? (masks == null ? layerMask : Concat( masks, layerMask )) : masks, output );
}
break;
}
case ButtonObj b:
{
var list = b.Current;
if ( list != null ) foreach ( var c in list ) Collect( c, m, cx, filters, masks, output );
break;
}
default:
{
var it = new RenderItem { Obj = d, World = m, Cx = cx, Filters = filters, Masks = masks };
if ( masks != null ) it.Clip = MaskBounds( masks );
output.Add( it );
break;
}
}
}
static List<RenderItem> Concat( List<RenderItem> a, List<RenderItem> b ) { var r = new List<RenderItem>( a ); r.AddRange( b ); return r; }
/// <summary>The shapes of a mask (visibility and color are ignored, as in Flash).</summary>
void CollectMask( DisplayObject d, Matrix parentM, List<RenderItem> output )
{
if ( d.Removed ) return;
var m = parentM.Mul( d.EffectiveMatrix );
switch ( d )
{
case MovieClip mc: foreach ( var c in mc.Children ) if ( c.ClipDepth == 0 ) CollectMask( c, m, output ); break;
case ButtonObj b: if ( b.Current != null ) foreach ( var c in b.Current ) CollectMask( c, m, output ); break;
default: output.Add( new RenderItem { Obj = d, World = m, Cx = ColorTransform.Identity } ); break;
}
}
static double[] MaskBounds( List<RenderItem> masks )
{
double x0 = double.MaxValue, y0 = double.MaxValue, x1 = double.MinValue, y1 = double.MinValue;
foreach ( var mi in masks )
{
var b = mi.Obj.BoundsIn( mi.World );
if ( b == null ) continue;
x0 = Math.Min( x0, b.Value.x0 ); y0 = Math.Min( y0, b.Value.y0 ); x1 = Math.Max( x1, b.Value.x1 ); y1 = Math.Max( y1, b.Value.y1 );
}
return x0 == double.MaxValue ? new double[] { 0, 0, 0, 0 } : new[] { x0, y0, x1, y1 };
}
static double[] Intersect( double[] a, double[] b )
{
if ( a == null ) return b;
if ( b == null ) return a;
return new[] { Math.Max( a[0], b[0] ), Math.Max( a[1], b[1] ), Math.Min( a[2], b[2] ), Math.Min( a[3], b[3] ) };
}
}