Porytiles
Loading...
Searching...
No Matches
anim_json_parser.cpp
Go to the documentation of this file.
2
3#include <fstream>
4#include <optional>
5#include <set>
6#include <sstream>
7#include <string>
8#include <vector>
9
10#include "fmt/format.h"
11#include "fmt/ranges.h"
12#include "nlohmann/json.hpp"
13
20
21namespace {
22
23using namespace porytiles;
24
25[[nodiscard]] std::optional<metatile::Layer> layer_from_string(const std::string &str)
26{
27 if (str == "bottom") {
28 return metatile::Layer::bottom;
29 }
30 if (str == "middle") {
31 return metatile::Layer::middle;
32 }
33 if (str == "top") {
34 return metatile::Layer::top;
35 }
36 return std::nullopt;
37}
38
39[[nodiscard]] std::optional<metatile::Subtile> subtile_from_string(const std::string &str)
40{
41 if (str == "northwest") {
42 return metatile::Subtile::northwest;
43 }
44 if (str == "northeast") {
45 return metatile::Subtile::northeast;
46 }
47 if (str == "southwest") {
48 return metatile::Subtile::southwest;
49 }
50 if (str == "southeast") {
51 return metatile::Subtile::southeast;
52 }
53 return std::nullopt;
54}
55
56[[nodiscard]] std::string subtile_to_json_string(metatile::Subtile subtile)
57{
58 switch (subtile) {
59 case metatile::Subtile::northwest:
60 return "northwest";
61 case metatile::Subtile::northeast:
62 return "northeast";
63 case metatile::Subtile::southwest:
64 return "southwest";
65 case metatile::Subtile::southeast:
66 return "southeast";
67 }
68 panic("unhandled Subtile value");
69}
70
82[[nodiscard]] std::size_t byte_offset_to_line_index(const std::filesystem::path &json_path, std::size_t byte_offset)
83{
84 std::ifstream in{json_path};
85 if (!in) {
86 return 0;
87 }
88
89 std::size_t line_index = 0;
90 std::size_t current_byte = 0;
91 char ch{};
92 while (in.get(ch) && current_byte < byte_offset) {
93 if (ch == '\n') {
94 ++line_index;
95 }
96 ++current_byte;
97 }
98 return line_index;
99}
100
112[[nodiscard]] std::size_t find_key_line_index(const std::filesystem::path &json_path, const std::string &key_name)
113{
114 std::ifstream in{json_path};
115 if (!in) {
116 return 0;
117 }
118
119 const std::string pattern = "\"" + key_name + "\"";
120 std::string line;
121 std::size_t line_index = 0;
122 while (std::getline(in, line)) {
123 if (line.find(pattern) != std::string::npos) {
124 return line_index;
125 }
126 ++line_index;
127 }
128 return 0;
129}
130
131[[nodiscard]] std::vector<AnimOverrideEntry>
132parse_override_entries(const std::string &context_name, const nlohmann::json &overrides_node)
133{
134 std::vector<AnimOverrideEntry> overrides;
135 for (const auto &entry_node : overrides_node) {
136 AnimOverrideEntry entry{};
137
138 entry.metatile_id = entry_node.at("id").get<std::size_t>();
139
140 const auto layer_str = entry_node.at("layer").get<std::string>();
141 const auto layer_opt = layer_from_string(layer_str);
142 if (!layer_opt.has_value()) {
143 panic("anim.json: '" + context_name + "' override has invalid layer '" + layer_str + "'");
144 }
145 entry.layer = *layer_opt;
146
147 const auto subtile_str = entry_node.at("subtile").get<std::string>();
148 const auto subtile_opt = subtile_from_string(subtile_str);
149 if (!subtile_opt.has_value()) {
150 panic("anim.json: '" + context_name + "' override has invalid subtile '" + subtile_str + "'");
151 }
152 entry.subtile = *subtile_opt;
153
154 entry.frame_subtile = entry_node.at("frame_subtile").get<std::size_t>();
155 entry.pal_index = entry_node.at("pal_index").get<std::size_t>();
156 entry.h_flip = entry_node.at("hflip").get<bool>();
157 entry.v_flip = entry_node.at("vflip").get<bool>();
158
159 overrides.push_back(entry);
160 }
161 return overrides;
162}
163
164[[nodiscard]] nlohmann::ordered_json serialize_override_entries(const std::vector<AnimOverrideEntry> &entries)
165{
166 nlohmann::ordered_json overrides_array = nlohmann::ordered_json::array();
167 for (const auto &entry : entries) {
168 nlohmann::ordered_json obj;
169 obj["id"] = entry.metatile_id;
170 obj["layer"] = metatile::to_string(entry.layer);
171 obj["subtile"] = subtile_to_json_string(entry.subtile);
172 obj["frame_subtile"] = entry.frame_subtile;
173 obj["pal_index"] = entry.pal_index;
174 obj["hflip"] = entry.h_flip;
175 obj["vflip"] = entry.v_flip;
176 overrides_array.push_back(std::move(obj));
177 }
178 return overrides_array;
179}
180
181AnimParams parse_animation_params(const std::string &anim_name, const nlohmann::json &node)
182{
183 AnimParams params;
184
185 if (node.contains("frame_factor")) {
186 params.frame_factor(node["frame_factor"].get<std::size_t>());
187 }
188
189 if (node.contains("frame_offset")) {
190 params.frame_offset(node["frame_offset"].get<std::size_t>());
191 }
192
193 // Parse unique frame definitions
194 if (node.contains("frames")) {
195 std::vector<DynamicCasedName> frame_names;
196 for (const auto &frame : node["frames"]) {
197 // Read as string - JSON arrays may contain strings or numbers, convert to string
198 frame_names.push_back(DynamicCasedName::from_snake_case(frame.get<std::string>()));
199 }
200 params.frame_names(std::move(frame_names));
201 }
202
203 // Parse playback sequence
204 if (node.contains("frame_order")) {
205 std::vector<DynamicCasedName> frame_order;
206 for (const auto &frame : node["frame_order"]) {
207 frame_order.push_back(DynamicCasedName::from_snake_case(frame.get<std::string>()));
208 }
209 params.frame_order(std::move(frame_order));
210 }
211 else {
212 // Default: frame_order = frame_names (for simple animations where playback order matches definition order)
213 params.frame_order(params.frame_names());
214 }
215
216 if (node.contains("counter_max")) {
217 params.counter_max(node["counter_max"].get<std::size_t>());
218 }
219
220 if (node.contains("overrides")) {
221 if (!node["overrides"].is_array()) {
222 panic("anim.json: animation '" + anim_name + "' overrides must be an array");
223 }
224 params.overrides(parse_override_entries(anim_name, node["overrides"]));
225 }
226
227 if (node.contains("tile_offset")) {
228 params.tile_offset(node["tile_offset"].get<std::size_t>());
229 }
230
231 params.cased_name(DynamicCasedName{anim_name});
232 return params;
233}
234
235nlohmann::ordered_json serialize_animation_params(const AnimParams &params)
236{
237 nlohmann::ordered_json node;
238
239 // Only write non-default values to keep the file clean
241 node["frame_factor"] = params.frame_factor();
242 }
243
245 node["frame_offset"] = params.frame_offset();
246 }
247
248 // Always write frames array since it's the core animation definition
249 nlohmann::ordered_json frames_array = nlohmann::ordered_json::array();
250 for (const auto &frame : params.frame_names()) {
251 frames_array.push_back(frame.to_snake_case());
252 }
253 node["frames"] = frames_array;
254
255 // Always write frame_order array since it defines the playback sequence
256 nlohmann::ordered_json frame_order_array = nlohmann::ordered_json::array();
257 for (const auto &frame : params.frame_order()) {
258 frame_order_array.push_back(frame.to_snake_case());
259 }
260 node["frame_order"] = frame_order_array;
261
262 if (params.counter_max() != anim::default_counter_max) {
263 node["counter_max"] = params.counter_max();
264 }
265
266 if (params.tile_offset() != 0) {
267 node["tile_offset"] = params.tile_offset();
268 }
269
270 if (!params.overrides().empty()) {
271 node["overrides"] = serialize_override_entries(params.overrides());
272 }
273
274 return node;
275}
276
277} // namespace
278
279namespace porytiles {
280
281AnimJsonParser::AnimJsonParser(gsl::not_null<const TextFormatter *> format) : format_{format} {}
282
284AnimJsonParser::parse(const std::filesystem::path &json_path) const
285{
286 if (!std::filesystem::exists(json_path)) {
287 return FormattableError{
288 std::vector<std::string>{"Parameters file not found.", "Expected file: {}"},
289 std::vector<std::vector<FormatParam>>{{}, {FormatParam{json_path.string(), Style::bold}}}};
290 }
291
292 try {
293 std::ifstream in{json_path};
294 if (!in) {
295 return FormattableError{
296 std::vector<std::string>{"Failed to open anim.json for reading.", "path: {}"},
297 std::vector<std::vector<FormatParam>>{{}, {FormatParam{json_path.string(), Style::bold}}}};
298 }
299
300 nlohmann::json root = nlohmann::json::parse(in);
301
302 std::map<DynamicCasedName, AnimParams> result;
303
304 if (!root.is_object()) {
305 FileHighlightPrinter printer{format_};
306 std::vector<std::string> err_lines;
307 err_lines.push_back(format_->format(
308 "{}:1: Invalid anim.json format, expected a JSON object at the root level.",
309 FormatParam{json_path.string(), Style::bold}));
310 err_lines.emplace_back();
311 auto context = printer.print(json_path, std::vector<std::size_t>{0});
312 err_lines.insert(err_lines.end(), context.begin(), context.end());
313 return FormattableError{std::move(err_lines)};
314 }
315
316 for (const auto &[anim_name, anim_node] : root.items()) {
317 if (anim_name == "primary_references") {
318 continue;
319 }
320
321 const std::size_t line_idx = find_key_line_index(json_path, anim_name);
322
323 // Validate snake_case naming convention
324 const auto expected_snake = DynamicCasedName{anim_name}.to_snake_case();
325 if (expected_snake != anim_name) {
326 FileHighlightPrinter printer{format_};
327 std::vector<std::string> err_lines;
328 err_lines.push_back(format_->format(
329 "{}:{}: Animation name '{}' must be snake_case (expected '{}').",
330 FormatParam{json_path.string(), Style::bold},
331 FormatParam{line_idx + 1},
332 FormatParam{anim_name, Style::bold},
333 FormatParam{expected_snake, Style::bold}));
334 err_lines.emplace_back();
335 auto context = printer.print(json_path, std::vector{line_idx});
336 err_lines.insert(err_lines.end(), context.begin(), context.end());
337 return FormattableError{std::move(err_lines)};
338 }
339
340 if (!anim_node.is_object()) {
341 FileHighlightPrinter printer{format_};
342 std::vector<std::string> err_lines;
343 err_lines.push_back(format_->format(
344 "{}:{}: Invalid animation entry, '{}' should be a JSON object with frame_factor, frame_offset, "
345 "frames fields.",
346 FormatParam{json_path.string(), Style::bold},
347 FormatParam{line_idx + 1},
348 FormatParam{anim_name, Style::bold}));
349 err_lines.emplace_back();
350 auto context = printer.print(json_path, std::vector{line_idx});
351 err_lines.insert(err_lines.end(), context.begin(), context.end());
352 return FormattableError{std::move(err_lines)};
353 }
354
355 auto parsed = parse_animation_params(anim_name, anim_node);
356 result.insert({DynamicCasedName{anim_name}, std::move(parsed)});
357
358 // Validate that frame_order entries reference valid frame_names
359 const auto &parsed_params = result.at(DynamicCasedName{anim_name});
360 std::set<DynamicCasedName> valid_frames(
361 parsed_params.frame_names().begin(), parsed_params.frame_names().end());
362
363 for (const auto &frame : parsed_params.frame_order()) {
364 if (!valid_frames.contains(frame)) {
365 FileHighlightPrinter printer{format_};
366 std::vector<std::string> err_lines;
367 err_lines.push_back(format_->format(
368 "{}:{}: frame_order entry '{}' does not exist in frames list.",
369 FormatParam{json_path.string(), Style::bold},
370 FormatParam{line_idx + 1},
371 FormatParam{frame.to_snake_case(), Style::bold}));
372 err_lines.emplace_back();
373
374 // Transform frame names to snake_case strings for display
375 std::vector<std::string> frame_strs;
376 frame_strs.reserve(parsed_params.frame_names().size());
377 for (const auto &f : parsed_params.frame_names()) {
378 frame_strs.push_back(f.to_snake_case());
379 }
380 err_lines.push_back(format_->format(
381 "Valid frames are: {}.",
382 FormatParam{fmt::format("[{}]", fmt::join(frame_strs, ", ")), Style::bold}));
383 err_lines.emplace_back();
384 auto context = printer.print(json_path, std::vector<std::size_t>{line_idx});
385 err_lines.insert(err_lines.end(), context.begin(), context.end());
386 return FormattableError{std::move(err_lines)};
387 }
388 }
389 }
390
391 return result;
392 }
393 catch (const nlohmann::json::parse_error &e) {
394 FileHighlightPrinter printer{format_};
395 std::vector<std::string> err_lines;
396
397 const auto byte_offset = static_cast<std::size_t>(e.byte);
398 const auto line_idx = byte_offset_to_line_index(json_path, byte_offset);
399 err_lines.push_back(format_->format(
400 "{}:{}: Failed to parse anim.json: {}.",
401 FormatParam{json_path.string(), Style::bold},
402 FormatParam{line_idx + 1},
403 FormatParam{e.what()}));
404 err_lines.emplace_back();
405 auto context = printer.print(json_path, std::vector<std::size_t>{line_idx});
406 err_lines.insert(err_lines.end(), context.begin(), context.end());
407
408 return FormattableError{std::move(err_lines)};
409 }
410 catch (const nlohmann::json::exception &e) {
411 return FormattableError{
412 std::vector<std::string>{"{}: Failed to parse anim.json: {}."},
413 std::vector<std::vector<FormatParam>>{
414 {FormatParam{json_path.string(), Style::bold}, FormatParam{e.what()}}}};
415 }
416}
417
419AnimJsonParser::parse_primary_references(const std::filesystem::path &json_path) const
420{
421 if (!std::filesystem::exists(json_path)) {
422 return FormattableError{
423 std::vector<std::string>{"Parameters file not found.", "Expected file: {}"},
424 std::vector<std::vector<FormatParam>>{{}, {FormatParam{json_path.string(), Style::bold}}}};
425 }
426
427 try {
428 std::ifstream in{json_path};
429 if (!in) {
430 return FormattableError{
431 std::vector<std::string>{"Failed to open anim.json for reading.", "path: {}"},
432 std::vector<std::vector<FormatParam>>{{}, {FormatParam{json_path.string(), Style::bold}}}};
433 }
434
435 nlohmann::json root = nlohmann::json::parse(in);
436
437 std::map<DynamicCasedName, std::vector<AnimOverrideEntry>> result;
438
439 if (!root.is_object() || !root.contains("primary_references")) {
440 return result;
441 }
442
443 const auto &refs_node = root["primary_references"];
444 if (!refs_node.is_object()) {
445 FileHighlightPrinter printer{format_};
446 const auto line_idx = find_key_line_index(json_path, "primary_references");
447 std::vector<std::string> err_lines;
448 err_lines.push_back(format_->format(
449 "{}:{}: 'primary_references' must be a JSON object.",
450 FormatParam{json_path.string(), Style::bold},
451 FormatParam{line_idx + 1}));
452 err_lines.emplace_back();
453 auto context = printer.print(json_path, std::vector{line_idx});
454 err_lines.insert(err_lines.end(), context.begin(), context.end());
455 return FormattableError{std::move(err_lines)};
456 }
457
458 for (const auto &[prim_anim_name, prim_anim_node] : refs_node.items()) {
459 const auto line_idx = find_key_line_index(json_path, prim_anim_name);
460
461 if (!prim_anim_node.is_object()) {
462 FileHighlightPrinter printer{format_};
463 std::vector<std::string> err_lines;
464 err_lines.push_back(format_->format(
465 "{}:{}: Primary reference '{}' must be a JSON object.",
466 FormatParam{json_path.string(), Style::bold},
467 FormatParam{line_idx + 1},
468 FormatParam{prim_anim_name, Style::bold}));
469 err_lines.emplace_back();
470 auto context = printer.print(json_path, std::vector{line_idx});
471 err_lines.insert(err_lines.end(), context.begin(), context.end());
472 return FormattableError{std::move(err_lines)};
473 }
474
475 if (!prim_anim_node.contains("overrides") || !prim_anim_node["overrides"].is_array()) {
476 FileHighlightPrinter printer{format_};
477 std::vector<std::string> err_lines;
478 err_lines.push_back(format_->format(
479 "{}:{}: Primary reference '{}' must contain an 'overrides' array.",
480 FormatParam{json_path.string(), Style::bold},
481 FormatParam{line_idx + 1},
482 FormatParam{prim_anim_name, Style::bold}));
483 err_lines.emplace_back();
484 auto context = printer.print(json_path, std::vector{line_idx});
485 err_lines.insert(err_lines.end(), context.begin(), context.end());
486 return FormattableError{std::move(err_lines)};
487 }
488
489 auto entries = parse_override_entries(prim_anim_name, prim_anim_node["overrides"]);
490 result.insert({DynamicCasedName{prim_anim_name}, std::move(entries)});
491 }
492
493 return result;
494 }
495 catch (const nlohmann::json::parse_error &e) {
496 FileHighlightPrinter printer{format_};
497 std::vector<std::string> err_lines;
498
499 const auto byte_offset = static_cast<std::size_t>(e.byte);
500 const auto line_idx = byte_offset_to_line_index(json_path, byte_offset);
501 err_lines.push_back(format_->format(
502 "{}:{}: Failed to parse anim.json: {}.",
503 FormatParam{json_path.string(), Style::bold},
504 FormatParam{line_idx + 1},
505 FormatParam{e.what()}));
506 err_lines.emplace_back();
507 auto context = printer.print(json_path, std::vector<std::size_t>{line_idx});
508 err_lines.insert(err_lines.end(), context.begin(), context.end());
509
510 return FormattableError{std::move(err_lines)};
511 }
512 catch (const nlohmann::json::exception &e) {
513 return FormattableError{
514 std::vector<std::string>{"{}: Failed to parse anim.json: {}."},
515 std::vector<std::vector<FormatParam>>{
516 {FormatParam{json_path.string(), Style::bold}, FormatParam{e.what()}}}};
517 }
518}
519
521 const std::filesystem::path &json_path,
522 const std::map<DynamicCasedName, AnimParams> &params,
523 const std::map<DynamicCasedName, std::vector<AnimOverrideEntry>> &primary_references) const
524{
525 try {
526 // Create parent directories if they don't exist
527 if (json_path.has_parent_path()) {
528 std::filesystem::create_directories(json_path.parent_path());
529 }
530
531 nlohmann::ordered_json root;
532 for (const auto &[name, anim_params] : params) {
533 root[name.to_snake_case()] = serialize_animation_params(anim_params);
534 }
535
536 if (!primary_references.empty()) {
537 nlohmann::ordered_json refs_node;
538 for (const auto &[prim_anim_name, entries] : primary_references) {
539 nlohmann::ordered_json anim_ref_node;
540 anim_ref_node["overrides"] = serialize_override_entries(entries);
541 refs_node[prim_anim_name.to_snake_case()] = std::move(anim_ref_node);
542 }
543 root["primary_references"] = std::move(refs_node);
544 }
545
546 std::ofstream out(json_path);
547 if (!out) {
548 return FormattableError{
549 std::vector<std::string>{"Failed to open anim.json for writing.", "path: {}"},
550 std::vector<std::vector<FormatParam>>{{}, {FormatParam{json_path.string(), Style::bold}}}};
551 }
552
553 out << root.dump(2);
554 out << std::endl;
555 out.close();
556
557 if (!out) {
558 return FormattableError{
559 std::vector<std::string>{"Failed to write anim.json.", "Error occurred while writing to: {}"},
560 std::vector<std::vector<FormatParam>>{{}, {FormatParam{json_path.string(), Style::bold}}}};
561 }
562
563 return {};
564 }
565 catch (const nlohmann::json::exception &e) {
566 return FormattableError{
567 std::vector<std::string>{"Failed to serialize anim.json.", "JSON error: {}"},
568 std::vector<std::vector<FormatParam>>{{}, {FormatParam{e.what()}}}};
569 }
570}
571
572} // namespace porytiles
AnimJsonParser(gsl::not_null< const TextFormatter * > format)
ChainableResult< void > write(const std::filesystem::path &json_path, const std::map< DynamicCasedName, AnimParams > &params, const std::map< DynamicCasedName, std::vector< AnimOverrideEntry > > &primary_references={}) const
Writes animation parameters to an anim.json file.
ChainableResult< std::map< DynamicCasedName, std::vector< AnimOverrideEntry > > > parse_primary_references(const std::filesystem::path &json_path) const
Parses the primary_references section from an anim.json file.
ChainableResult< std::map< DynamicCasedName, AnimParams > > parse(const std::filesystem::path &json_path) const
Parses an anim.json file into a map of animation parameters.
Configuration parameters for a single tileset animation.
const DynamicCasedName & cased_name() const
Returns the structured name for this animation, preserving case format information.
const std::vector< AnimOverrideEntry > & overrides() const
Returns the manual override entries for this animation.
const std::vector< DynamicCasedName > & frame_order() const
Returns the playback sequence.
std::size_t frame_offset() const
Returns the frame offset (remainder value for timer modulo check).
std::size_t tile_offset() const
Returns the VRAM tile offset for this animation.
const std::vector< DynamicCasedName > & frame_names() const
Returns the unique frame definitions.
std::size_t frame_factor() const
Returns the frame factor (modulus divisor for timer).
std::size_t counter_max() const
Returns the animation counter maximum value.
A result type that maintains a chainable sequence of errors for debugging and error reporting.
A smart string wrapper that preserves word structure for lossless case format conversion.
std::string to_snake_case() const
Outputs all words flattened and joined with underscores.
static DynamicCasedName from_snake_case(const std::string &input)
Constructs from a snake_case input string.
A service for printing file lines with highlighted lines and line numbers.
A text parameter with associated styling for formatted output.
General-purpose error implementation with formatted message support.
Definition error.hpp:63
static const Style bold
Bold text formatting.
virtual std::string format(const std::string &format_str, const std::vector< FormatParam > &params) const
Formats a string with styled parameters using fmtlib syntax.
constexpr std::size_t default_counter_max
constexpr std::size_t default_frame_offset
constexpr std::size_t default_frame_factor
std::string to_string(Layer layer)
Definition metatile.hpp:26
void panic(const StringViewSourceLoc &s)
Unconditionally terminates the program with a panic message.
Definition panic.cpp:43
A manual override that maps a specific metatile entry to an animation subtile.
std::size_t metatile_id
The metatile ID this override applies to (corresponds to JSON "id" field).