Page 1 of 2

2D Modular Sprite Engine (Spriter Pro)

Posted: Wed Feb 18, 2026 4:14 am
by D Ogre
Spriter Pro Animation Demo Viewer: (Moved here.) Date: 02/28/26

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

Re: 2D Modular Sprite Engine (Spriter Pro)

Posted: Wed Feb 18, 2026 4:17 am
by D Ogre
2D Engine Post Closed for now....

Re: 2D Modular Sprite Engine (Spriter Pro)

Posted: Wed Feb 18, 2026 4:18 am
by D Ogre
2D Engine post closed for now...

Re: 2D Modular Sprite Engine (Spriter Pro)

Posted: Wed Feb 18, 2026 4:21 am
by D Ogre
2D Engine post closed for now...

Re: 2D Modular Sprite Engine (Spriter Pro)

Posted: Wed Feb 18, 2026 4:22 am
by D Ogre
2D Engine post closed for now...

Re: 2D Modular Sprite Engine (Spriter Pro)

Posted: Wed Feb 18, 2026 4:24 am
by D Ogre
2D Engine post closed for now....

Re: 2D Modular Sprite Engine (Spriter Pro)

Posted: Wed Feb 18, 2026 7:19 am
by IceSoft
D Ogre wrote: Wed Feb 18, 2026 4:14 am I'm looking for testers to verify functionality of my 2D Modular Sprite Engine.
Nice! But how can test it?
I think a small example is needed, right?

Re: 2D Modular Sprite Engine (Spriter Pro)

Posted: Wed Feb 18, 2026 7:43 am
by D Ogre
There's an example character you can download on the Spriter Pro website. He's called the GreyGuy.

Re: 2D Modular Sprite Engine (Spriter Pro)

Posted: Wed Feb 18, 2026 9:00 am
by miso
Thanks. Did not test it, but that seems to be a lot of work. I will test it later. Thanks for sharing.

Re: 2D Modular Sprite Engine (Spriter Pro)

Posted: Wed Feb 18, 2026 10:23 am
by IceSoft
D Ogre wrote: Wed Feb 18, 2026 7:43 am There's an example character you can download on the Spriter Pro website. He's called the GreyGuy.
Yes. But a small "demo.pb" will be help to have a fast start for testing.

Re: 2D Modular Sprite Engine (Spriter Pro)

Posted: Wed Feb 18, 2026 2:00 pm
by HeX0R
is that AI created?
That looks like pure nonsense:

Code: Select all

  Protected JXID.i = #PB_Any
  If Not ParseJSON(JXID, JSON)
    ProcedureReturn #False
  EndIf
  
  Protected *Root = JSONValue(JXID)
  If Not *Root
    FreeJSON(JXID)
    ProcedureReturn #False
  EndIf

Re: 2D Modular Sprite Engine (Spriter Pro)

Posted: Wed Feb 18, 2026 7:23 pm
by STARGĂ…TE
I was also wondering how to compile the code. :?

Engine.pbi
  • Code: Select all

    #ENGINE_VERSION.s = "1.0.0"
    A constant cannot have a type. Syntax error.
  • Code: Select all

    Structure Character
      Name.s
      ; Parts
      List Parts.SpritePart()
      PartCount.i
    ;[...]
    
    Structure "Character" is already a predefined structure in Pure Basic.
AssetPack.pbi
  • Code: Select all

    #PACK_MAGIC.s    = "MSAE"   ; Modular Sprite Animation Engine
    #PACK_VERSION.i  = 1
    #AES_KEY_SIZE.i  = 32       ; 256-bit AES
    #AES_IV_SIZE.i   = 16       ; 128-bit IV
    
    Constants cannot have a type. Syntax error.
  • Code: Select all

    Procedure DeriveKey(Password.s, *KeyBuffer)
      
      ; Use PureBasic's built-in key derivation
      ; Salt is fixed per engine - in production use a stored random salt
      Protected Salt.s = "MSAE_SALT_2024_V1"
      DeriveCipherKey(*KeyBuffer, #AES_KEY_SIZE, Password, Salt)
      
    EndProcedure
    
    Wrong parameter order and count for DeriveCipherKey()
  • Code: Select all

      Protected KeyBuffer.a[32]
      DeriveKey(Password, @KeyBuffer)
    
    Syntax error.
  • Code: Select all

    Protected IVBuffer.a[16]
      OpenCryptRandom()
      CryptRandomData(@IVBuffer, 16)
      CloseCryptRandom()
      
    Syntax error.
  • Code: Select all

    Procedure.i LoadAssetPack(PackFile.s, Password.s)
      
      If Engine\Pack\Loaded
        ; Free existing pack first
        UnloadAssetPack()
      EndIf
    
    UnloadAssetPack() is no function, because it is defined afterwards, but no Declare was used.
I stopped here to waste my time. :cry:
How do you test this code?

Re: 2D Modular Sprite Engine (Spriter Pro)

Posted: Thu Feb 19, 2026 4:44 am
by D Ogre
I knew people was going to get after me after I realized I posted the wrong files late last evening. I suppose I deserve it! :oops:

To answer your question on was some of the code Ai generated? The answer is yes! I been working on this forever and I thought it would help at some point, but I was sadly mistaken. Image and video generation has come a long way with Ai, but code generation is not quite there yet. Especially with PureBasic.

Anyway, this is still in heavy development. Very rough!! I thought some people would like to at least take a look at what I'm working on. As of right now I have figure that there will be between 15 and 20 modules when I complete this monster. I have at least 10 in various stages of completeness.

The MSAE.pbi file does have some generic examples commented. Now, they won't work out of the box. I haven't got that far yet. You will need assets to load into the engine.

Re: 2D Modular Sprite Engine (Spriter Pro)

Posted: Thu Feb 19, 2026 9:20 am
by miso
Thanks guyz. Yesterday the first was to seek for the rotation/position logic of node chains, but did not find any that was expected. I thought maybe it is not computed, but these are all precomputed somehow... Now it has something I was expecting, but still does not look what I need.
Too bad, as I think I will need to create something like this for waponez bosses...

@D Ogre: There will be people who would test your program, but please only compilable ones can be tested.

Re: 2D Modular Sprite Engine (Spriter Pro)

Posted: Fri Feb 20, 2026 12:22 am
by D Ogre
@miso

I'm currently working on further compatibility with Spriter's file format. I hope to have a simple working demo soon. Probably something along the lines of simply loading and animating a character with sound and everything.