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
80[[nodiscard]] std::size_t byte_offset_to_line_index(const std::filesystem::path &json_path, std::size_t byte_offset)
81{
82 std::ifstream in{json_path};
83 if (!in) {
84 return 0;
85 }
86
87 std::size_t line_index = 0;
88 std::size_t current_byte = 0;
89 char ch{};
90 while (in.get(ch) && current_byte < byte_offset) {
91 if (ch == '\n') {
92 ++line_index;
93 }
94 ++current_byte;
95 }
96 return line_index;
97}
98
108[[nodiscard]] std::size_t find_key_line_index(const std::filesystem::path &json_path, const std::string &key_name)
109{
110 std::ifstream in{json_path};
111 if (!in) {
112 return 0;
113 }
114
115 const std::string pattern = "\"" + key_name + "\"";
116 std::string line;
117 std::size_t line_index = 0;
118 while (std::getline(in, line)) {
119 if (line.find(pattern) != std::string::npos) {
120 return line_index;
121 }
122 ++line_index;
123 }
124 return 0;
125}
126
127[[nodiscard]] std::vector<AnimOverrideEntry>
128parse_override_entries(const std::string &context_name, const nlohmann::json &overrides_node)
129{
130 std::vector<AnimOverrideEntry> overrides;
131 for (const auto &entry_node : overrides_node) {
132 AnimOverrideEntry entry{};
133
134 entry.metatile_id = entry_node.at("id").get<std::size_t>();
135
136 const auto layer_str = entry_node.at("layer").get<std::string>();
137 const auto layer_opt = layer_from_string(layer_str);
138 if (!layer_opt.has_value()) {
139 panic("anim.json: '" + context_name + "' override has invalid layer '" + layer_str + "'");
140 }
141 entry.layer = *layer_opt;
142
143 const auto subtile_str = entry_node.at("subtile").get<std::string>();
144 const auto subtile_opt = subtile_from_string(subtile_str);
145 if (!subtile_opt.has_value()) {
146 panic("anim.json: '" + context_name + "' override has invalid subtile '" + subtile_str + "'");
147 }
148 entry.subtile = *subtile_opt;
149
150 entry.frame_subtile = entry_node.at("frame_subtile").get<std::size_t>();
151 entry.palette_index = entry_node.at("palette_index").get<std::size_t>();
152 entry.h_flip = entry_node.at("hflip").get<bool>();
153 entry.v_flip = entry_node.at("vflip").get<bool>();
154
155 overrides.push_back(entry);
156 }
157 return overrides;
158}
159
160[[nodiscard]] nlohmann::ordered_json serialize_override_entries(const std::vector<AnimOverrideEntry> &entries)
161{
162 nlohmann::ordered_json overrides_array = nlohmann::ordered_json::array();
163 for (const auto &entry : entries) {
164 nlohmann::ordered_json obj;
165 obj["id"] = entry.metatile_id;
166 obj["layer"] = metatile::to_string(entry.layer);
167 obj["subtile"] = subtile_to_json_string(entry.subtile);
168 obj["frame_subtile"] = entry.frame_subtile;
169 obj["palette_index"] = entry.palette_index;
170 obj["hflip"] = entry.h_flip;
171 obj["vflip"] = entry.v_flip;
172 overrides_array.push_back(std::move(obj));
173 }
174 return overrides_array;
175}
176
177AnimParams parse_animation_params(const std::string &anim_name, const nlohmann::json &node)
178{
179 AnimParams params;
180
181 if (node.contains("frame_factor")) {
182 params.frame_factor(node["frame_factor"].get<std::size_t>());
183 }
184
185 if (node.contains("frame_offset")) {
186 params.frame_offset(node["frame_offset"].get<std::size_t>());
187 }
188
189 // Parse unique frame definitions
190 if (node.contains("frames")) {
191 std::vector<DynamicCasedName> frame_names;
192 for (const auto &frame : node["frames"]) {
193 // Read as string - JSON arrays may contain strings or numbers, convert to string
194 frame_names.push_back(DynamicCasedName::from_snake_case(frame.get<std::string>()));
195 }
196 params.frame_names(std::move(frame_names));
197 }
198
199 // Parse playback sequence
200 if (node.contains("frame_order")) {
201 std::vector<DynamicCasedName> frame_order;
202 for (const auto &frame : node["frame_order"]) {
203 frame_order.push_back(DynamicCasedName::from_snake_case(frame.get<std::string>()));
204 }
205 params.frame_order(std::move(frame_order));
206 }
207 else {
208 // Default: frame_order = frame_names (for simple animations where playback order matches definition order)
209 params.frame_order(params.frame_names());
210 }
211
212 if (node.contains("counter_max")) {
213 params.counter_max(node["counter_max"].get<std::size_t>());
214 }
215
216 if (node.contains("overrides")) {
217 if (!node["overrides"].is_array()) {
218 panic("anim.json: animation '" + anim_name + "' overrides must be an array");
219 }
220 params.overrides(parse_override_entries(anim_name, node["overrides"]));
221 }
222
223 if (node.contains("tile_offset")) {
224 params.tile_offset(node["tile_offset"].get<std::size_t>());
225 }
226
227 params.cased_name(DynamicCasedName{anim_name});
228 return params;
229}
230
231nlohmann::ordered_json serialize_animation_params(const AnimParams &params)
232{
233 nlohmann::ordered_json node;
234
235 // Only write non-default values to keep the file clean
237 node["frame_factor"] = params.frame_factor();
238 }
239
241 node["frame_offset"] = params.frame_offset();
242 }
243
244 // Always write frames array since it's the core animation definition
245 nlohmann::ordered_json frames_array = nlohmann::ordered_json::array();
246 for (const auto &frame : params.frame_names()) {
247 frames_array.push_back(frame.to_snake_case());
248 }
249 node["frames"] = frames_array;
250
251 // Always write frame_order array since it defines the playback sequence
252 nlohmann::ordered_json frame_order_array = nlohmann::ordered_json::array();
253 for (const auto &frame : params.frame_order()) {
254 frame_order_array.push_back(frame.to_snake_case());
255 }
256 node["frame_order"] = frame_order_array;
257
258 if (params.counter_max() != anim::default_counter_max) {
259 node["counter_max"] = params.counter_max();
260 }
261
262 if (params.tile_offset() != 0) {
263 node["tile_offset"] = params.tile_offset();
264 }
265
266 if (!params.overrides().empty()) {
267 node["overrides"] = serialize_override_entries(params.overrides());
268 }
269
270 return node;
271}
272
273} // namespace
274
275namespace porytiles {
276
277AnimJsonParser::AnimJsonParser(gsl::not_null<const TextFormatter *> format) : format_{format} {}
278
280AnimJsonParser::parse(const std::filesystem::path &json_path) const
281{
282 if (!std::filesystem::exists(json_path)) {
283 return FormattableError{
284 std::vector<std::string>{"Parameters file not found.", "Expected file: {}"},
285 std::vector<std::vector<FormatParam>>{{}, {FormatParam{json_path.string(), Style::bold}}}};
286 }
287
288 try {
289 std::ifstream in{json_path};
290 if (!in) {
291 return FormattableError{
292 std::vector<std::string>{"Failed to open anim.json for reading.", "path: {}"},
293 std::vector<std::vector<FormatParam>>{{}, {FormatParam{json_path.string(), Style::bold}}}};
294 }
295
296 nlohmann::json root = nlohmann::json::parse(in);
297
298 std::map<DynamicCasedName, AnimParams> result;
299
300 if (!root.is_object()) {
301 FileHighlightPrinter printer{format_};
302 std::vector<std::string> err_lines;
303 err_lines.push_back(format_->format(
304 "{}:1: Invalid anim.json format, expected a JSON object at the root level.",
305 FormatParam{json_path.string(), Style::bold}));
306 err_lines.emplace_back();
307 auto context = printer.print(json_path, std::vector<std::size_t>{0});
308 err_lines.insert(err_lines.end(), context.begin(), context.end());
309 return FormattableError{std::move(err_lines)};
310 }
311
312 for (const auto &[anim_name, anim_node] : root.items()) {
313 if (anim_name == "primary_references") {
314 continue;
315 }
316
317 const std::size_t line_idx = find_key_line_index(json_path, anim_name);
318
319 // Validate snake_case naming convention
320 const auto expected_snake = DynamicCasedName{anim_name}.to_snake_case();
321 if (expected_snake != anim_name) {
322 FileHighlightPrinter printer{format_};
323 std::vector<std::string> err_lines;
324 err_lines.push_back(format_->format(
325 "{}:{}: Animation name '{}' must be snake_case (expected '{}').",
326 FormatParam{json_path.string(), Style::bold},
327 FormatParam{line_idx + 1},
328 FormatParam{anim_name, Style::bold},
329 FormatParam{expected_snake, Style::bold}));
330 err_lines.emplace_back();
331 auto context = printer.print(json_path, std::vector{line_idx});
332 err_lines.insert(err_lines.end(), context.begin(), context.end());
333 return FormattableError{std::move(err_lines)};
334 }
335
336 if (!anim_node.is_object()) {
337 FileHighlightPrinter printer{format_};
338 std::vector<std::string> err_lines;
339 err_lines.push_back(format_->format(
340 "{}:{}: Invalid animation entry, '{}' should be a JSON object with frame_factor, frame_offset, "
341 "frames fields.",
342 FormatParam{json_path.string(), Style::bold},
343 FormatParam{line_idx + 1},
344 FormatParam{anim_name, Style::bold}));
345 err_lines.emplace_back();
346 auto context = printer.print(json_path, std::vector{line_idx});
347 err_lines.insert(err_lines.end(), context.begin(), context.end());
348 return FormattableError{std::move(err_lines)};
349 }
350
351 auto parsed = parse_animation_params(anim_name, anim_node);
352 result.insert({DynamicCasedName{anim_name}, std::move(parsed)});
353
354 // Validate that frame_order entries reference valid frame_names
355 const auto &parsed_params = result.at(DynamicCasedName{anim_name});
356 std::set<DynamicCasedName> valid_frames(
357 parsed_params.frame_names().begin(), parsed_params.frame_names().end());
358
359 for (const auto &frame : parsed_params.frame_order()) {
360 if (!valid_frames.contains(frame)) {
361 FileHighlightPrinter printer{format_};
362 std::vector<std::string> err_lines;
363 err_lines.push_back(format_->format(
364 "{}:{}: frame_order entry '{}' does not exist in frames list.",
365 FormatParam{json_path.string(), Style::bold},
366 FormatParam{line_idx + 1},
367 FormatParam{frame.to_snake_case(), Style::bold}));
368 err_lines.emplace_back();
369
370 // Transform frame names to snake_case strings for display
371 std::vector<std::string> frame_strs;
372 frame_strs.reserve(parsed_params.frame_names().size());
373 for (const auto &f : parsed_params.frame_names()) {
374 frame_strs.push_back(f.to_snake_case());
375 }
376 err_lines.push_back(format_->format(
377 "Valid frames are: {}.",
378 FormatParam{fmt::format("[{}]", fmt::join(frame_strs, ", ")), Style::bold}));
379 err_lines.emplace_back();
380 auto context = printer.print(json_path, std::vector<std::size_t>{line_idx});
381 err_lines.insert(err_lines.end(), context.begin(), context.end());
382 return FormattableError{std::move(err_lines)};
383 }
384 }
385 }
386
387 return result;
388 }
389 catch (const nlohmann::json::parse_error &e) {
390 FileHighlightPrinter printer{format_};
391 std::vector<std::string> err_lines;
392
393 const auto byte_offset = static_cast<std::size_t>(e.byte);
394 const auto line_idx = byte_offset_to_line_index(json_path, byte_offset);
395 err_lines.push_back(format_->format(
396 "{}:{}: Failed to parse anim.json: {}.",
397 FormatParam{json_path.string(), Style::bold},
398 FormatParam{line_idx + 1},
399 FormatParam{e.what()}));
400 err_lines.emplace_back();
401 auto context = printer.print(json_path, std::vector<std::size_t>{line_idx});
402 err_lines.insert(err_lines.end(), context.begin(), context.end());
403
404 return FormattableError{std::move(err_lines)};
405 }
406 catch (const nlohmann::json::exception &e) {
407 return FormattableError{
408 std::vector<std::string>{"{}: Failed to parse anim.json: {}."},
409 std::vector<std::vector<FormatParam>>{
410 {FormatParam{json_path.string(), Style::bold}, FormatParam{e.what()}}}};
411 }
412}
413
415AnimJsonParser::parse_primary_references(const std::filesystem::path &json_path) const
416{
417 if (!std::filesystem::exists(json_path)) {
418 return FormattableError{
419 std::vector<std::string>{"Parameters file not found.", "Expected file: {}"},
420 std::vector<std::vector<FormatParam>>{{}, {FormatParam{json_path.string(), Style::bold}}}};
421 }
422
423 try {
424 std::ifstream in{json_path};
425 if (!in) {
426 return FormattableError{
427 std::vector<std::string>{"Failed to open anim.json for reading.", "path: {}"},
428 std::vector<std::vector<FormatParam>>{{}, {FormatParam{json_path.string(), Style::bold}}}};
429 }
430
431 nlohmann::json root = nlohmann::json::parse(in);
432
433 std::map<DynamicCasedName, std::vector<AnimOverrideEntry>> result;
434
435 if (!root.is_object() || !root.contains("primary_references")) {
436 return result;
437 }
438
439 const auto &refs_node = root["primary_references"];
440 if (!refs_node.is_object()) {
441 FileHighlightPrinter printer{format_};
442 const auto line_idx = find_key_line_index(json_path, "primary_references");
443 std::vector<std::string> err_lines;
444 err_lines.push_back(format_->format(
445 "{}:{}: 'primary_references' must be a JSON object.",
446 FormatParam{json_path.string(), Style::bold},
447 FormatParam{line_idx + 1}));
448 err_lines.emplace_back();
449 auto context = printer.print(json_path, std::vector{line_idx});
450 err_lines.insert(err_lines.end(), context.begin(), context.end());
451 return FormattableError{std::move(err_lines)};
452 }
453
454 for (const auto &[prim_anim_name, prim_anim_node] : refs_node.items()) {
455 const auto line_idx = find_key_line_index(json_path, prim_anim_name);
456
457 if (!prim_anim_node.is_object()) {
458 FileHighlightPrinter printer{format_};
459 std::vector<std::string> err_lines;
460 err_lines.push_back(format_->format(
461 "{}:{}: Primary reference '{}' must be a JSON object.",
462 FormatParam{json_path.string(), Style::bold},
463 FormatParam{line_idx + 1},
464 FormatParam{prim_anim_name, Style::bold}));
465 err_lines.emplace_back();
466 auto context = printer.print(json_path, std::vector{line_idx});
467 err_lines.insert(err_lines.end(), context.begin(), context.end());
468 return FormattableError{std::move(err_lines)};
469 }
470
471 if (!prim_anim_node.contains("overrides") || !prim_anim_node["overrides"].is_array()) {
472 FileHighlightPrinter printer{format_};
473 std::vector<std::string> err_lines;
474 err_lines.push_back(format_->format(
475 "{}:{}: Primary reference '{}' must contain an 'overrides' array.",
476 FormatParam{json_path.string(), Style::bold},
477 FormatParam{line_idx + 1},
478 FormatParam{prim_anim_name, Style::bold}));
479 err_lines.emplace_back();
480 auto context = printer.print(json_path, std::vector{line_idx});
481 err_lines.insert(err_lines.end(), context.begin(), context.end());
482 return FormattableError{std::move(err_lines)};
483 }
484
485 auto entries = parse_override_entries(prim_anim_name, prim_anim_node["overrides"]);
486 result.insert({DynamicCasedName{prim_anim_name}, std::move(entries)});
487 }
488
489 return result;
490 }
491 catch (const nlohmann::json::parse_error &e) {
492 FileHighlightPrinter printer{format_};
493 std::vector<std::string> err_lines;
494
495 const auto byte_offset = static_cast<std::size_t>(e.byte);
496 const auto line_idx = byte_offset_to_line_index(json_path, byte_offset);
497 err_lines.push_back(format_->format(
498 "{}:{}: Failed to parse anim.json: {}.",
499 FormatParam{json_path.string(), Style::bold},
500 FormatParam{line_idx + 1},
501 FormatParam{e.what()}));
502 err_lines.emplace_back();
503 auto context = printer.print(json_path, std::vector<std::size_t>{line_idx});
504 err_lines.insert(err_lines.end(), context.begin(), context.end());
505
506 return FormattableError{std::move(err_lines)};
507 }
508 catch (const nlohmann::json::exception &e) {
509 return FormattableError{
510 std::vector<std::string>{"{}: Failed to parse anim.json: {}."},
511 std::vector<std::vector<FormatParam>>{
512 {FormatParam{json_path.string(), Style::bold}, FormatParam{e.what()}}}};
513 }
514}
515
517 const std::filesystem::path &json_path,
518 const std::map<DynamicCasedName, AnimParams> &params,
519 const std::map<DynamicCasedName, std::vector<AnimOverrideEntry>> &primary_references) const
520{
521 try {
522 // Create parent directories if they don't exist
523 if (json_path.has_parent_path()) {
524 std::filesystem::create_directories(json_path.parent_path());
525 }
526
527 nlohmann::ordered_json root;
528 for (const auto &[name, anim_params] : params) {
529 root[name.to_snake_case()] = serialize_animation_params(anim_params);
530 }
531
532 if (!primary_references.empty()) {
533 nlohmann::ordered_json refs_node;
534 for (const auto &[prim_anim_name, entries] : primary_references) {
535 nlohmann::ordered_json anim_ref_node;
536 anim_ref_node["overrides"] = serialize_override_entries(entries);
537 refs_node[prim_anim_name.to_snake_case()] = std::move(anim_ref_node);
538 }
539 root["primary_references"] = std::move(refs_node);
540 }
541
542 std::ofstream out(json_path);
543 if (!out) {
544 return FormattableError{
545 std::vector<std::string>{"Failed to open anim.json for writing.", "path: {}"},
546 std::vector<std::vector<FormatParam>>{{}, {FormatParam{json_path.string(), Style::bold}}}};
547 }
548
549 out << root.dump(2);
550 out << std::endl;
551 out.close();
552
553 if (!out) {
554 return FormattableError{
555 std::vector<std::string>{"Failed to write anim.json.", "Error occurred while writing to: {}"},
556 std::vector<std::vector<FormatParam>>{{}, {FormatParam{json_path.string(), Style::bold}}}};
557 }
558
559 return {};
560 }
561 catch (const nlohmann::json::exception &e) {
562 return FormattableError{
563 std::vector<std::string>{"Failed to serialize anim.json.", "JSON error: {}"},
564 std::vector<std::vector<FormatParam>>{{}, {FormatParam{e.what()}}}};
565 }
566}
567
568} // 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:57
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.
std::string name
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:47
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).