Merge branch '4.0' into 4.1-beta

This commit is contained in:
Mario Zechner 2021-10-11 16:00:23 +02:00
commit e6aefc1065
11 changed files with 165 additions and 91 deletions

View File

@ -40,7 +40,7 @@ namespace Spine {
/// See <a href='http://esotericsoftware.com/spine-applying-animations/'>Applying Animations</a> in the Spine Runtimes Guide.</para> /// See <a href='http://esotericsoftware.com/spine-applying-animations/'>Applying Animations</a> in the Spine Runtimes Guide.</para>
/// </summary> /// </summary>
public class AnimationState { public class AnimationState {
static readonly Animation EmptyAnimation = new Animation("<empty>", new ExposedList<Timeline>(), 0); internal static readonly Animation EmptyAnimation = new Animation("<empty>", new ExposedList<Timeline>(), 0);
/// 1) A previously applied timeline has set this property.<para /> /// 1) A previously applied timeline has set this property.<para />
/// Result: Mix from the current pose to the timeline pose. /// Result: Mix from the current pose to the timeline pose.
@ -309,9 +309,11 @@ namespace Spine {
return applied; return applied;
} }
/// <summary>Version of <see cref="Apply"/> only applying EventTimelines for lightweight off-screen updates.</summary> /// <summary>Version of <see cref="Apply"/> only applying and updating time at
/// EventTimelines for lightweight off-screen updates.</summary>
/// <param name="issueEvents">When set to false, only animation times of TrackEntries are updated.</param>
// Note: This method is not part of the libgdx reference implementation. // Note: This method is not part of the libgdx reference implementation.
public bool ApplyEventTimelinesOnly (Skeleton skeleton) { public bool ApplyEventTimelinesOnly (Skeleton skeleton, bool issueEvents = true) {
if (skeleton == null) throw new ArgumentNullException("skeleton", "skeleton cannot be null."); if (skeleton == null) throw new ArgumentNullException("skeleton", "skeleton cannot be null.");
ExposedList<Event> events = this.events; ExposedList<Event> events = this.events;
@ -323,24 +325,28 @@ namespace Spine {
applied = true; applied = true;
// Apply mixing from entries first. // Apply mixing from entries first.
if (current.mixingFrom != null) ApplyMixingFromEventTimelinesOnly(current, skeleton); if (current.mixingFrom != null) ApplyMixingFromEventTimelinesOnly(current, skeleton, issueEvents);
// Apply current entry. // Apply current entry.
float animationLast = current.animationLast, animationTime = current.AnimationTime; float animationLast = current.animationLast, animationTime = current.AnimationTime;
int timelineCount = current.animation.timelines.Count;
Timeline[] timelines = current.animation.timelines.Items; if (issueEvents) {
for (int ii = 0; ii < timelineCount; ii++) { int timelineCount = current.animation.timelines.Count;
Timeline timeline = timelines[ii]; Timeline[] timelines = current.animation.timelines.Items;
if (timeline is EventTimeline) for (int ii = 0; ii < timelineCount; ii++) {
timeline.Apply(skeleton, animationLast, animationTime, events, 1.0f, MixBlend.Setup, MixDirection.In); Timeline timeline = timelines[ii];
if (timeline is EventTimeline)
timeline.Apply(skeleton, animationLast, animationTime, events, 1.0f, MixBlend.Setup, MixDirection.In);
}
QueueEvents(current, animationTime);
events.Clear(false);
} }
QueueEvents(current, animationTime);
events.Clear(false);
current.nextAnimationLast = animationTime; current.nextAnimationLast = animationTime;
current.nextTrackLast = current.trackTime; current.nextTrackLast = current.trackTime;
} }
queue.Drain(); if (issueEvents)
queue.Drain();
return applied; return applied;
} }
@ -434,11 +440,14 @@ namespace Spine {
return mix; return mix;
} }
/// <summary>Version of <see cref="ApplyMixingFrom"/> only applying EventTimelines for lightweight off-screen updates.</summary> /// <summary>Version of <see cref="ApplyMixingFrom"/> only applying and updating time at
/// EventTimelines for lightweight off-screen updates.</summary>
/// <param name="issueEvents">When set to false, only animation times of TrackEntries are updated.</param>
// Note: This method is not part of the libgdx reference implementation. // Note: This method is not part of the libgdx reference implementation.
private float ApplyMixingFromEventTimelinesOnly (TrackEntry to, Skeleton skeleton) { private float ApplyMixingFromEventTimelinesOnly (TrackEntry to, Skeleton skeleton, bool issueEvents) {
TrackEntry from = to.mixingFrom; TrackEntry from = to.mixingFrom;
if (from.mixingFrom != null) ApplyMixingFromEventTimelinesOnly(from, skeleton); if (from.mixingFrom != null) ApplyMixingFromEventTimelinesOnly(from, skeleton, issueEvents);
float mix; float mix;
if (to.mixDuration == 0) { // Single frame mix to undo mixingFrom changes. if (to.mixDuration == 0) { // Single frame mix to undo mixingFrom changes.
@ -452,16 +461,18 @@ namespace Spine {
if (eventBuffer == null) return mix; if (eventBuffer == null) return mix;
float animationLast = from.animationLast, animationTime = from.AnimationTime; float animationLast = from.animationLast, animationTime = from.AnimationTime;
int timelineCount = from.animation.timelines.Count; if (issueEvents) {
Timeline[] timelines = from.animation.timelines.Items; int timelineCount = from.animation.timelines.Count;
for (int i = 0; i < timelineCount; i++) { Timeline[] timelines = from.animation.timelines.Items;
Timeline timeline = timelines[i]; for (int i = 0; i < timelineCount; i++) {
if (timeline is EventTimeline) Timeline timeline = timelines[i];
timeline.Apply(skeleton, animationLast, animationTime, eventBuffer, 0, MixBlend.Setup, MixDirection.Out); if (timeline is EventTimeline)
} timeline.Apply(skeleton, animationLast, animationTime, eventBuffer, 0, MixBlend.Setup, MixDirection.Out);
}
if (to.mixDuration > 0) QueueEvents(from, animationTime); if (to.mixDuration > 0) QueueEvents(from, animationTime);
this.events.Clear(false); this.events.Clear(false);
}
from.nextAnimationLast = animationTime; from.nextAnimationLast = animationTime;
from.nextTrackLast = from.trackTime; from.nextTrackLast = from.trackTime;
@ -1242,6 +1253,10 @@ namespace Spine {
/// If true, the animation will be applied in reverse. Events are not fired when an animation is applied in reverse.</summary> /// If true, the animation will be applied in reverse. Events are not fired when an animation is applied in reverse.</summary>
public bool Reverse { get { return reverse; } set { reverse = value; } } public bool Reverse { get { return reverse; } set { reverse = value; } }
/// <summary>Returns true if this entry is for the empty animation. See <see cref="AnimationState.SetEmptyAnimation(int, float)"/>,
/// <see cref="AnimationState.AddEmptyAnimation(int, float, float)"/>, and <see cref="AnimationState.SetEmptyAnimations(float)"/>.
public bool IsEmptyAnimation { get { return animation == AnimationState.EmptyAnimation; } }
/// <summary> /// <summary>
/// <para> /// <para>
/// Resets the rotation directions for mixing this entry's rotate timelines. This can be useful to avoid bones rotating the /// Resets the rotation directions for mixing this entry's rotate timelines. This can be useful to avoid bones rotating the

View File

@ -274,7 +274,7 @@ namespace Spine {
/// Some information is ambiguous in the world transform, such as -1,-1 scale versus 180 rotation. The applied transform after /// Some information is ambiguous in the world transform, such as -1,-1 scale versus 180 rotation. The applied transform after
/// calling this method is equivalent to the local transform used to compute the world transform, but may not be identical. /// calling this method is equivalent to the local transform used to compute the world transform, but may not be identical.
/// </para></summary> /// </para></summary>
internal void UpdateAppliedTransform () { public void UpdateAppliedTransform () {
Bone parent = this.parent; Bone parent = this.parent;
if (parent == null) { if (parent == null) {
ax = worldX - skeleton.x; ax = worldX - skeleton.x;

View File

@ -173,11 +173,16 @@ namespace Spine.Unity {
int i = 0; int i = 0;
if (content.Length >= 3 && content[0] == 0xEF && content[1] == 0xBB && content[2] == 0xBF) // skip potential BOM if (content.Length >= 3 && content[0] == 0xEF && content[1] == 0xBB && content[2] == 0xBF) // skip potential BOM
i = 3; i = 3;
bool openingBraceFound = false;
for (; i < numCharsToCheck; ++i) { for (; i < numCharsToCheck; ++i) {
char c = (char)content[i]; char c = (char)content[i];
if (char.IsWhiteSpace(c)) if (char.IsWhiteSpace(c))
continue; continue;
return c == '{'; if (!openingBraceFound) {
if (c == '{') openingBraceFound = true;
else return false;
} else
return c == '"';
} }
return true; return true;
} }

View File

@ -206,8 +206,10 @@ namespace Spine.Unity {
return; return;
UpdateAnimationStatus(deltaTime); UpdateAnimationStatus(deltaTime);
if (updateMode == UpdateMode.OnlyAnimationStatus) if (updateMode == UpdateMode.OnlyAnimationStatus) {
state.ApplyEventTimelinesOnly(skeleton, issueEvents: false);
return; return;
}
ApplyAnimation(); ApplyAnimation();
} }
@ -224,7 +226,7 @@ namespace Spine.Unity {
if (updateMode != UpdateMode.OnlyEventTimelines) if (updateMode != UpdateMode.OnlyEventTimelines)
state.Apply(skeleton); state.Apply(skeleton);
else else
state.ApplyEventTimelinesOnly(skeleton); state.ApplyEventTimelinesOnly(skeleton, issueEvents: true);
if (_UpdateLocal != null) if (_UpdateLocal != null)
_UpdateLocal(this); _UpdateLocal(this);
@ -246,6 +248,18 @@ namespace Spine.Unity {
if (!wasUpdatedAfterInit) Update(0); if (!wasUpdatedAfterInit) Update(0);
base.LateUpdate(); base.LateUpdate();
} }
public override void OnBecameVisible () {
UpdateMode previousUpdateMode = updateMode;
updateMode = UpdateMode.FullUpdate;
// OnBecameVisible is called after LateUpdate()
if (previousUpdateMode != UpdateMode.FullUpdate &&
previousUpdateMode != UpdateMode.EverythingExceptMesh)
Update(0);
if (previousUpdateMode != UpdateMode.FullUpdate)
LateUpdate();
}
} }
} }

View File

@ -265,10 +265,13 @@ namespace Spine.Unity {
wasUpdatedAfterInit = true; wasUpdatedAfterInit = true;
if (updateMode < UpdateMode.OnlyAnimationStatus) if (updateMode < UpdateMode.OnlyAnimationStatus)
return; return;
UpdateAnimationStatus(deltaTime); UpdateAnimationStatus(deltaTime);
if (updateMode == UpdateMode.OnlyAnimationStatus) if (updateMode == UpdateMode.OnlyAnimationStatus) {
state.ApplyEventTimelinesOnly(skeleton, issueEvents: false);
return; return;
}
ApplyAnimation(); ApplyAnimation();
} }
@ -303,7 +306,7 @@ namespace Spine.Unity {
if (updateMode != UpdateMode.OnlyEventTimelines) if (updateMode != UpdateMode.OnlyEventTimelines)
state.Apply(skeleton); state.Apply(skeleton);
else else
state.ApplyEventTimelinesOnly(skeleton); state.ApplyEventTimelinesOnly(skeleton, issueEvents: true);
if (UpdateLocal != null) if (UpdateLocal != null)
UpdateLocal(this); UpdateLocal(this);

View File

@ -140,6 +140,18 @@ namespace Spine.Unity {
base.LateUpdate(); base.LateUpdate();
} }
public override void OnBecameVisible () {
UpdateMode previousUpdateMode = updateMode;
updateMode = UpdateMode.FullUpdate;
// OnBecameVisible is called after LateUpdate()
if (previousUpdateMode != UpdateMode.FullUpdate &&
previousUpdateMode != UpdateMode.EverythingExceptMesh)
Update();
if (previousUpdateMode != UpdateMode.FullUpdate)
LateUpdate();
}
[System.Serializable] [System.Serializable]
public class MecanimTranslator { public class MecanimTranslator {

View File

@ -554,11 +554,13 @@ namespace Spine.Unity {
OnMeshAndMaterialsUpdated(this); OnMeshAndMaterialsUpdated(this);
} }
public void OnBecameVisible () { public virtual void OnBecameVisible () {
UpdateMode previousUpdateMode = updateMode; UpdateMode previousUpdateMode = updateMode;
updateMode = UpdateMode.FullUpdate; updateMode = UpdateMode.FullUpdate;
// OnBecameVisible is called after LateUpdate()
if (previousUpdateMode != UpdateMode.FullUpdate) if (previousUpdateMode != UpdateMode.FullUpdate)
LateUpdate(); // OnBecameVisible is called after LateUpdate() LateUpdate();
} }
public void OnBecameInvisible () { public void OnBecameInvisible () {

View File

@ -79,7 +79,7 @@ namespace Spine.Unity.Editor {
if (timelineClip == null) if (timelineClip == null)
return; return;
float blendInDur = (float)timelineClip.blendInDuration; float blendInDur = System.Math.Max((float)timelineClip.blendInDuration, (float)timelineClip.easeInDuration);
bool isBlendingNow = blendInDur > 0; bool isBlendingNow = blendInDur > 0;
bool wasBlendingBefore = timelineClipInfo.previousBlendInDuration > 0; bool wasBlendingBefore = timelineClipInfo.previousBlendInDuration > 0;

View File

@ -38,8 +38,10 @@ namespace Spine.Unity.Playables {
public class SpineAnimationStateMixerBehaviour : PlayableBehaviour { public class SpineAnimationStateMixerBehaviour : PlayableBehaviour {
float[] lastInputWeights; float[] lastInputWeights;
bool lastAnyTrackPlaying = false; bool lastAnyClipPlaying = false;
public int trackIndex; public int trackIndex;
ScriptPlayable<SpineAnimationStateBehaviour>[] startingClips
= new ScriptPlayable<SpineAnimationStateBehaviour>[2];
IAnimationStateComponent animationStateComponent; IAnimationStateComponent animationStateComponent;
bool pauseWithDirector = true; bool pauseWithDirector = true;
@ -135,60 +137,81 @@ namespace Spine.Unity.Playables {
this.lastInputWeights[i] = default(float); this.lastInputWeights[i] = default(float);
} }
var lastInputWeights = this.lastInputWeights; var lastInputWeights = this.lastInputWeights;
bool anyTrackPlaying = false; int numStartingClips = 0;
bool anyClipPlaying = false;
// Check all clips. If a clip that was weight 0 turned into weight 1, call SetAnimation. // Check all clips. If a clip that was weight 0 turned into weight 1, call SetAnimation.
for (int i = 0; i < inputCount; i++) { for (int i = 0; i < inputCount; i++) {
float lastInputWeight = lastInputWeights[i]; float lastInputWeight = lastInputWeights[i];
float inputWeight = playable.GetInputWeight(i); float inputWeight = playable.GetInputWeight(i);
bool trackStarted = lastInputWeight == 0 && inputWeight > 0; bool clipStarted = lastInputWeight == 0 && inputWeight > 0;
if (inputWeight > 0) if (inputWeight > 0)
anyTrackPlaying = true; anyClipPlaying = true;
lastInputWeights[i] = inputWeight; lastInputWeights[i] = inputWeight;
if (trackStarted) { if (clipStarted && numStartingClips < 2) {
ScriptPlayable<SpineAnimationStateBehaviour> inputPlayable = (ScriptPlayable<SpineAnimationStateBehaviour>)playable.GetInput(i); ScriptPlayable<SpineAnimationStateBehaviour> clipPlayable = (ScriptPlayable<SpineAnimationStateBehaviour>)playable.GetInput(i);
SpineAnimationStateBehaviour clipData = inputPlayable.GetBehaviour(); startingClips[numStartingClips++] = clipPlayable;
pauseWithDirector = !clipData.dontPauseWithDirector;
endAtClipEnd = !clipData.dontEndWithClip;
endMixOutDuration = clipData.endMixOutDuration;
if (clipData.animationReference == null) {
float mixDuration = clipData.customDuration ? clipData.mixDuration : state.Data.DefaultMix;
state.SetEmptyAnimation(trackIndex, mixDuration);
} else {
if (clipData.animationReference.Animation != null) {
Spine.TrackEntry trackEntry = state.SetAnimation(trackIndex, clipData.animationReference.Animation, clipData.loop);
trackEntry.EventThreshold = clipData.eventThreshold;
trackEntry.DrawOrderThreshold = clipData.drawOrderThreshold;
trackEntry.TrackTime = (float)inputPlayable.GetTime() * (float)inputPlayable.GetSpeed();
trackEntry.TimeScale = (float)inputPlayable.GetSpeed();
trackEntry.AttachmentThreshold = clipData.attachmentThreshold;
trackEntry.HoldPrevious = clipData.holdPrevious;
if (clipData.customDuration)
trackEntry.MixDuration = clipData.mixDuration;
timelineStartedTrackEntry = trackEntry;
}
//else Debug.LogWarningFormat("Animation named '{0}' not found", clipData.animationName);
}
// Ensure that the first frame ends with an updated mesh.
if (skeletonAnimation) {
skeletonAnimation.Update(0);
skeletonAnimation.LateUpdate();
} else if (skeletonGraphic) {
skeletonGraphic.Update(0);
skeletonGraphic.LateUpdate();
}
} }
} }
if (lastAnyTrackPlaying && !anyTrackPlaying) // unfortunately order of clips can be wrong when two start at the same time, we have to sort clips
if (numStartingClips == 2) {
ScriptPlayable<SpineAnimationStateBehaviour> clipPlayable0 = startingClips[0];
ScriptPlayable<SpineAnimationStateBehaviour> clipPlayable1 = startingClips[1];
if (clipPlayable0.GetDuration() > clipPlayable1.GetDuration()) { // swap, clip 0 ends after clip 1
startingClips[0] = clipPlayable1;
startingClips[1] = clipPlayable0;
}
}
for (int j = 0; j < numStartingClips; ++j) {
ScriptPlayable<SpineAnimationStateBehaviour> clipPlayable = startingClips[j];
SpineAnimationStateBehaviour clipData = clipPlayable.GetBehaviour();
pauseWithDirector = !clipData.dontPauseWithDirector;
endAtClipEnd = !clipData.dontEndWithClip;
endMixOutDuration = clipData.endMixOutDuration;
if (clipData.animationReference == null) {
float mixDuration = clipData.customDuration ? clipData.mixDuration : state.Data.DefaultMix;
state.SetEmptyAnimation(trackIndex, mixDuration);
} else {
if (clipData.animationReference.Animation != null) {
TrackEntry currentEntry = state.GetCurrent(trackIndex);
Spine.TrackEntry trackEntry;
if (currentEntry == null && (clipData.customDuration && clipData.mixDuration > 0)) {
state.SetEmptyAnimation(trackIndex, 0); // ease in requires empty animation
trackEntry = state.AddAnimation(trackIndex, clipData.animationReference.Animation, clipData.loop, 0);
} else
trackEntry = state.SetAnimation(trackIndex, clipData.animationReference.Animation, clipData.loop);
trackEntry.EventThreshold = clipData.eventThreshold;
trackEntry.DrawOrderThreshold = clipData.drawOrderThreshold;
trackEntry.TrackTime = (float)clipPlayable.GetTime() * (float)clipPlayable.GetSpeed();
trackEntry.TimeScale = (float)clipPlayable.GetSpeed();
trackEntry.AttachmentThreshold = clipData.attachmentThreshold;
trackEntry.HoldPrevious = clipData.holdPrevious;
if (clipData.customDuration)
trackEntry.MixDuration = clipData.mixDuration;
timelineStartedTrackEntry = trackEntry;
}
//else Debug.LogWarningFormat("Animation named '{0}' not found", clipData.animationName);
}
// Ensure that the first frame ends with an updated mesh.
if (skeletonAnimation) {
skeletonAnimation.Update(0);
skeletonAnimation.LateUpdate();
} else if (skeletonGraphic) {
skeletonGraphic.Update(0);
skeletonGraphic.LateUpdate();
}
}
startingClips[0] = startingClips[1] = ScriptPlayable<SpineAnimationStateBehaviour>.Null;
if (lastAnyClipPlaying && !anyClipPlaying)
HandleClipEnd(); HandleClipEnd();
this.lastAnyTrackPlaying = anyTrackPlaying; this.lastAnyClipPlaying = anyClipPlaying;
} }
#if SPINE_EDITMODEPOSE #if SPINE_EDITMODEPOSE
@ -251,25 +274,25 @@ namespace Spine.Unity.Playables {
if (fromAnimation != null && mixDuration > 0 && toClipTime < mixDuration) { if (fromAnimation != null && mixDuration > 0 && toClipTime < mixDuration) {
dummyAnimationState = dummyAnimationState ?? new AnimationState(skeletonComponent.SkeletonDataAsset.GetAnimationStateData()); dummyAnimationState = dummyAnimationState ?? new AnimationState(skeletonComponent.SkeletonDataAsset.GetAnimationStateData());
var toTrack = dummyAnimationState.GetCurrent(0); var toEntry = dummyAnimationState.GetCurrent(0);
var fromTrack = toTrack != null ? toTrack.MixingFrom : null; var fromEntry = toEntry != null ? toEntry.MixingFrom : null;
bool isAnimationTransitionMatch = (toTrack != null && toTrack.Animation == toAnimation && fromTrack != null && fromTrack.Animation == fromAnimation); bool isAnimationTransitionMatch = (toEntry != null && toEntry.Animation == toAnimation && fromEntry != null && fromEntry.Animation == fromAnimation);
if (!isAnimationTransitionMatch) { if (!isAnimationTransitionMatch) {
dummyAnimationState.ClearTracks(); dummyAnimationState.ClearTracks();
fromTrack = dummyAnimationState.SetAnimation(0, fromAnimation, fromClipLoop); fromEntry = dummyAnimationState.SetAnimation(0, fromAnimation, fromClipLoop);
fromTrack.AllowImmediateQueue(); fromEntry.AllowImmediateQueue();
if (toAnimation != null) { if (toAnimation != null) {
toTrack = dummyAnimationState.SetAnimation(0, toAnimation, clipData.loop); toEntry = dummyAnimationState.SetAnimation(0, toAnimation, clipData.loop);
toTrack.HoldPrevious = clipData.holdPrevious; toEntry.HoldPrevious = clipData.holdPrevious;
} }
} }
// Update track times. // Update track times.
fromTrack.TrackTime = fromClipTime; fromEntry.TrackTime = fromClipTime;
if (toTrack != null) { if (toEntry != null) {
toTrack.TrackTime = toClipTime; toEntry.TrackTime = toClipTime;
toTrack.MixTime = toClipTime; toEntry.MixTime = toClipTime;
} }
// Apply Pose // Apply Pose

View File

@ -2,7 +2,7 @@
"name": "com.esotericsoftware.spine.timeline", "name": "com.esotericsoftware.spine.timeline",
"displayName": "Spine Timeline Extensions", "displayName": "Spine Timeline Extensions",
"description": "This plugin provides integration of spine-unity for the Unity Timeline.\n\nPrerequisites:\nIt requires a working installation of the spine-unity runtime (via the spine-unity unitypackage), version 4.0.\n(See http://esotericsoftware.com/git/spine-runtimes/spine-unity)", "description": "This plugin provides integration of spine-unity for the Unity Timeline.\n\nPrerequisites:\nIt requires a working installation of the spine-unity runtime (via the spine-unity unitypackage), version 4.0.\n(See http://esotericsoftware.com/git/spine-runtimes/spine-unity)",
"version": "4.0.5", "version": "4.0.6",
"unity": "2018.3", "unity": "2018.3",
"author": { "author": {
"name": "Esoteric Software", "name": "Esoteric Software",

View File

@ -2,7 +2,7 @@
"name": "com.esotericsoftware.spine.timeline", "name": "com.esotericsoftware.spine.timeline",
"displayName": "Spine Timeline Extensions", "displayName": "Spine Timeline Extensions",
"description": "This plugin provides integration of spine-unity for the Unity Timeline.\n\nPrerequisites:\nIt requires a working installation of the spine-unity and spine-csharp runtimes as UPM packages (not as spine-unity unitypackage), version 4.0.\n(See http://esotericsoftware.com/git/spine-runtimes/spine-unity)", "description": "This plugin provides integration of spine-unity for the Unity Timeline.\n\nPrerequisites:\nIt requires a working installation of the spine-unity and spine-csharp runtimes as UPM packages (not as spine-unity unitypackage), version 4.0.\n(See http://esotericsoftware.com/git/spine-runtimes/spine-unity)",
"version": "4.0.5", "version": "4.0.6",
"unity": "2018.3", "unity": "2018.3",
"author": { "author": {
"name": "Esoteric Software", "name": "Esoteric Software",