Code: Select all
; ============================================================================
; Spriter Pro SCML Demo - PureBasic 6.30
;
; Supports Spriter Pro r11 sprite-with-bones mode ONLY!
;
; 02/28/26 By D_Ogre
;
; Loads a Spriter SCML project, displays animated sprites with full
; keyboard-controlled movement, animation toggling, and HUD overlay.
;
; FIXED: Bone hierarchy is now properly applied to body-part sprites.
; Each sprite's local timeline transform is composed with its parent
; bone's accumulated world-space transform before drawing.
;
; USAGE:
; 1. Place your .scml file and its assets (PNG images, sounds) in a folder.
; 2. At runtime Press O and a file-open dialog appears - select the .scml file.
; 3. Controls (see also on-screen HUD):
; Arrow keys - move sprite around the screen
; A / D - previous / next animation
; SPACE - pause / resume playback
; + / - - speed up / slow down animation
; [ / ] - scale sprite up / down
; R - reset position, scale and speed to defaults
; F - flip character facing (mirror around anchor)
; V - flip character vertically (mirror around ground)
; F1 - toggle HUD overlay
; Escape - quit
; ============================================================================
UsePNGImageDecoder()
UseOGGSoundDecoder() ; WAV is built-in; OGG and FLAC require explicit decoders
If Not InitSprite() Or Not InitSound() Or Not InitKeyboard()
MessageRequester("ERROR", "Unable to initialise subsystems.", #PB_MessageRequester_Error | #PB_MessageRequester_Ok)
End
EndIf
; ============================================================================
; CONSTANTS
; ============================================================================
#MAX_OBJS = 256
#MAX_BONES = 128
; ============================================================================
; STRUCTURES
; ============================================================================
Structure SpriterFile
id.i
name.s
width.i
height.i
pivot_x.f
pivot_y.f
type.s
imageID.i
soundID.i
EndStructure
Structure SpriterFolder
id.i
name.s
List files.SpriterFile()
EndStructure
Structure SpriterObjectRef
id.i
parent.i ; id of parent bone_ref (-1 = no parent)
timeline.i
key.i
z_index.i
name.s
folder.i
file.i
abs_x.f
abs_y.f
abs_angle.f
abs_scale_x.f
abs_scale_y.f
abs_a.f
EndStructure
Structure SpriterBoneRef
id.i
parent.i ; id of parent bone_ref (-1 = no parent)
timeline.i
key.i
EndStructure
Structure SpriterMainlineKey
id.i
time.i
List bone_refs.SpriterBoneRef()
List object_refs.SpriterObjectRef()
EndStructure
Structure SpriterTimelineKey
id.i
time.i
spin.i
folder.i
file.i
x.f
y.f
angle.f
scale_x.f
scale_y.f
alpha.f
; Per-key pivot override stored on <object> child of each timeline <key>.
; -1.0 = attribute absent => fall back to file-level pivot from <file> element.
key_pivot_x.f
key_pivot_y.f
EndStructure
Structure SpriterTimeline
id.i
name.s
object_type.s
List keys.SpriterTimelineKey()
EndStructure
Structure SpriterSoundlineKey
id.i
time.i
folder.i
file.i
EndStructure
Structure SpriterSoundline
id.i
name.s
List keys.SpriterSoundlineKey()
EndStructure
Structure SpriterAnimation
id.i
name.s
length.i
looping.i
List mainline_keys.SpriterMainlineKey()
List timelines.SpriterTimeline()
List soundlines.SpriterSoundline()
EndStructure
Structure SpriterEntity
id.i
name.s
List animations.SpriterAnimation()
EndStructure
; World-space transform used during bone hierarchy computation
Structure WorldTransform
x.f
y.f
angle.f
scale_x.f
scale_y.f
alpha.f
EndStructure
; ============================================================================
; GLOBALS
; ============================================================================
Global NewList folders.SpriterFolder()
Global NewList entities.SpriterEntity()
Global basePath.s
; Skip animation index 0 (TriggerIntro - box trigger, draws nothing) and
; index 1 (intro - one-shot fall-in cutscene). Start directly on Idle (index 2).
Global currentAnimation.i = 2
Global facingLeft.i = #False ; #True = mirror character around basePosX (face left)
Global facingUp.i = #False ; #True = mirror character around basePosY (flip upside-down)
Global currentTime.i = 0
Global animationSpeed.f = 1.0
Global isPlaying.i = #True
Global basePosX.f = 400.0
Global basePosY.f = 300.0
Global scaleMultiplier.f = 1.0
Global showHUD.i = #True
Global moveSpeed.f = 3.0
Global loadError.s = "" ; set by LoadSpriterData when loading fails
Global lastSoundTime.i = -1
Global prevSoundTime.i = -1
; ============================================================================
; FORWARD DECLARATIONS
; ============================================================================
Declare.i GetSoundID(folderID.i, fileID.i)
; ============================================================================
; SCML LOADER
; ============================================================================
Procedure LoadSpriterData(filename.s)
Protected xml = LoadXML(#PB_Any, filename)
If Not xml
Debug "Error loading XML file: " + filename
ProcedureReturn #False
EndIf
Protected *root = MainXMLNode(xml)
If Not *root Or GetXMLNodeName(*root) <> "spriter_data"
Debug "Invalid file format."
FreeXML(xml)
ProcedureReturn #False
EndIf
basePath = GetPathPart(filename)
; Error tracking: detect unsupported SCML features during parsing.
Protected unsupportedType.s = "" ; first unsupported object_type found
Protected skinRefCount.i = 0 ; object_refs using skin= instead of timeline=
Protected spriteTimelineCount.i = 0 ; timelines that are renderable
; Folders & files
Protected *node = ChildXMLNode(*root)
While *node
If GetXMLNodeName(*node) = "folder"
AddElement(folders())
folders()\id = Val(GetXMLAttribute(*node, "id"))
folders()\name = GetXMLAttribute(*node, "name")
Protected *file = ChildXMLNode(*node)
While *file
If GetXMLNodeName(*file) = "file"
AddElement(folders()\files())
folders()\files()\id = Val(GetXMLAttribute(*file, "id"))
folders()\files()\name = GetXMLAttribute(*file, "name")
folders()\files()\width = Val(GetXMLAttribute(*file, "width"))
folders()\files()\height = Val(GetXMLAttribute(*file, "height"))
; Check for absent attribute (not value=0).
; pivot_x="0" means left-edge pivot; pivot_y="0" means bottom pivot.
; Spriter defaults when absent: pivot_x=0.5, pivot_y=0.0
Protected pvxStr.s = GetXMLAttribute(*file, "pivot_x")
If pvxStr = ""
folders()\files()\pivot_x = 0.5
Else
folders()\files()\pivot_x = ValF(pvxStr)
EndIf
Protected pvyStr.s = GetXMLAttribute(*file, "pivot_y")
If pvyStr = ""
folders()\files()\pivot_y = 0.0
Else
folders()\files()\pivot_y = ValF(pvyStr)
EndIf
folders()\files()\type = GetXMLAttribute(*file, "type")
If folders()\files()\type = "" : folders()\files()\type = "sprite" : EndIf
Protected fullPath.s = basePath + folders()\files()\name
folders()\files()\imageID = -1 ; sentinel: -1 = not loaded
folders()\files()\soundID = -1 ; sentinel: -1 = not loaded
If folders()\files()\type = "sound"
If FileSize(fullPath) <> -1
folders()\files()\soundID = LoadSound(#PB_Any, fullPath)
If folders()\files()\soundID < 0
missingFiles + 1
Debug "Failed to load sound: " + fullPath
EndIf
Else
missingFiles + 1
Debug "Missing sound: " + fullPath
EndIf
Else
If FileSize(fullPath) <> -1
folders()\files()\imageID = LoadSprite(#PB_Any, fullPath, #PB_Sprite_AlphaBlending)
If Not IsSprite(folders()\files()\imageID)
folders()\files()\imageID = -1
missingFiles + 1
Debug "Failed to load image: " + fullPath
EndIf
Else
missingFiles + 1
Debug "Missing image: " + fullPath
EndIf
EndIf
EndIf
*file = NextXMLNode(*file)
Wend
EndIf
*node = NextXMLNode(*node)
Wend
; Entities & animations
*node = ChildXMLNode(*root)
While *node
If GetXMLNodeName(*node) = "entity"
AddElement(entities())
entities()\id = Val(GetXMLAttribute(*node, "id"))
entities()\name = GetXMLAttribute(*node, "name")
Protected *child = ChildXMLNode(*node)
While *child
If GetXMLNodeName(*child) = "animation"
AddElement(entities()\animations())
entities()\animations()\id = Val(GetXMLAttribute(*child, "id"))
entities()\animations()\name = GetXMLAttribute(*child, "name")
entities()\animations()\length = Val(GetXMLAttribute(*child, "length"))
entities()\animations()\looping = 1
Protected loopVal.s = GetXMLAttribute(*child, "looping")
If loopVal = "false" Or loopVal = "0"
entities()\animations()\looping = 0
EndIf
Debug "Animation: " + entities()\animations()\name + " (" + Str(entities()\animations()\length) + "ms)"
Protected *animChild = ChildXMLNode(*child)
While *animChild
Select GetXMLNodeName(*animChild)
Case "mainline"
Protected *mainKey = ChildXMLNode(*animChild)
While *mainKey
If GetXMLNodeName(*mainKey) = "key"
AddElement(entities()\animations()\mainline_keys())
entities()\animations()\mainline_keys()\id = Val(GetXMLAttribute(*mainKey, "id"))
entities()\animations()\mainline_keys()\time = Val(GetXMLAttribute(*mainKey, "time"))
Protected *ref = ChildXMLNode(*mainKey)
While *ref
If GetXMLNodeName(*ref) = "bone_ref"
AddElement(entities()\animations()\mainline_keys()\bone_refs())
entities()\animations()\mainline_keys()\bone_refs()\id = Val(GetXMLAttribute(*ref, "id"))
; Default parent to -1 when attribute is absent (root bone)
Protected bParentAttr.s = GetXMLAttribute(*ref, "parent")
If bParentAttr = ""
entities()\animations()\mainline_keys()\bone_refs()\parent = -1
Else
entities()\animations()\mainline_keys()\bone_refs()\parent = Val(bParentAttr)
EndIf
entities()\animations()\mainline_keys()\bone_refs()\timeline = Val(GetXMLAttribute(*ref, "timeline"))
entities()\animations()\mainline_keys()\bone_refs()\key = Val(GetXMLAttribute(*ref, "key"))
ElseIf GetXMLNodeName(*ref) = "object_ref"
AddElement(entities()\animations()\mainline_keys()\object_refs())
entities()\animations()\mainline_keys()\object_refs()\id = Val(GetXMLAttribute(*ref, "id"))
Protected oParentAttr.s = GetXMLAttribute(*ref, "parent")
If oParentAttr = ""
entities()\animations()\mainline_keys()\object_refs()\parent = -1
Else
entities()\animations()\mainline_keys()\object_refs()\parent = Val(oParentAttr)
EndIf
Protected tlAttr.s = GetXMLAttribute(*ref, "timeline")
If tlAttr = ""
; Skin/mesh format uses skin= and s= instead of timeline= and key=.
skinRefCount + 1
EndIf
entities()\animations()\mainline_keys()\object_refs()\timeline = Val(tlAttr)
entities()\animations()\mainline_keys()\object_refs()\key = Val(GetXMLAttribute(*ref, "key"))
entities()\animations()\mainline_keys()\object_refs()\z_index = Val(GetXMLAttribute(*ref, "z_index"))
entities()\animations()\mainline_keys()\object_refs()\name = GetXMLAttribute(*ref, "name")
entities()\animations()\mainline_keys()\object_refs()\folder = Val(GetXMLAttribute(*ref, "folder"))
entities()\animations()\mainline_keys()\object_refs()\file = Val(GetXMLAttribute(*ref, "file"))
entities()\animations()\mainline_keys()\object_refs()\abs_x = ValF(GetXMLAttribute(*ref, "abs_x"))
entities()\animations()\mainline_keys()\object_refs()\abs_y = ValF(GetXMLAttribute(*ref, "abs_y"))
entities()\animations()\mainline_keys()\object_refs()\abs_angle = ValF(GetXMLAttribute(*ref, "abs_angle"))
entities()\animations()\mainline_keys()\object_refs()\abs_scale_x = ValF(GetXMLAttribute(*ref, "abs_scale_x"))
If entities()\animations()\mainline_keys()\object_refs()\abs_scale_x = 0
entities()\animations()\mainline_keys()\object_refs()\abs_scale_x = 1.0
EndIf
entities()\animations()\mainline_keys()\object_refs()\abs_scale_y = ValF(GetXMLAttribute(*ref, "abs_scale_y"))
If entities()\animations()\mainline_keys()\object_refs()\abs_scale_y = 0
entities()\animations()\mainline_keys()\object_refs()\abs_scale_y = 1.0
EndIf
entities()\animations()\mainline_keys()\object_refs()\abs_a = ValF(GetXMLAttribute(*ref, "abs_a"))
If entities()\animations()\mainline_keys()\object_refs()\abs_a = 0
entities()\animations()\mainline_keys()\object_refs()\abs_a = 1.0
EndIf
EndIf
*ref = NextXMLNode(*ref)
Wend
EndIf
*mainKey = NextXMLNode(*mainKey)
Wend
Case "timeline"
AddElement(entities()\animations()\timelines())
entities()\animations()\timelines()\id = Val(GetXMLAttribute(*animChild, "id"))
entities()\animations()\timelines()\name = GetXMLAttribute(*animChild, "name")
entities()\animations()\timelines()\object_type = GetXMLAttribute(*animChild, "object_type")
Select entities()\animations()\timelines()\object_type
Case "", "sprite"
spriteTimelineCount + 1
Case "box" ; hitboxes - parsed, used for collision, not rendered
Case "bone" ; bone transforms - parsed in Step 1, not directly rendered
Default
If unsupportedType = ""
unsupportedType = entities()\animations()\timelines()\object_type
EndIf
EndSelect
Protected *timeKey = ChildXMLNode(*animChild)
While *timeKey
If GetXMLNodeName(*timeKey) = "key"
AddElement(entities()\animations()\timelines()\keys())
entities()\animations()\timelines()\keys()\id = Val(GetXMLAttribute(*timeKey, "id"))
entities()\animations()\timelines()\keys()\time = Val(GetXMLAttribute(*timeKey, "time"))
; spin="0" means no rotation; absent spin defaults to 1.
; Val("") = Val("0") = 0, so check absence explicitly.
Protected spinAttr.s = GetXMLAttribute(*timeKey, "spin")
If spinAttr = ""
entities()\animations()\timelines()\keys()\spin = 1
Else
entities()\animations()\timelines()\keys()\spin = Val(spinAttr)
EndIf
Protected *obj = ChildXMLNode(*timeKey)
If *obj
If GetXMLNodeName(*obj) = "object"
entities()\animations()\timelines()\keys()\folder = Val(GetXMLAttribute(*obj, "folder"))
entities()\animations()\timelines()\keys()\file = Val(GetXMLAttribute(*obj, "file"))
entities()\animations()\timelines()\keys()\x = ValF(GetXMLAttribute(*obj, "x"))
entities()\animations()\timelines()\keys()\y = ValF(GetXMLAttribute(*obj, "y"))
entities()\animations()\timelines()\keys()\angle = ValF(GetXMLAttribute(*obj, "angle"))
; Check string absence, NOT value=0.
; scale_x="0" collapses effects; a="0" hides them.
; Overriding those to 1.0 breaks all effects visibility.
Protected sxStr.s = GetXMLAttribute(*obj, "scale_x")
If sxStr = "" : entities()\animations()\timelines()\keys()\scale_x = 1.0
Else : entities()\animations()\timelines()\keys()\scale_x = ValF(sxStr) : EndIf
Protected syStr.s = GetXMLAttribute(*obj, "scale_y")
If syStr = "" : entities()\animations()\timelines()\keys()\scale_y = 1.0
Else : entities()\animations()\timelines()\keys()\scale_y = ValF(syStr) : EndIf
Protected aStr.s = GetXMLAttribute(*obj, "a")
If aStr = "" : entities()\animations()\timelines()\keys()\alpha = 1.0
Else : entities()\animations()\timelines()\keys()\alpha = ValF(aStr) : EndIf
; Per-key pivot: Spriter writes pivot_x/pivot_y on <object> to
; override the file-level pivot per frame (used heavily by effects).
; -1.0 sentinel means absent => use file-level pivot at draw time.
Protected kpxStr.s = GetXMLAttribute(*obj, "pivot_x")
If kpxStr = "" : entities()\animations()\timelines()\keys()\key_pivot_x = -1.0
Else : entities()\animations()\timelines()\keys()\key_pivot_x = ValF(kpxStr) : EndIf
Protected kpyStr.s = GetXMLAttribute(*obj, "pivot_y")
If kpyStr = "" : entities()\animations()\timelines()\keys()\key_pivot_y = -1.0
Else : entities()\animations()\timelines()\keys()\key_pivot_y = ValF(kpyStr) : EndIf
ElseIf GetXMLNodeName(*obj) = "bone"
entities()\animations()\timelines()\keys()\x = ValF(GetXMLAttribute(*obj, "x"))
entities()\animations()\timelines()\keys()\y = ValF(GetXMLAttribute(*obj, "y"))
entities()\animations()\timelines()\keys()\angle = ValF(GetXMLAttribute(*obj, "angle"))
Protected bsxStr.s = GetXMLAttribute(*obj, "scale_x")
If bsxStr = "" : entities()\animations()\timelines()\keys()\scale_x = 1.0
Else : entities()\animations()\timelines()\keys()\scale_x = ValF(bsxStr) : EndIf
Protected bsyStr.s = GetXMLAttribute(*obj, "scale_y")
If bsyStr = "" : entities()\animations()\timelines()\keys()\scale_y = 1.0
Else : entities()\animations()\timelines()\keys()\scale_y = ValF(bsyStr) : EndIf
entities()\animations()\timelines()\keys()\alpha = 1.0
EndIf
EndIf
EndIf
*timeKey = NextXMLNode(*timeKey)
Wend
Case "soundline"
AddElement(entities()\animations()\soundlines())
entities()\animations()\soundlines()\id = Val(GetXMLAttribute(*animChild, "id"))
entities()\animations()\soundlines()\name = GetXMLAttribute(*animChild, "name")
Protected *soundKey = ChildXMLNode(*animChild)
While *soundKey
If GetXMLNodeName(*soundKey) = "key"
AddElement(entities()\animations()\soundlines()\keys())
entities()\animations()\soundlines()\keys()\id = Val(GetXMLAttribute(*soundKey, "id"))
entities()\animations()\soundlines()\keys()\time = Val(GetXMLAttribute(*soundKey, "time"))
Protected *sound = ChildXMLNode(*soundKey)
If *sound And GetXMLNodeName(*sound) = "object"
entities()\animations()\soundlines()\keys()\folder = Val(GetXMLAttribute(*sound, "folder"))
entities()\animations()\soundlines()\keys()\file = Val(GetXMLAttribute(*sound, "file"))
; Verify the folder/file actually resolved to a loaded sound
Protected chkSnd.i = GetSoundID(entities()\animations()\soundlines()\keys()\folder,
entities()\animations()\soundlines()\keys()\file)
If chkSnd < 0
Debug "Soundline '" + entities()\animations()\soundlines()\name + "' key " +
Str(entities()\animations()\soundlines()\keys()\id) +
": sound not loaded (folder=" +
Str(entities()\animations()\soundlines()\keys()\folder) + " file=" +
Str(entities()\animations()\soundlines()\keys()\file) + ")"
EndIf
Else
Debug "Soundline '" + entities()\animations()\soundlines()\name + "' key " +
Str(entities()\animations()\soundlines()\keys()\id) +
": missing <object> child node"
EndIf
EndIf
*soundKey = NextXMLNode(*soundKey)
Wend
EndSelect
*animChild = NextXMLNode(*animChild)
Wend
EndIf
*child = NextXMLNode(*child)
Wend
EndIf
*node = NextXMLNode(*node)
Wend
FreeXML(xml)
; Validate what was loaded - catch unsupported formats before returning success.
If skinRefCount > 0 And spriteTimelineCount = 0
; Spriter r11 skin/mesh deformation format - needs UV mesh renderer.
loadError = "Unsupported SCML format: skin/mesh deformation." + Chr(10) +
Chr(10) +
"This file uses Spriter's mesh skin system (object_type=" + Chr(34) + "skin" + Chr(34) + ")." + Chr(10) +
"It requires UV mesh rendering which is not supported by this loader." + Chr(10) +
Chr(10) +
"Re-export the animation from Spriter using standard" + Chr(10) +
"sprite-with-bones mode (no mesh/skin deformation)."
Debug "Load error: " + loadError
ClearList(entities()) : ClearList(folders())
ProcedureReturn #False
EndIf
If unsupportedType <> "" And spriteTimelineCount = 0
; All timelines use an unrecognised object_type - nothing would render.
loadError = "Unsupported SCML format: object_type=" + Chr(34) + unsupportedType + Chr(34) + "." + Chr(10) +
Chr(10) +
"No renderable sprite timelines were found in this file." + Chr(10) +
"Only object_type=sprite and object_type=box (hitbox) are supported."
Debug "Load error: " + loadError
ClearList(entities()) : ClearList(folders())
ProcedureReturn #False
EndIf
If unsupportedType <> ""
; Mixed file: some supported timelines exist alongside unsupported ones.
; Warn but continue - the supported parts will render.
Debug "Warning: SCML contains unsupported object_type=" + Chr(34) + unsupportedType + Chr(34) + " timelines (skipped)."
EndIf
Debug "SCML loaded successfully."
ProcedureReturn #True
EndProcedure
; ============================================================================
; CLEANUP
; ============================================================================
; Frees all sprites and sounds loaded by LoadSpriterData, then clears
; the folder and entity lists. Call before loading a new file or on exit.
; Note: hudFont is NOT freed here - it belongs to the demo, not to the
; Spriter asset. Free it separately at program shutdown.
Procedure FreeSpriterData()
ForEach folders()
ForEach folders()\files()
If IsSprite(folders()\files()\imageID)
FreeSprite(folders()\files()\imageID)
folders()\files()\imageID = -1
EndIf
If IsSound(folders()\files()\soundID)
FreeSound(folders()\files()\soundID)
folders()\files()\soundID = -1
EndIf
Next
Next
ClearList(folders())
ClearList(entities())
EndProcedure
; ============================================================================
; HELPERS: folder/file lookups
; ============================================================================
Procedure.i GetImageID(folderID.i, fileID.i)
ForEach folders()
If folders()\id = folderID
ForEach folders()\files()
If folders()\files()\id = fileID : ProcedureReturn folders()\files()\imageID : EndIf
Next
EndIf
Next
ProcedureReturn -1 ; -1 = not found
EndProcedure
Procedure.f GetPivotX(folderID.i, fileID.i)
ForEach folders()
If folders()\id = folderID
ForEach folders()\files()
If folders()\files()\id = fileID : ProcedureReturn folders()\files()\pivot_x : EndIf
Next
EndIf
Next
ProcedureReturn 0.5
EndProcedure
Procedure.f GetPivotY(folderID.i, fileID.i)
ForEach folders()
If folders()\id = folderID
ForEach folders()\files()
If folders()\files()\id = fileID : ProcedureReturn folders()\files()\pivot_y : EndIf
Next
EndIf
Next
ProcedureReturn 0.5
EndProcedure
Procedure.i GetSoundID(folderID.i, fileID.i)
ForEach folders()
If folders()\id = folderID
ForEach folders()\files()
If folders()\files()\id = fileID : ProcedureReturn folders()\files()\soundID : EndIf
Next
EndIf
Next
ProcedureReturn -1 ; -1 = not found / not a sound file
EndProcedure
Procedure.i GetOriginalWidth(folderID.i, fileID.i)
ForEach folders()
If folders()\id = folderID
ForEach folders()\files()
If folders()\files()\id = fileID : ProcedureReturn folders()\files()\width : EndIf
Next
EndIf
Next
ProcedureReturn 1
EndProcedure
Procedure.i GetOriginalHeight(folderID.i, fileID.i)
ForEach folders()
If folders()\id = folderID
ForEach folders()\files()
If folders()\files()\id = fileID : ProcedureReturn folders()\files()\height : EndIf
Next
EndIf
Next
ProcedureReturn 1
EndProcedure
; ============================================================================
; INTERPOLATION
; ============================================================================
Procedure.f Lerp(a.f, b.f, t.f)
ProcedureReturn a + (b - a) * t
EndProcedure
Procedure.f LerpAngle(a.f, b.f, t.f, spin.i)
Protected diff.f = b - a
If spin > 0
While diff < 0 : diff + 360.0 : Wend
ElseIf spin < 0
While diff > 0 : diff - 360.0 : Wend
EndIf
ProcedureReturn a + diff * t
EndProcedure
Procedure FindMainlineKey(*anim.SpriterAnimation, time.i)
Protected bestID.i = -1
Protected bestTime.i = -1
ForEach *anim\mainline_keys()
If *anim\mainline_keys()\time <= time And *anim\mainline_keys()\time > bestTime
bestTime = *anim\mainline_keys()\time
bestID = *anim\mainline_keys()\id
EndIf
Next
ProcedureReturn bestID
EndProcedure
Procedure GetInterpolatedKey(*tl.SpriterTimeline, startKeyID.i, time.i, animLength.i, looping.i, *out.SpriterTimelineKey)
Protected *k1.SpriterTimelineKey = 0
Protected *k2.SpriterTimelineKey = 0
; Find k1 by the id the mainline specifies
ForEach *tl\keys()
If *tl\keys()\id = startKeyID
*k1 = @*tl\keys()
Break
EndIf
Next
If *k1 = 0 And ListSize(*tl\keys()) > 0
FirstElement(*tl\keys()) : *k1 = @*tl\keys()
EndIf
If *k1 = 0 : ProcedureReturn : EndIf
; k2 is the chronologically next key after k1
ForEach *tl\keys()
If *tl\keys()\time > *k1\time
If *k2 = 0 Or *tl\keys()\time < *k2\time
*k2 = @*tl\keys()
EndIf
EndIf
Next
If *k2 = 0 And looping
FirstElement(*tl\keys()) : *k2 = @*tl\keys()
EndIf
If *k2 = 0
*out\folder = *k1\folder : *out\file = *k1\file
*out\x = *k1\x : *out\y = *k1\y
*out\angle = *k1\angle
*out\scale_x = *k1\scale_x : *out\scale_y = *k1\scale_y
*out\alpha = *k1\alpha
*out\key_pivot_x = *k1\key_pivot_x : *out\key_pivot_y = *k1\key_pivot_y
ProcedureReturn
EndIf
Protected t2.i = *k2\time
If t2 <= *k1\time : t2 = animLength : EndIf
Protected span.f = t2 - *k1\time
Protected t.f = 0.0
; No Float casting required here. (t and span are floats.)
If span > 0 : t = (time - *k1\time) / span : EndIf
If t < 0.0 : t = 0.0 : EndIf
If t > 1.0 : t = 1.0 : EndIf
*out\folder = *k1\folder : *out\file = *k1\file
*out\x = Lerp(*k1\x, *k2\x, t)
*out\y = Lerp(*k1\y, *k2\y, t)
*out\angle = LerpAngle(*k1\angle, *k2\angle, t, *k1\spin)
*out\scale_x = Lerp(*k1\scale_x, *k2\scale_x, t)
*out\scale_y = Lerp(*k1\scale_y, *k2\scale_y, t)
*out\alpha = Lerp(*k1\alpha, *k2\alpha, t)
; Pivots don't interpolate - they snap at each key boundary. Use k1's value.
*out\key_pivot_x = *k1\key_pivot_x
*out\key_pivot_y = *k1\key_pivot_y
EndProcedure
; ============================================================================
; BONE HIERARCHY: compose a child local-space key into parent world-space
;
; Spriter coordinate conventions (Y-up, angles CCW in degrees):
;
; world_x = parent_x + cos(parent_angle) * parent_sx * child_x
; - sin(parent_angle) * parent_sy * child_y
; world_y = parent_y + sin(parent_angle) * parent_sx * child_x
; + cos(parent_angle) * parent_sy * child_y
; world_angle = parent_angle + child_angle
; world_sx = parent_sx * child_sx
; world_sy = parent_sy * child_sy
; world_alpha = parent_alpha * child_alpha
; ============================================================================
Procedure ComposeTransform(*parent.WorldTransform, *child.SpriterTimelineKey, *out.WorldTransform)
; Radian() is PureBasic's built-in degree->radian macro; Sin/Cos require radians
Protected cosA.f = Cos(Radian(*parent\angle))
Protected sinA.f = Sin(Radian(*parent\angle))
*out\x = *parent\x + cosA * *parent\scale_x * *child\x - sinA * *parent\scale_y * *child\y
*out\y = *parent\y + sinA * *parent\scale_x * *child\x + cosA * *parent\scale_y * *child\y
; When a parent has an odd number of negative scale axes, it reflects the local
; coordinate space. Rotations in a reflected space go the opposite direction,
; so child angles must be negated. sign(sx*sy): +1 if 0 or 2 negatives, -1 if 1.
If (*parent\scale_x * *parent\scale_y) >= 0
*out\angle = *parent\angle + *child\angle
Else
*out\angle = *parent\angle - *child\angle
EndIf
*out\scale_x = *parent\scale_x * *child\scale_x
*out\scale_y = *parent\scale_y * *child\scale_y
*out\alpha = *parent\alpha * *child\alpha
EndProcedure
; ============================================================================
; RENDER ONE FRAME - with full bone hierarchy applied to every body part
;
; Pipeline:
; 1. Walk bone_refs (parent-first order as Spriter exports them) and build
; a world-transform table, composing each bone into its parent's space.
; 2. Collect object_refs, z-sort them, then for each sprite part:
; a. Interpolate the sprite's own timeline key (local space).
; b. Compose with the parent bone's world transform.
; c. Draw using the resulting world-space position/angle/scale/alpha.
; ============================================================================
Procedure RenderAnimation()
If Not FirstElement(entities()) : ProcedureReturn : EndIf
; BUG FIX: Protected initializers run ONCE (first call only) in PureBasic.
; animIdx must be explicitly reset to 0 every call so the animation walker
; always starts from the first element regardless of previous frame state.
Protected animIdx.i
animIdx = 0
FirstElement(entities()\animations())
While animIdx < currentAnimation And NextElement(entities()\animations())
animIdx + 1
Wend
Protected *anim.SpriterAnimation = @entities()\animations()
If *anim = 0 : ProcedureReturn : EndIf
Protected time.i = currentTime
; Sound triggers - range check so we never miss a cue between frames.
; currentTime advances ~17ms per frame at 60fps; an exact-match on a
; specific millisecond timestamp would almost never fire.
; We fire every key whose timestamp fell in (prevSoundTime, time].
; lastSoundTime guards against double-firing the same cue if currentTime
; sits on it for more than one frame (e.g. paused or final hold frame).
ForEach *anim\soundlines()
ForEach *anim\soundlines()\keys()
Protected sndT.i = *anim\soundlines()\keys()\time
If sndT > prevSoundTime And sndT <= time And sndT <> lastSoundTime
Protected sID.i = GetSoundID(*anim\soundlines()\keys()\folder, *anim\soundlines()\keys()\file)
If sID >= 0 : PlaySound(sID) : EndIf
lastSoundTime = sndT
EndIf
Next
Next
Protected mlKeyID.i = FindMainlineKey(*anim, time)
; -- Step 1: compute world transforms for all bone_refs ----------------------
; Spriter guarantees bone_refs are ordered parent-before-child, so one
; forward pass is sufficient to build the full hierarchy.
Protected Dim bWT.WorldTransform(#MAX_BONES - 1)
Protected Dim bValid.i(#MAX_BONES - 1)
; Track root bone so we can read its baked scale signs after the loop.
; Some animations are authored with a negative root scale on one or both
; axes (e.g. wall_slide/wall_throw/hit_0 have scale_x=-0.18527 on pelvis;
; Ladder has scale_y=-1.0). rootBoneID lets us detect and compensate.
Protected rootBoneID.i
rootBoneID = -1
ForEach *anim\mainline_keys()
If *anim\mainline_keys()\id = mlKeyID
ForEach *anim\mainline_keys()\bone_refs()
Protected bID.i = *anim\mainline_keys()\bone_refs()\id
Protected bParent.i = *anim\mainline_keys()\bone_refs()\parent
Protected bTL.i = *anim\mainline_keys()\bone_refs()\timeline
Protected bTLKey.i = *anim\mainline_keys()\bone_refs()\key
If bID < 0 Or bID >= #MAX_BONES : Continue : EndIf
; Interpolate this bone's own timeline to get its local transform
; ClearStructure every iteration: Protected declarations inside loops
; are no-ops after the first call, so boneLocal is never re-zeroed.
Protected boneLocal.SpriterTimelineKey
ClearStructure(boneLocal, SpriterTimelineKey)
boneLocal\scale_x = 1.0 : boneLocal\scale_y = 1.0 : boneLocal\alpha = 1.0
boneLocal\key_pivot_x = -1.0 : boneLocal\key_pivot_y = -1.0
ForEach *anim\timelines()
If *anim\timelines()\id = bTL
GetInterpolatedKey(*anim\timelines(), bTLKey, time, *anim\length, *anim\looping, boneLocal)
Break
EndIf
Next
If bParent >= 0 And bParent < #MAX_BONES And bValid(bParent)
; Compose local bone into parent bone's world space
ComposeTransform(bWT(bParent), boneLocal, bWT(bID))
Else
; Root bone: world == local
bWT(bID)\x = boneLocal\x
bWT(bID)\y = boneLocal\y
bWT(bID)\angle = boneLocal\angle
bWT(bID)\scale_x = boneLocal\scale_x
bWT(bID)\scale_y = boneLocal\scale_y
bWT(bID)\alpha = boneLocal\alpha
rootBoneID = bID ; record for baked-flip detection below
EndIf
bValid(bID) = #True
Next
Break
EndIf
Next
; -- Baked-flip detection --------------------------------------------------
; Some animations are authored with a negative root-bone scale on one axis
; (wall_slide/wall_throw/hit_0: pelvis scale_x=-0.18527; Ladder: scale_y=-1).
; This bakes a flip into the animation data itself. We strip it from
; sprWorld after composition (see below) so the normal facingLeft/Up logic
; works without interference. bakedFlipX/Y are read-only after this point.
Protected bakedFlipX.i = Bool(rootBoneID >= 0 And bWT(rootBoneID)\scale_x < 0)
Protected bakedFlipY.i = Bool(rootBoneID >= 0 And bWT(rootBoneID)\scale_y < 0)
; -- Step 2: collect object_refs and z-sort ---------------------------------
Protected Dim oTimeline.i(#MAX_OBJS - 1)
Protected Dim oParent.i(#MAX_OBJS - 1)
Protected Dim oZIndex.i(#MAX_OBJS - 1)
Protected Dim oKey.i(#MAX_OBJS - 1)
; BUG FIX: Protected initializers run ONCE in PureBasic. objCount must be
; explicitly zeroed every frame, otherwise phantom sprite entries from zeroed
; Dim arrays accumulate each call until capped at #MAX_OBJS, causing stale
; sprites (wrong image, wrong position) to be rendered on top of real ones.
Protected objCount.i
objCount = 0
ForEach *anim\mainline_keys()
If *anim\mainline_keys()\id = mlKeyID
ForEach *anim\mainline_keys()\object_refs()
If objCount < #MAX_OBJS
oTimeline(objCount) = *anim\mainline_keys()\object_refs()\timeline
oParent(objCount) = *anim\mainline_keys()\object_refs()\parent
oZIndex(objCount) = *anim\mainline_keys()\object_refs()\z_index
oKey(objCount) = *anim\mainline_keys()\object_refs()\key
objCount + 1
EndIf
Next
Break
EndIf
Next
; Bubble sort by z_index ascending (back to front)
Protected changed.i = #True
While changed
changed = #False
Protected zi.i
For zi = 0 To objCount - 2
If oZIndex(zi) > oZIndex(zi + 1)
Swap oTimeline(zi), oTimeline(zi+1)
Swap oParent(zi), oParent(zi+1)
Swap oZIndex(zi), oZIndex(zi+1)
Swap oKey(zi), oKey(zi+1)
changed = #True
EndIf
Next
Wend
; -- Step 3: render each body-part sprite with bone transform applied ---------
Protected i.i
Protected sprLocal.SpriterTimelineKey ; sprite's interpolated local values
Protected sprWorld.WorldTransform ; sprite's final world-space transform
For i = 0 To objCount - 1
Protected tlID.i = oTimeline(i)
Protected pBone.i = oParent(i) ; bone_ref id of this sprite's parent bone
Protected sprKey.i = oKey(i) ; timeline key id to use as k1
; Clear sprLocal so stale folder/file from a previous iteration never leaks
; into the current one if no matching timeline is found.
ClearStructure(sprLocal, SpriterTimelineKey)
sprLocal\scale_x = 1.0 : sprLocal\scale_y = 1.0 : sprLocal\alpha = 1.0
sprLocal\key_pivot_x = -1.0 : sprLocal\key_pivot_y = -1.0
; Only process sprite-type timelines. Box/bone timelines appear in object_refs
; (as hitboxes etc.) and must be skipped: they have no image, so folder/file
; default to 0/0 which maps to the first loaded sprite, causing phantom
; duplicates and missing parts.
; NOTE: Protected declarations inside loops are no-ops after the first
; iteration in PureBasic - the = #False assignment must be explicit.
Protected foundSprTL.i
foundSprTL = #False
ForEach *anim\timelines()
If *anim\timelines()\id = tlID
If *anim\timelines()\object_type = "" Or *anim\timelines()\object_type = "sprite"
GetInterpolatedKey(*anim\timelines(), sprKey, time, *anim\length, *anim\looping, sprLocal)
foundSprTL = #True
EndIf
Break
EndIf
Next
If Not foundSprTL : Continue : EndIf
; Compose with parent bone world transform to get world-space values
If pBone >= 0 And pBone < #MAX_BONES And bValid(pBone)
ComposeTransform(bWT(pBone), sprLocal, sprWorld)
Else
; No parent bone: world == local
sprWorld\x = sprLocal\x
sprWorld\y = sprLocal\y
sprWorld\angle = sprLocal\angle
sprWorld\scale_x = sprLocal\scale_x
sprWorld\scale_y = sprLocal\scale_y
sprWorld\alpha = sprLocal\alpha
EndIf
If bakedFlipX
sprWorld\x = -sprWorld\x
sprWorld\angle = -sprWorld\angle
sprWorld\scale_x = -sprWorld\scale_x
EndIf
If bakedFlipY
sprWorld\y = -sprWorld\y
sprWorld\angle = -sprWorld\angle ; second negation cancels first if both axes baked
sprWorld\scale_y = -sprWorld\scale_y
EndIf
Protected imgID.i = GetImageID(sprLocal\folder, sprLocal\file)
If Not IsSprite(imgID) : Continue : EndIf ; skip if not loaded or not found
Protected scrX.f = basePosX + sprWorld\x * scaleMultiplier
Protected scrY.f = basePosY - sprWorld\y * scaleMultiplier
Protected sw.i = GetOriginalWidth(sprLocal\folder, sprLocal\file)
Protected sh.i = GetOriginalHeight(sprLocal\folder, sprLocal\file)
Protected pvX.f
If sprLocal\key_pivot_x >= 0.0
pvX = sprLocal\key_pivot_x
Else
pvX = GetPivotX(sprLocal\folder, sprLocal\file)
EndIf
Protected pvY.f
If sprLocal\key_pivot_y >= 0.0
pvY = sprLocal\key_pivot_y
Else
pvY = GetPivotY(sprLocal\folder, sprLocal\file)
EndIf
Protected drawW.f = sw * Abs(sprWorld\scale_x) * scaleMultiplier
Protected drawH.f = sh * Abs(sprWorld\scale_y) * scaleMultiplier
Protected angR.f = Radian(-sprWorld\angle) ; PB CW from world CCW
Protected cosA.f = Cos(angR)
Protected sinA.f = Sin(angR)
Protected lx_L.f = -pvX * drawW
Protected lx_R.f = (1.0 - pvX) * drawW
Protected ly_T.f = -(1.0 - pvY) * drawH
Protected ly_B.f = pvY * drawH
; Rotate each corner offset and add pivot:
; TL = top-left TR = top-right
; BL = bottom-left BR = bottom-right
Protected tlx.f = scrX + lx_L * cosA - ly_T * sinA
Protected tly.f = scrY + lx_L * sinA + ly_T * cosA
Protected trx.f = scrX + lx_R * cosA - ly_T * sinA
Protected try_.f = scrY + lx_R * sinA + ly_T * cosA
Protected brx.f = scrX + lx_R * cosA - ly_B * sinA
Protected bry.f = scrY + lx_R * sinA + ly_B * cosA
Protected blx.f = scrX + lx_L * cosA - ly_B * sinA
Protected bly.f = scrY + lx_L * sinA + ly_B * cosA
; Apply facing mirror. The baked flip was stripped from sprWorld above,
; so plain facingLeft/Up are correct here for all animations.
If facingLeft
tlx = 2.0 * basePosX - tlx : trx = 2.0 * basePosX - trx
brx = 2.0 * basePosX - brx : blx = 2.0 * basePosX - blx
EndIf
If facingUp
tly = 2.0 * basePosY - tly : try_ = 2.0 * basePosY - try_
bry = 2.0 * basePosY - bry : bly = 2.0 * basePosY - bly
EndIf
Protected flipX.i = Bool(sprWorld\scale_x < 0)
Protected flipY.i = Bool(sprWorld\scale_y < 0)
Protected p1x.f, p1y.f, p2x.f, p2y.f, p3x.f, p3y.f, p4x.f, p4y.f
If flipX And flipY
p1x=brx : p1y=bry : p2x=blx : p2y=bly : p3x=tlx : p3y=tly : p4x=trx : p4y=try_
ElseIf flipX
p1x=trx : p1y=try_ : p2x=tlx : p2y=tly : p3x=blx : p3y=bly : p4x=brx : p4y=bry
ElseIf flipY
p1x=blx : p1y=bly : p2x=brx : p2y=bry : p3x=trx : p3y=try_ : p4x=tlx : p4y=tly
Else
p1x=tlx : p1y=tly : p2x=trx : p2y=try_ : p3x=brx : p3y=bry : p4x=blx : p4y=bly
EndIf
; Alpha: clamp world alpha to 0-255 for display.
Protected alpha255.i = sprWorld\alpha * 255
If alpha255 < 0 : alpha255 = 0 : EndIf
If alpha255 > 255 : alpha255 = 255 : EndIf
; ZoomSprite resets any previous transform; call it once to set original
; pixel dimensions before TransformSprite remaps them to our quad.
ZoomSprite(imgID, sw, sh)
TransformSprite(imgID, p1x, p1y, p2x, p2y, p3x, p3y, p4x, p4y)
DisplayTransparentSprite(imgID, 0, 0, alpha255)
Next
EndProcedure
; ============================================================================
; ANIMATION COUNT / NAME / LENGTH HELPERS
; ============================================================================
Procedure.i AnimationCount()
If Not FirstElement(entities()) : ProcedureReturn 0 : EndIf
ProcedureReturn ListSize(entities()\animations())
EndProcedure
Procedure.s AnimationName(idx.i)
If Not FirstElement(entities()) : ProcedureReturn "" : EndIf
Protected i.i = 0
FirstElement(entities()\animations())
While i < idx And NextElement(entities()\animations()) : i + 1 : Wend
ProcedureReturn entities()\animations()\name
EndProcedure
Procedure.i AnimationLength(idx.i)
If Not FirstElement(entities()) : ProcedureReturn 1000 : EndIf
Protected i.i = 0
FirstElement(entities()\animations())
While i < idx And NextElement(entities()\animations()) : i + 1 : Wend
ProcedureReturn entities()\animations()\length
EndProcedure
; Advances currentTime by elapsed milliseconds and handles loop-wrap / non-looping end.
; Reads/writes globals: isPlaying, currentTime, prevSoundTime, lastSoundTime,
; animationSpeed, currentAnimation.
; Called once per frame from the main loop, passing the frame delta in ms.
Procedure AdvanceAnimationTime(elapsed.q)
If Not isPlaying : ProcedureReturn : EndIf
prevSoundTime = currentTime ; snapshot before advancing for range-check
currentTime + elapsed * animationSpeed
Protected animLen.i = AnimationLength(currentAnimation)
If animLen > 0 And currentTime >= animLen
Protected *animChk.SpriterAnimation = 0
If FirstElement(entities())
Protected aidx.i = 0
FirstElement(entities()\animations())
While aidx < currentAnimation And NextElement(entities()\animations()) : aidx + 1 : Wend
*animChk = @entities()\animations()
EndIf
If *animChk And *animChk\looping
currentTime = Mod(currentTime, animLen)
; Reset sound timers on loop wrap so cues replay each loop.
lastSoundTime = -1 : prevSoundTime = -1
Else
currentTime = animLen - 1
isPlaying = #False
; Do NOT reset sound timers here. prevSoundTime is only updated while
; isPlaying is true, so resetting it to -1 here would leave it frozen at
; -1 forever while the animation holds on its last frame, causing every
; sound cue in (-1, animLen-1] to re-trigger on every subsequent frame.
EndIf
EndIf
EndProcedure
; ============================================================================
; MAIN PROGRAM
; ============================================================================
screenW.i = 1920
screenH.i = 1080
; OpenWindowedScreen hosts DirectX inside a normal window so file dialogs
; (and other OS windows) can appear on top without destroying the surface.
; OpenScreen (fullscreen) minimises when any dialog is shown and cannot
; recover without re-creating the surface.
; OpenWindow() must be created first; WindowEvent() must be pumped every
; frame before FlipBuffers() for correct windowed screen behaviour.
If Not OpenWindow(0, 0, 0, screenW, screenH, "Spriter Pro Demo",
#PB_Window_SystemMenu | #PB_Window_MinimizeGadget | #PB_Window_ScreenCentered)
MessageRequester("Spriter Demo", "Failed to open window.")
End
EndIf
If Not OpenWindowedScreen(WindowID(0), 0, 0, screenW, screenH, #False, 0, 0, #PB_Screen_WaitSynchronization)
CloseWindow(0)
MessageRequester("Spriter Demo", "Failed to open screen.")
End
EndIf
; Sprites are loaded on demand when the user presses O.
SetFrameRate(60)
; basePosX/Y is the on-screen position of the character's feet (world origin).
; Place feet at horizontal centre, near the bottom of the screen.
basePosX = screenW / 2
basePosY = screenH - 100
hudFont = LoadFont(#PB_Any, "Arial", 12)
lastTick.q = ElapsedMilliseconds()
keyA_prev.i = 0 : keyD_prev.i = 0
keySP_prev.i = 0
keyF1_prev.i = 0
keyF_prev.i = 0
keyV_prev.i = 0
keyR_prev.i = 0
keyO_prev.i = 0
; -- MAIN LOOP -----------------------------------------------------------------
Repeat
; Pump all pending window events before rendering - required by OpenWindowedScreen.
nowTick.q = ElapsedMilliseconds()
elapsed.q = nowTick - lastTick
lastTick = nowTick
ExamineKeyboard()
If KeyboardPushed(#PB_Key_Left) : basePosX - moveSpeed : EndIf
If KeyboardPushed(#PB_Key_Right) : basePosX + moveSpeed : EndIf
If KeyboardPushed(#PB_Key_Up) : basePosY - moveSpeed : EndIf
If KeyboardPushed(#PB_Key_Down) : basePosY + moveSpeed : EndIf
If basePosX < 0 : basePosX = 0 : EndIf
If basePosX > screenW : basePosX = screenW : EndIf
If basePosY < 0 : basePosY = 0 : EndIf
If basePosY > screenH : basePosY = screenH : EndIf
If KeyboardPushed(#PB_Key_Add) Or KeyboardPushed(#PB_Key_Equals)
animationSpeed + 0.02 : If animationSpeed > 5.0 : animationSpeed = 5.0 : EndIf
EndIf
If KeyboardPushed(#PB_Key_Subtract) Or KeyboardPushed(#PB_Key_Minus)
animationSpeed - 0.02 : If animationSpeed < 0.1 : animationSpeed = 0.1 : EndIf
EndIf
; [ = scale up, ] = scale down
If KeyboardPushed(#PB_Key_LeftBracket)
scaleMultiplier + 0.01 : If scaleMultiplier > 5.0 : scaleMultiplier = 5.0 : EndIf
EndIf
If KeyboardPushed(#PB_Key_RightBracket)
scaleMultiplier - 0.01 : If scaleMultiplier < 0.1 : scaleMultiplier = 0.1 : EndIf
EndIf
keyD_cur.i = KeyboardPushed(#PB_Key_D)
If keyD_cur And Not keyD_prev
currentAnimation + 1
If currentAnimation >= AnimationCount() : currentAnimation = 0 : EndIf
currentTime = 0 : lastSoundTime = -1 : prevSoundTime = -1 : isPlaying = #True
EndIf
keyD_prev = keyD_cur
keyA_cur.i = KeyboardPushed(#PB_Key_A)
If keyA_cur And Not keyA_prev
currentAnimation - 1
If currentAnimation < 0 : currentAnimation = AnimationCount() - 1 : EndIf
currentTime = 0 : lastSoundTime = -1 : prevSoundTime = -1 : isPlaying = #True
EndIf
keyA_prev = keyA_cur
keySP_cur.i = KeyboardPushed(#PB_Key_Space)
If keySP_cur And Not keySP_prev : isPlaying ! 1 : EndIf
keySP_prev = keySP_cur
keyR_cur.i = KeyboardPushed(#PB_Key_R)
If keyR_cur And Not keyR_prev
basePosX = screenW / 2 : basePosY = screenH - 100
scaleMultiplier = 1.0 : animationSpeed = 1.0
currentAnimation = 2 ; return to Idle, not TriggerIntro
currentTime = 0 : lastSoundTime = -1 : prevSoundTime = -1 : isPlaying = #True
facingLeft = #False
facingUp = #False
EndIf
keyR_prev = keyR_cur
; F - toggle character facing direction (mirror around basePosX)
keyF_cur.i = KeyboardPushed(#PB_Key_F)
If keyF_cur And Not keyF_prev : facingLeft ! 1 : EndIf
keyF_prev = keyF_cur
; V - toggle vertical flip (mirror around basePosY)
keyV_cur.i = KeyboardPushed(#PB_Key_V)
If keyV_cur And Not keyV_prev : facingUp ! 1 : EndIf
keyV_prev = keyV_cur
keyF1_cur.i = KeyboardPushed(#PB_Key_F1)
If keyF1_cur And Not keyF1_prev : showHUD ! 1 : EndIf
keyF1_prev = keyF1_cur
keyO_cur.i = KeyboardPushed(#PB_Key_O)
If keyO_cur And Not keyO_prev
; Pause, show file dialog, reload if a new file was chosen.
isPlaying = #False
newFile.s = OpenFileRequester("Open Spriter SCML File", "", "Spriter Files (*.scml)|*.scml|All Files (*.*)|*.*", 0)
If newFile <> ""
FreeSpriterData()
loadError = ""
If LoadSpriterData(newFile)
currentAnimation = 0 ; start on first animation in file
currentTime = 0 : lastSoundTime = -1 : prevSoundTime = -1 : isPlaying = #True
basePosX = screenW / 2 : basePosY = screenH - 100
scaleMultiplier = 1.0 : animationSpeed = 1.0
Else
If loadError <> ""
MessageRequester("Spriter Demo", loadError)
Else
MessageRequester("Spriter Demo", "Failed to load: " + newFile)
EndIf
EndIf
EndIf
; Flush stale keyboard state accumulated while the dialog was open,
; and reset the frame timer so elapsed doesn't spike on resume.
ExamineKeyboard()
keyO_prev = 1 ; treat O as still held so the handler doesn't fire again
lastTick = ElapsedMilliseconds()
isPlaying = #True
EndIf
keyO_prev = keyO_cur
; Advance animation time
If AnimationCount() > 0 : AdvanceAnimationTime(elapsed) : EndIf
; Render
ClearScreen(RGB(30, 30, 45))
If AnimationCount() > 0
StartDrawing(ScreenOutput())
DrawingMode(#PB_2DDrawing_Default)
gx.i : For gx = 0 To screenW Step 50 : LineXY(gx, 0, gx, screenH, RGBA(60,60,80,255)) : Next
gy.i : For gy = 0 To screenH Step 50 : LineXY(0, gy, screenW, gy, RGBA(60,60,80,255)) : Next
LineXY(basePosX-10, basePosY, basePosX+10, basePosY, RGBA(200,80,80,220))
LineXY(basePosX, basePosY-10, basePosX, basePosY+10, RGBA(200,80,80,220))
StopDrawing()
RenderAnimation()
Else
; No file loaded - show a prompt in the centre of the screen.
StartDrawing(ScreenOutput())
DrawingFont(FontID(hudFont))
DrawingMode(#PB_2DDrawing_Transparent)
DrawText(screenW/2 - 120, screenH/2 - 10, "Press O to open a Spriter SCML file", RGBA(200,200,200,200))
StopDrawing()
EndIf
If showHUD
StartDrawing(ScreenOutput())
DrawingFont(FontID(hudFont))
DrawingMode(#PB_2DDrawing_AlphaBlend)
Box(5, 5, 275, 267, RGBA(0, 0, 0, 160))
DrawingMode(#PB_2DDrawing_Transparent)
col.i = RGBA(220,220,255,255) : hdrC.i = RGBA(255,200,80,255)
DrawText(10, 10, "=== SPRITER PRO DEMO ===", hdrC)
If AnimationCount() > 0
DrawText(10, 30, "Anim (" + Str(currentAnimation+1) + "/" + Str(AnimationCount()) + "): " + AnimationName(currentAnimation), col)
DrawText(10, 48, "Time: " + Str(currentTime) + " / " + Str(AnimationLength(currentAnimation)) + " ms", col)
DrawText(10, 66, "Speed: " + StrF(animationSpeed,2) + "x Scale: " + StrF(scaleMultiplier,2) + "x", col)
If facingLeft
DrawText(10, 84, "Facing: LEFT (F to flip)", RGBA(255,180,80,255))
Else
DrawText(10, 84, "Facing: RIGHT (F to flip)", col)
EndIf
If facingUp
DrawText(10, 102, "Vertical: FLIPPED (V to flip)", RGBA(255,180,80,255))
Else
DrawText(10, 102, "Vertical: normal (V to flip)", col)
EndIf
Else
DrawText(10, 30, "No file loaded", RGBA(180,180,180,200))
EndIf
DrawText(10,128, "--- Controls ---", hdrC)
DrawText(10,146, "Arrows : Move sprite", col)
DrawText(10,162, "A / D : Prev / Next anim", col)
DrawText(10,178, "SPACE : Pause / Resume", col)
DrawText(10,194, "+/- or =/- : Speed", col)
DrawText(10,210, "[ / ] : Scale up / down", col)
DrawText(10,226, "F / V : Flip H / V", col)
DrawText(10,242, "R: Reset F1: Hide HUD", col)
DrawText(10,258, "O: Open new SCML file", col)
StopDrawing()
Else
StartDrawing(ScreenOutput())
DrawingFont(FontID(hudFont))
DrawingMode(#PB_2DDrawing_Transparent)
DrawText(5, 5, "F1: Show HUD", RGBA(200,200,200,200))
StopDrawing()
EndIf
FlipBuffers()
Until KeyboardPushed(#PB_Key_Escape) Or WindowEvent() = #PB_Event_CloseWindow
FreeSpriterData()
If IsFont(hudFont) : FreeFont(hudFont) : EndIf
CloseScreen()
CloseWindow(0)
End


