///|
/// Runtime/export targets that commonly consume Aseprite animation metadata.
pub(all) enum RuntimeTarget {
TargetRuntimeManifest
TargetCanvas2D
TargetPhaser3
TargetGenericEngine
} derive(Eq, @debug.Debug)
///|
pub fn RuntimeTarget::to_string(self : RuntimeTarget) -> String {
match self {
TargetRuntimeManifest => "runtime-manifest"
TargetCanvas2D => "canvas2d"
TargetPhaser3 => "phaser3"
TargetGenericEngine => "generic-engine"
}
}
///|
pub fn RuntimeTarget::expects_external_renderer(self : RuntimeTarget) -> Bool {
match self {
TargetRuntimeManifest | TargetCanvas2D | TargetPhaser3 => true
TargetGenericEngine => false
}
}
///|
pub fn RuntimeTarget::requires_box_metadata_by_default(
self : RuntimeTarget,
) -> Bool {
match self {
TargetPhaser3 | TargetGenericEngine => true
TargetRuntimeManifest | TargetCanvas2D => false
}
}
///|
pub(all) struct ExportCompatibilityOptions {
target : RuntimeTarget
require_frame_tags : Bool
require_layer_metadata : Bool
require_slice_boxes : Bool
require_hitbox : Bool
require_hurtbox : Bool
require_collision_box : Bool
require_image_name : Bool
allow_rotated_frames : Bool
allow_trimmed_frames : Bool
max_atlas_width : Int
max_atlas_height : Int
min_frame_duration_ms : Int
max_frame_duration_ms : Int
} derive(Eq, @debug.Debug)
///|
pub fn ExportCompatibilityOptions::runtime_manifest() -> ExportCompatibilityOptions {
{
target: TargetRuntimeManifest,
require_frame_tags: false,
require_layer_metadata: false,
require_slice_boxes: false,
require_hitbox: false,
require_hurtbox: false,
require_collision_box: false,
require_image_name: true,
allow_rotated_frames: true,
allow_trimmed_frames: true,
max_atlas_width: 0,
max_atlas_height: 0,
min_frame_duration_ms: 1,
max_frame_duration_ms: 5000,
}
}
///|
pub fn ExportCompatibilityOptions::canvas2d() -> ExportCompatibilityOptions {
{
target: TargetCanvas2D,
require_frame_tags: false,
require_layer_metadata: false,
require_slice_boxes: false,
require_hitbox: false,
require_hurtbox: false,
require_collision_box: false,
require_image_name: true,
allow_rotated_frames: false,
allow_trimmed_frames: true,
max_atlas_width: 4096,
max_atlas_height: 4096,
min_frame_duration_ms: 16,
max_frame_duration_ms: 2000,
}
}
///|
pub fn ExportCompatibilityOptions::phaser3() -> ExportCompatibilityOptions {
{
target: TargetPhaser3,
require_frame_tags: true,
require_layer_metadata: false,
require_slice_boxes: true,
require_hitbox: true,
require_hurtbox: true,
require_collision_box: true,
require_image_name: true,
allow_rotated_frames: false,
allow_trimmed_frames: true,
max_atlas_width: 4096,
max_atlas_height: 4096,
min_frame_duration_ms: 16,
max_frame_duration_ms: 2000,
}
}
///|
pub fn ExportCompatibilityOptions::strict_game_asset() -> ExportCompatibilityOptions {
{
target: TargetGenericEngine,
require_frame_tags: true,
require_layer_metadata: true,
require_slice_boxes: true,
require_hitbox: true,
require_hurtbox: true,
require_collision_box: true,
require_image_name: true,
allow_rotated_frames: false,
allow_trimmed_frames: false,
max_atlas_width: 2048,
max_atlas_height: 2048,
min_frame_duration_ms: 16,
max_frame_duration_ms: 1000,
}
}
///|
pub fn check_export_compatibility(
sheet : SpriteSheet,
options? : ExportCompatibilityOptions = ExportCompatibilityOptions::phaser3(),
) -> ValidationReport {
let issues : Array[ValidationIssue] = []
check_export_meta(sheet, options, issues)
check_export_frames(sheet, options, issues)
check_export_tags(sheet, options, issues)
check_export_boxes(sheet, options, issues)
ValidationReport::new(issues)
}
///|
pub fn validate_sprite_sheet_json_for_export(
input : String,
options? : ExportCompatibilityOptions = ExportCompatibilityOptions::phaser3(),
) -> ValidationReport {
let parsed = parse_sprite_sheet_json(input)
if !parsed.ok {
ValidationReport::new([
ValidationIssue::error(
parsed.error.message,
path=parsed.error.path,
code=parsed.error.code(),
),
])
} else {
merge_validation_reports(
validate_sprite_sheet(parsed.sheet),
check_export_compatibility(parsed.sheet, options~),
)
}
}
///|
pub fn merge_validation_reports(
first : ValidationReport,
second : ValidationReport,
) -> ValidationReport {
let issues : Array[ValidationIssue] = []
for issue in first.issues {
issues.push(issue)
}
for issue in second.issues {
issues.push(issue)
}
ValidationReport::new(issues)
}
///|
pub fn validation_report_has_code(
report : ValidationReport,
code : String,
) -> Bool {
for issue in report.issues {
if issue.code == code {
return true
}
}
false
}
///|
pub fn recommended_aseprite_export_flags(
options? : ExportCompatibilityOptions = ExportCompatibilityOptions::phaser3(),
) -> String {
if options.require_frame_tags && options.require_slice_boxes {
"--batch --sheet --data --format json-array --list-tags --list-slices"
} else if options.require_frame_tags {
"--batch --sheet --data --format json-array --list-tags"
} else if options.require_slice_boxes {
"--batch --sheet --data --format json-array --list-slices"
} else {
"--batch --sheet --data --format json-array"
}
}
///|
fn check_export_meta(
sheet : SpriteSheet,
options : ExportCompatibilityOptions,
issues : Array[ValidationIssue],
) -> Unit {
let target = options.target.to_string()
if options.require_image_name && sheet.meta.image == "" {
issues.push(
ValidationIssue::error(
"\{target} export needs meta.image to bind the texture atlas",
path="$.meta.image",
code="export-missing-image",
),
)
}
if options.require_layer_metadata && sheet.meta.layers.length() == 0 {
issues.push(
ValidationIssue::warning(
"\{target} policy expects layer metadata for authoring diagnostics",
path="$.meta.layers",
code="export-missing-layers",
),
)
}
if options.max_atlas_width > 0 &&
sheet.meta.size.width > options.max_atlas_width {
issues.push(
ValidationIssue::warning(
"atlas width \{sheet.meta.size.width} exceeds \{target} policy limit \{options.max_atlas_width}",
path="$.meta.size.w",
code="export-atlas-too-wide",
),
)
}
if options.max_atlas_height > 0 &&
sheet.meta.size.height > options.max_atlas_height {
issues.push(
ValidationIssue::warning(
"atlas height \{sheet.meta.size.height} exceeds \{target} policy limit \{options.max_atlas_height}",
path="$.meta.size.h",
code="export-atlas-too-tall",
),
)
}
}
///|
fn check_export_frames(
sheet : SpriteSheet,
options : ExportCompatibilityOptions,
issues : Array[ValidationIssue],
) -> Unit {
for i, frame in sheet.frames {
let path = json_index_path("$.frames", i)
if frame.rotated && !options.allow_rotated_frames {
issues.push(
ValidationIssue::warning(
"\{options.target.to_string()} profile disables rotated frame export",
path=json_field_path(path, "rotated"),
code="export-rotated-frame",
),
)
}
if frame.trimmed && !options.allow_trimmed_frames {
issues.push(
ValidationIssue::warning(
"\{options.target.to_string()} profile expects untrimmed frame rectangles",
path=json_field_path(path, "trimmed"),
code="export-trimmed-frame",
),
)
}
if options.min_frame_duration_ms > 0 &&
frame.duration_ms < options.min_frame_duration_ms {
issues.push(
ValidationIssue::warning(
"frame duration \{frame.duration_ms}ms is below export policy minimum \{options.min_frame_duration_ms}ms",
path=json_field_path(path, "duration"),
code="export-frame-too-fast",
),
)
}
if options.max_frame_duration_ms > 0 &&
frame.duration_ms > options.max_frame_duration_ms {
issues.push(
ValidationIssue::warning(
"frame duration \{frame.duration_ms}ms exceeds export policy maximum \{options.max_frame_duration_ms}ms",
path=json_field_path(path, "duration"),
code="export-frame-too-slow",
),
)
}
}
}
///|
fn check_export_tags(
sheet : SpriteSheet,
options : ExportCompatibilityOptions,
issues : Array[ValidationIssue],
) -> Unit {
if options.require_frame_tags && sheet.meta.frame_tags.length() == 0 {
issues.push(
ValidationIssue::error(
"\{options.target.to_string()} export needs frameTags to create named animation clips",
path="$.meta.frameTags",
code="export-missing-frame-tags",
),
)
}
for i, tag in sheet.meta.frame_tags {
match tag.direction {
DirectionUnknown(value) =>
issues.push(
ValidationIssue::warning(
"unknown tag direction '\{value}' may need renderer-side mapping",
path=json_field_path(
json_index_path("$.meta.frameTags", i),
"direction",
),
code="export-unknown-direction",
),
)
DirectionPingPongReverse =>
if options.target == TargetPhaser3 {
issues.push(
ValidationIssue::warning(
"pingpong_reverse should be normalized before Phaser animation creation",
path=json_field_path(
json_index_path("$.meta.frameTags", i),
"direction",
),
code="export-phaser-pingpong-reverse",
),
)
}
_ => ()
}
}
}
///|
fn check_export_boxes(
sheet : SpriteSheet,
options : ExportCompatibilityOptions,
issues : Array[ValidationIssue],
) -> Unit {
if options.require_slice_boxes && sheet.meta.slices.length() == 0 {
issues.push(
ValidationIssue::error(
"\{options.target.to_string()} export needs slices for frame-level boxes",
path="$.meta.slices",
code="export-missing-slices",
),
)
}
if options.require_hitbox ||
options.require_hurtbox ||
options.require_collision_box {
let table = build_frame_box_table(sheet)
if !has_required_box_kinds(
table,
require_hit=options.require_hitbox,
require_hurt=options.require_hurtbox,
require_collision=options.require_collision_box,
) {
issues.push(
ValidationIssue::error(
"required hitbox/hurtbox/collision box kinds are not all present",
path="$.meta.slices",
code="export-missing-required-box-kind",
),
)
}
}
}