///|
pub(all) struct SpriteSheetStats {
frame_count : Int
tag_count : Int
slice_count : Int
layer_count : Int
atlas_area : Int
used_area : Int
utilization_milli : Int
trimmed_frames : Int
rotated_frames : Int
total_duration_ms : Int
shortest_duration_ms : Int
longest_duration_ms : Int
average_duration_ms : Int
} derive(Eq, @debug.Debug)
///|
pub fn SpriteSheetStats::empty() -> SpriteSheetStats {
{
frame_count: 0,
tag_count: 0,
slice_count: 0,
layer_count: 0,
atlas_area: 0,
used_area: 0,
utilization_milli: 0,
trimmed_frames: 0,
rotated_frames: 0,
total_duration_ms: 0,
shortest_duration_ms: 0,
longest_duration_ms: 0,
average_duration_ms: 0,
}
}
///|
pub fn SpriteSheetStats::utilization_percent(self : SpriteSheetStats) -> Double {
self.utilization_milli.to_double() / 10.0
}
///|
pub fn analyze_sprite_sheet(sheet : SpriteSheet) -> SpriteSheetStats {
if sheet.frames.length() == 0 {
SpriteSheetStats::empty()
} else {
let used = for frame in sheet.frames; total = 0 {
continue total + frame.rect.area()
} nobreak {
total
}
let trimmed = for frame in sheet.frames; total = 0 {
if frame.trimmed {
continue total + 1
} else {
continue total
}
} nobreak {
total
}
let rotated = for frame in sheet.frames; total = 0 {
if frame.rotated {
continue total + 1
} else {
continue total
}
} nobreak {
total
}
let shortest = for
frame in sheet.frames
value = sheet.frames[0].duration_ms {
if frame.duration_ms < value {
continue frame.duration_ms
} else {
continue value
}
} nobreak {
value
}
let longest = for frame in sheet.frames; value = 0 {
if frame.duration_ms > value {
continue frame.duration_ms
} else {
continue value
}
} nobreak {
value
}
let atlas_area = sheet.meta.size.area()
let utilization = if atlas_area <= 0 { 0 } else { used * 1000 / atlas_area }
{
frame_count: sheet.frames.length(),
tag_count: sheet.meta.frame_tags.length(),
slice_count: sheet.meta.slices.length(),
layer_count: sheet.meta.layers.length(),
atlas_area,
used_area: used,
utilization_milli: utilization,
trimmed_frames: trimmed,
rotated_frames: rotated,
total_duration_ms: sheet.duration_ms(),
shortest_duration_ms: shortest,
longest_duration_ms: longest,
average_duration_ms: sheet.duration_ms() / sheet.frames.length(),
}
}
}
///|
pub(all) struct FrameTimelineEntry {
frame_index : Int
frame_name : String
start_ms : Int
end_ms : Int
duration_ms : Int
} derive(Eq, @debug.Debug)
///|
pub fn FrameTimelineEntry::new(
frame_index : Int,
frame_name : String,
start_ms : Int,
duration_ms : Int,
) -> FrameTimelineEntry {
{
frame_index,
frame_name,
start_ms,
end_ms: start_ms + duration_ms,
duration_ms,
}
}
///|
pub fn FrameTimelineEntry::contains(
self : FrameTimelineEntry,
ms : Int,
) -> Bool {
ms >= self.start_ms && ms < self.end_ms
}
///|
pub fn build_sheet_timeline(sheet : SpriteSheet) -> Array[FrameTimelineEntry] {
let entries : Array[FrameTimelineEntry] = []
let mut start = 0
for frame in sheet.frames {
entries.push(
FrameTimelineEntry::new(frame.index, frame.name, start, frame.duration_ms),
)
start = start + frame.duration_ms
}
entries
}
///|
pub fn build_tag_timeline(
sheet : SpriteSheet,
tag_name : String,
) -> Array[FrameTimelineEntry] {
match sheet.find_tag(tag_name) {
None => []
Some(tag) => {
let entries : Array[FrameTimelineEntry] = []
let mut start = 0
if tag.direction == DirectionReverse ||
tag.direction == DirectionPingPongReverse {
let mut i = tag.to_index
while i >= tag.from_index {
match sheet.frame_at(i) {
Some(frame) => {
entries.push(
FrameTimelineEntry::new(
frame.index,
frame.name,
start,
frame.duration_ms,
),
)
start = start + frame.duration_ms
}
None => ()
}
i = i - 1
}
} else {
for i in tag.from_index..<=tag.to_index {
match sheet.frame_at(i) {
Some(frame) => {
entries.push(
FrameTimelineEntry::new(
frame.index,
frame.name,
start,
frame.duration_ms,
),
)
start = start + frame.duration_ms
}
None => ()
}
}
}
entries
}
}
}
///|
pub fn timeline_total_duration(entries : Array[FrameTimelineEntry]) -> Int {
if entries.length() == 0 {
0
} else {
entries[entries.length() - 1].end_ms
}
}
///|
pub fn timeline_frame_at(
entries : Array[FrameTimelineEntry],
elapsed_ms : Int,
looped? : Bool = true,
) -> FrameTimelineEntry? {
if entries.length() == 0 || elapsed_ms < 0 {
None
} else {
let total = timeline_total_duration(entries)
let ms = if looped && total > 0 {
elapsed_ms % total
} else if elapsed_ms >= total {
total - 1
} else {
elapsed_ms
}
for entry in entries {
if entry.contains(ms) {
return Some(entry)
}
}
Some(entries[entries.length() - 1])
}
}
///|
pub(all) struct DurationBucket {
name : String
min_ms : Int
max_ms : Int
count : Int
} derive(Eq, @debug.Debug)
///|
pub fn DurationBucket::new(
name : String,
min_ms : Int,
max_ms : Int,
count? : Int = 0,
) -> DurationBucket {
{ name, min_ms, max_ms, count }
}
///|
pub fn build_duration_buckets(sheet : SpriteSheet) -> Array[DurationBucket] {
let fast = DurationBucket::new("fast", 0, 80)
let normal = DurationBucket::new("normal", 81, 140)
let slow = DurationBucket::new("slow", 141, 1000000)
let buckets = [fast, normal, slow]
for frame in sheet.frames {
if frame.duration_ms <= 80 {
buckets[0] = { ..buckets[0], count: buckets[0].count + 1 }
} else if frame.duration_ms <= 140 {
buckets[1] = { ..buckets[1], count: buckets[1].count + 1 }
} else {
buckets[2] = { ..buckets[2], count: buckets[2].count + 1 }
}
}
buckets
}
///|
pub(all) struct AssetPolicy {
min_frames : Int
max_frames : Int
require_tags : Bool
require_box_slices : Bool
allow_rotated_frames : Bool
max_frame_duration_ms : Int
max_atlas_waste_milli : Int
} derive(Eq, @debug.Debug)
///|
pub fn AssetPolicy::game_sprite_default() -> AssetPolicy {
{
min_frames: 1,
max_frames: 512,
require_tags: true,
require_box_slices: true,
allow_rotated_frames: false,
max_frame_duration_ms: 1000,
max_atlas_waste_milli: 850,
}
}
///|
pub fn AssetPolicy::relaxed() -> AssetPolicy {
{
min_frames: 1,
max_frames: 4096,
require_tags: false,
require_box_slices: false,
allow_rotated_frames: true,
max_frame_duration_ms: 10000,
max_atlas_waste_milli: 1000,
}
}
///|
pub fn check_asset_policy(
sheet : SpriteSheet,
policy : AssetPolicy,
) -> ValidationReport {
let issues : Array[ValidationIssue] = []
let stats = analyze_sprite_sheet(sheet)
if stats.frame_count < policy.min_frames {
issues.push(
ValidationIssue::error(
"frame count is below policy minimum",
path="$.frames",
code="policy-min-frames",
),
)
}
if stats.frame_count > policy.max_frames {
issues.push(
ValidationIssue::error(
"frame count exceeds policy maximum",
path="$.frames",
code="policy-max-frames",
),
)
}
if policy.require_tags && sheet.meta.frame_tags.length() == 0 {
issues.push(
ValidationIssue::error(
"policy requires at least one animation tag",
path="$.meta.frameTags",
code="policy-require-tags",
),
)
}
if policy.require_box_slices &&
build_frame_box_table(sheet).boxes.length() == 0 {
issues.push(
ValidationIssue::error(
"policy requires hitbox/hurtbox/collision slices",
path="$.meta.slices",
code="policy-require-boxes",
),
)
}
if !policy.allow_rotated_frames && stats.rotated_frames > 0 {
issues.push(
ValidationIssue::error(
"policy disallows rotated frames",
path="$.frames",
code="policy-rotated-frames",
),
)
}
if stats.longest_duration_ms > policy.max_frame_duration_ms {
issues.push(
ValidationIssue::warning(
"some frame durations exceed policy maximum",
path="$.frames",
code="policy-long-duration",
),
)
}
let waste = 1000 - stats.utilization_milli
if stats.atlas_area > 0 && waste > policy.max_atlas_waste_milli {
issues.push(
ValidationIssue::warning(
"atlas utilization is low",
path="$.meta.size",
code="policy-atlas-waste",
),
)
}
ValidationReport::new(issues)
}
///|
pub fn analytics_summary_text(sheet : SpriteSheet) -> String {
let stats = analyze_sprite_sheet(sheet)
let buckets = build_duration_buckets(sheet)
let lines : Array[String] = []
lines.push("frames=\{stats.frame_count}")
lines.push("tags=\{stats.tag_count}")
lines.push("slices=\{stats.slice_count}")
lines.push("duration=\{stats.total_duration_ms}ms")
lines.push("atlas-used=\{stats.used_area}/\{stats.atlas_area}")
lines.push("atlas-utilization=\{stats.utilization_percent()}%")
for bucket in buckets {
lines.push("duration-\{bucket.name}=\{bucket.count}")
}
join_strings(lines, "\n")
}