yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
dungeon_proposal_overlay.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <cmath>
5#include <set>
6#include <string>
7
8#include "absl/status/status.h"
9#include "absl/strings/str_format.h"
10#include "nlohmann/json.hpp"
11
12namespace yaze::editor {
13namespace {
14
15using json = nlohmann::json;
16
17absl::Status Invalid(const std::string& where, const std::string& what) {
18 return absl::InvalidArgumentError(
19 absl::StrFormat("proposal overlay %s: %s", where, what));
20}
21
22absl::StatusOr<int> ParseInteger(const json& value, const std::string& where,
23 const char* name, int min, int max) {
24 if (!value.is_number_integer() || value < min || value > max) {
25 return Invalid(where, absl::StrFormat("%s must be an integer in %d..%d",
26 name, min, max));
27 }
28 return value.get<int>();
29}
30
31// Accepts 135, "135", "0x87", or "$87".
32absl::StatusOr<int> ParseRoomId(const json& value, const std::string& where,
33 int max_room_id) {
34 int room = -1;
35 if (value.is_number_integer()) {
36 auto number = ParseInteger(value, where, "room id", 0, max_room_id);
37 if (!number.ok())
38 return number.status();
39 room = *number;
40 } else if (value.is_string()) {
41 std::string text = value.get<std::string>();
42 int base = 10;
43 if (text.rfind("0x", 0) == 0 || text.rfind("0X", 0) == 0) {
44 text = text.substr(2);
45 base = 16;
46 } else if (!text.empty() && text[0] == '$') {
47 text = text.substr(1);
48 base = 16;
49 }
50 if (text.empty()) {
51 return Invalid(where, "empty room id");
52 }
53 size_t used = 0;
54 try {
55 room = std::stoi(text, &used, base);
56 } catch (...) {
57 return Invalid(where, "room id is not a number");
58 }
59 if (used != text.size()) {
60 return Invalid(where, "room id is not a number");
61 }
62 } else {
63 return Invalid(where, "room id must be a number or string");
64 }
65 if (room < 0 || room > max_room_id) {
66 return Invalid(where, absl::StrFormat("room 0x%X is out of range", room));
67 }
68 return room;
69}
70
71absl::StatusOr<ProposalTile> ParseTile(const json& value,
72 const std::string& where) {
73 if (!value.is_array() || value.size() != 2 || !value[0].is_number_integer() ||
74 !value[1].is_number_integer()) {
75 return Invalid(where, "tile must be [x, y] integers");
76 }
77 auto x = ParseInteger(value[0], where, "tile x", 0, kProposalRoomTiles - 1);
78 auto y = ParseInteger(value[1], where, "tile y", 0, kProposalRoomTiles - 1);
79 if (!x.ok())
80 return x.status();
81 if (!y.ok())
82 return y.status();
83 return ProposalTile{*x, *y};
84}
85
86std::string OptionalString(const json& object, const char* key) {
87 auto it = object.find(key);
88 if (it == object.end() || !it->is_string()) {
89 return {};
90 }
91 return it->get<std::string>();
92}
93
94absl::StatusOr<ProposalShape> ParseShape(const json& value,
95 const std::string& where,
96 const std::set<int>& rooms,
97 int max_room_id) {
98 if (!value.is_object()) {
99 return Invalid(where, "shape must be an object");
100 }
101 ProposalShape shape;
102 auto room_it = value.find("room");
103 if (room_it == value.end()) {
104 return Invalid(where, "missing \"room\"");
105 }
106 auto room = ParseRoomId(*room_it, where, max_room_id);
107 if (!room.ok()) {
108 return room.status();
109 }
110 if (!rooms.contains(*room)) {
111 return Invalid(
112 where, absl::StrFormat("room 0x%X is not listed in \"rooms\"", *room));
113 }
114 shape.room_id = *room;
115 shape.label = OptionalString(value, "label");
116 shape.detail = OptionalString(value, "detail");
117
118 const std::string type = OptionalString(value, "type");
119 if (type == "rect") {
121 auto it = value.find("tiles");
122 if (it == value.end() || !it->is_array() || it->size() != 4) {
123 return Invalid(where, "rect needs \"tiles\": [x0, y0, x1, y1]");
124 }
125 auto a = ParseTile(json::array({(*it)[0], (*it)[1]}), where);
126 auto b = ParseTile(json::array({(*it)[2], (*it)[3]}), where);
127 if (!a.ok())
128 return a.status();
129 if (!b.ok())
130 return b.status();
131 if (b->x < a->x || b->y < a->y) {
132 return Invalid(where,
133 "rect corners must be [x0, y0, x1, y1] with "
134 "x0<=x1 and y0<=y1");
135 }
136 shape.tiles = {*a, *b};
137 } else if (type == "path") {
139 auto it = value.find("tiles");
140 if (it == value.end() || !it->is_array() || it->size() < 2) {
141 return Invalid(where, "path needs \"tiles\": [[x, y], [x, y], ...]");
142 }
143 for (const auto& point : *it) {
144 auto tile = ParseTile(point, where);
145 if (!tile.ok())
146 return tile.status();
147 shape.tiles.push_back(*tile);
148 }
149 } else if (type == "marker" || type == "remove" || type == "actor" ||
150 type == "object") {
151 shape.type = type == "marker" ? ProposalShapeType::kMarker
153 auto it = value.find("tile");
154 if (it == value.end()) {
155 return Invalid(where, "missing \"tile\": [x, y]");
156 }
157 auto tile = ParseTile(*it, where);
158 if (!tile.ok())
159 return tile.status();
160 shape.tiles = {*tile};
161 if (type == "actor") {
163 const std::string facing = OptionalString(value, "facing");
164 if (facing == "north")
166 else if (facing == "east")
168 else if (facing == "south")
170 else if (facing == "west")
172 else
173 return Invalid(where,
174 "actor needs facing: north, east, south, or west");
175 if (auto offset = value.find("offset"); offset != value.end()) {
176 auto parsed = ParseTile(*offset, where + " offset");
177 if (!parsed.ok())
178 return parsed.status();
179 if (parsed->x >= kProposalTilePixels ||
180 parsed->y >= kProposalTilePixels) {
181 return Invalid(where, "actor offset must be [x, y] pixels in 0..7");
182 }
183 shape.pixel_offset = *parsed;
184 }
185 } else if (type == "object") {
187 auto id = value.find("object_id");
188 if (id == value.end())
189 return Invalid(where, "object needs object_id");
190 // Object IDs use the same decimal/$hex/0xhex notation as room IDs.
191 auto parsed_id = ParseRoomId(*id, where + " object_id", 0xFFF);
192 if (!parsed_id.ok())
193 return parsed_id.status();
194 if (!(*parsed_id <= 0xF7 ||
195 (*parsed_id >= 0x100 && *parsed_id <= 0x13F) ||
196 *parsed_id >= 0xF80)) {
197 return Invalid(where, "object_id is not an encodable dungeon object");
198 }
199 shape.object_id = *parsed_id;
200 for (const char* field : {"size", "list"}) {
201 if (auto entry = value.find(field); entry != value.end()) {
202 const bool is_size = std::string_view(field) == "size";
203 auto number =
204 ParseInteger(*entry, where, field, 0, is_size ? 255 : 2);
205 if (!number.ok())
206 return number.status();
207 (is_size ? shape.object_size : shape.object_list) = *number;
208 }
209 }
210 auto footprint = value.find("footprint");
211 if (footprint == value.end() || !footprint->is_array() ||
212 footprint->size() != 2) {
213 return Invalid(where,
214 "object needs footprint: [width, height] in tiles");
215 }
216 auto width = ParseInteger((*footprint)[0], where, "footprint width", 1,
217 kProposalRoomTiles - tile->x);
218 auto height = ParseInteger((*footprint)[1], where, "footprint height", 1,
219 kProposalRoomTiles - tile->y);
220 if (!width.ok())
221 return width.status();
222 if (!height.ok())
223 return height.status();
224 shape.footprint = {*width, *height};
225 }
226 } else {
227 return Invalid(where,
228 absl::StrFormat("unknown shape type \"%s\" (use rect, path, "
229 "marker, remove, actor, object)",
230 type));
231 }
232 return shape;
233}
234
235} // namespace
236
237absl::StatusOr<uint32_t> ParseProposalColor(std::string_view text) {
238 // absl::StrFormat gets std::string, not std::string_view: Ubuntu 22.04's
239 // Abseil (20210324, absl::string_view != std::string_view) cannot format a
240 // std::string_view argument.
241 const std::string shown(text);
242 if (text.size() != 7 && text.size() != 9) {
243 return absl::InvalidArgumentError(
244 absl::StrFormat("color \"%s\" must be #RRGGBB or #RRGGBBAA", shown));
245 }
246 if (text[0] != '#') {
247 return absl::InvalidArgumentError(
248 absl::StrFormat("color \"%s\" must start with #", shown));
249 }
250 uint32_t value = 0;
251 for (size_t i = 1; i < text.size(); ++i) {
252 const char c = text[i];
253 int digit = -1;
254 if (c >= '0' && c <= '9')
255 digit = c - '0';
256 if (c >= 'a' && c <= 'f')
257 digit = c - 'a' + 10;
258 if (c >= 'A' && c <= 'F')
259 digit = c - 'A' + 10;
260 if (digit < 0) {
261 return absl::InvalidArgumentError(
262 absl::StrFormat("color \"%s\" has a non-hex digit", shown));
263 }
264 value = (value << 4) | static_cast<uint32_t>(digit);
265 }
266 if (text.size() == 7) {
267 value = (value << 8) | 0xFF;
268 }
269 return value;
270}
271
272absl::StatusOr<DungeonProposalOverlay> ParseDungeonProposalOverlay(
273 std::string_view json_text, int max_room_id) {
274 json root = json::parse(json_text.begin(), json_text.end(), nullptr,
275 /*allow_exceptions=*/false);
276 if (root.is_discarded()) {
277 return absl::InvalidArgumentError("proposal overlay is not valid JSON");
278 }
279 if (!root.is_object()) {
280 return Invalid("root", "must be an object");
281 }
282 if (OptionalString(root, "format") != kProposalOverlayFormat) {
283 return Invalid("root",
284 absl::StrFormat("\"format\" must be \"%s\"",
285 std::string(kProposalOverlayFormat)));
286 }
287 auto version = root.find("version");
288 if (version == root.end() || !version->is_number_integer() ||
289 *version != kProposalOverlayVersion) {
290 return Invalid("root",
291 absl::StrFormat("unsupported \"version\" (expected %d)",
293 }
294
296 overlay.title = OptionalString(root, "title");
297 overlay.status = OptionalString(root, "status");
298 if (auto notes = root.find("notes");
299 notes != root.end() && notes->is_array()) {
300 for (const auto& note : *notes) {
301 if (note.is_string())
302 overlay.notes.push_back(note.get<std::string>());
303 }
304 }
305
306 auto rooms = root.find("rooms");
307 if (rooms == root.end() || !rooms->is_array() || rooms->empty()) {
308 return Invalid("root", "\"rooms\" must be a non-empty list");
309 }
310 std::set<int> room_ids;
311 for (size_t i = 0; i < rooms->size(); ++i) {
312 const std::string where = absl::StrFormat("rooms[%d]", i);
313 const json& entry = (*rooms)[i];
314 const json& id =
315 entry.is_object() && entry.contains("room") ? entry["room"] : entry;
316 auto room = ParseRoomId(id, where, max_room_id);
317 if (!room.ok())
318 return room.status();
319 if (!room_ids.insert(*room).second) {
320 return Invalid(where,
321 absl::StrFormat("room 0x%X is listed twice", *room));
322 }
323 overlay.rooms.push_back(
324 {*room, entry.is_object() ? OptionalString(entry, "label") : ""});
325 }
326
327 auto layers = root.find("layers");
328 if (layers == root.end() || !layers->is_array()) {
329 return Invalid("root", "\"layers\" must be a list");
330 }
331 std::set<std::string> layer_ids;
332 for (size_t li = 0; li < layers->size(); ++li) {
333 const json& entry = (*layers)[li];
334 std::string where = absl::StrFormat("layers[%d]", li);
335 if (!entry.is_object()) {
336 return Invalid(where, "must be an object");
337 }
338 ProposalLayer layer;
339 layer.id = OptionalString(entry, "id");
340 if (layer.id.empty()) {
341 return Invalid(where, "missing \"id\"");
342 }
343 if (!layer_ids.insert(layer.id).second) {
344 return Invalid(where,
345 absl::StrFormat("duplicate layer id \"%s\"", layer.id));
346 }
347 where = absl::StrFormat("layer \"%s\"", layer.id);
348 layer.label = OptionalString(entry, "label");
349 if (layer.label.empty())
350 layer.label = layer.id;
351 if (auto color = entry.find("color"); color != entry.end()) {
352 if (!color->is_string()) {
353 return Invalid(where, "\"color\" must be a string");
354 }
355 auto rgba = ParseProposalColor(color->get<std::string>());
356 if (!rgba.ok()) {
357 return Invalid(where, std::string(rgba.status().message()));
358 }
359 layer.color_rgba = *rgba;
360 }
361 if (auto visible = entry.find("visible");
362 visible != entry.end() && visible->is_boolean()) {
363 layer.visible = visible->get<bool>();
364 }
365 auto shapes = entry.find("shapes");
366 if (shapes == entry.end() || !shapes->is_array()) {
367 return Invalid(where, "\"shapes\" must be a list");
368 }
369 for (size_t si = 0; si < shapes->size(); ++si) {
370 auto shape =
371 ParseShape((*shapes)[si], absl::StrFormat("%s shapes[%d]", where, si),
372 room_ids, max_room_id);
373 if (!shape.ok())
374 return shape.status();
375 layer.shapes.push_back(std::move(*shape));
376 }
377 overlay.layers.push_back(std::move(layer));
378 }
379 if (auto steps = root.find("steps"); steps != root.end()) {
380 if (!steps->is_array())
381 return Invalid("root", "steps must be a list");
382 std::set<std::string> step_ids;
383 for (size_t i = 0; i < steps->size(); ++i) {
384 const auto& entry = (*steps)[i];
385 const std::string where = absl::StrFormat("steps[%d]", i);
386 if (!entry.is_object())
387 return Invalid(where, "must be an object");
388 ProposalStep step;
389 step.id = OptionalString(entry, "id");
390 if (step.id.empty() || !step_ids.insert(step.id).second)
391 return Invalid(where, "step id must be non-empty and unique");
392 step.label = OptionalString(entry, "label");
393 if (step.label.empty())
394 step.label = step.id;
395 step.detail = OptionalString(entry, "detail");
396 auto refs = entry.find("layers");
397 if (refs == entry.end() || !refs->is_array() || refs->empty())
398 return Invalid(where, "layers must be a non-empty list of layer ids");
399 std::set<std::string> referenced;
400 for (const auto& ref : *refs) {
401 if (!ref.is_string())
402 return Invalid(where, "layer reference must be a string");
403 const auto id = ref.get<std::string>();
404 if (!layer_ids.contains(id))
405 return Invalid(where, absl::StrFormat("unknown layer id \"%s\"", id));
406 if (!referenced.insert(id).second)
407 return Invalid(where,
408 absl::StrFormat("duplicate layer id \"%s\"", id));
409 step.layer_ids.push_back(id);
410 }
411 overlay.steps.push_back(std::move(step));
412 }
413 }
414 return overlay;
415}
416
418 const int x0 = std::min(a.x, b.x);
419 const int y0 = std::min(a.y, b.y);
420 const int x1 = std::max(a.x, b.x) + 1;
421 const int y1 = std::max(a.y, b.y) + 1;
422 return {static_cast<float>(x0 * kProposalTilePixels),
423 static_cast<float>(y0 * kProposalTilePixels),
424 static_cast<float>(x1 * kProposalTilePixels),
425 static_cast<float>(y1 * kProposalTilePixels)};
426}
427
428void ProposalTileCenter(ProposalTile tile, float* x, float* y) {
429 *x = (tile.x + 0.5f) * kProposalTilePixels;
430 *y = (tile.y + 0.5f) * kProposalTilePixels;
431}
432
434 const bool actor = shape.type == ProposalShapeType::kActor;
435 const float x = shape.tiles[0].x * kProposalTilePixels +
436 (actor ? shape.pixel_offset.x : 0);
437 const float y = shape.tiles[0].y * kProposalTilePixels +
438 (actor ? shape.pixel_offset.y : 0);
439 return {x, y, x + (actor ? 16 : shape.footprint.x * kProposalTilePixels),
440 y + (actor ? 16 : shape.footprint.y * kProposalTilePixels)};
441}
442
444 std::string_view layer_id, int step) {
445 if (step < 0)
446 return true;
447 if (static_cast<size_t>(step) >= overlay.steps.size())
448 return false;
449 const auto& ids = overlay.steps[step].layer_ids;
450 return std::find(ids.begin(), ids.end(), layer_id) != ids.end();
451}
452
453float ProposalRoomOriginX(int index, float gap_pixels) {
454 return index * (kProposalRoomTiles * kProposalTilePixels + gap_pixels);
455}
456
457float ProposalDistanceToSegment(float px, float py, float ax, float ay,
458 float bx, float by) {
459 const float dx = bx - ax;
460 const float dy = by - ay;
461 const float len2 = dx * dx + dy * dy;
462 float t = 0.0f;
463 if (len2 > 0.0f) {
464 t = std::clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0.0f, 1.0f);
465 }
466 const float cx = ax + t * dx - px;
467 const float cy = ay + t * dy - py;
468 return std::sqrt(cx * cx + cy * cy);
469}
470
471bool ProposalShapeHitTest(const ProposalShape& shape, float px, float py,
472 float tolerance) {
473 switch (shape.type) {
476 const auto r = ProposalFootprintToPixels(shape);
477 return px >= r.x0 && px < r.x1 && py >= r.y0 && py < r.y1;
478 }
480 const auto r = ProposalTileRectToPixels(shape.tiles[0], shape.tiles[1]);
481 return px >= r.x0 && px < r.x1 && py >= r.y0 && py < r.y1;
482 }
484 for (size_t i = 1; i < shape.tiles.size(); ++i) {
485 float ax, ay, bx, by;
486 ProposalTileCenter(shape.tiles[i - 1], &ax, &ay);
487 ProposalTileCenter(shape.tiles[i], &bx, &by);
488 if (ProposalDistanceToSegment(px, py, ax, ay, bx, by) <= tolerance) {
489 return true;
490 }
491 }
492 return false;
493 }
496 float cx, cy;
497 ProposalTileCenter(shape.tiles[0], &cx, &cy);
498 const float dx = px - cx;
499 const float dy = py - cy;
500 return std::sqrt(dx * dx + dy * dy) <= tolerance;
501 }
502 }
503 return false;
504}
505
506} // namespace yaze::editor
absl::Status Invalid(const std::string &where, const std::string &what)
absl::StatusOr< int > ParseInteger(const json &value, const std::string &where, const char *name, int min, int max)
absl::StatusOr< ProposalShape > ParseShape(const json &value, const std::string &where, const std::set< int > &rooms, int max_room_id)
std::string OptionalString(const json &object, const char *key)
absl::StatusOr< ProposalTile > ParseTile(const json &value, const std::string &where)
absl::StatusOr< int > ParseRoomId(const json &value, const std::string &where, int max_room_id)
Editors are the view controllers for the application.
float ProposalRoomOriginX(int index, float gap_pixels)
absl::StatusOr< DungeonProposalOverlay > ParseDungeonProposalOverlay(std::string_view json_text, int max_room_id)
ProposalPixelRect ProposalFootprintToPixels(const ProposalShape &shape)
ProposalPixelRect ProposalTileRectToPixels(ProposalTile a, ProposalTile b)
constexpr int kProposalOverlayVersion
absl::StatusOr< uint32_t > ParseProposalColor(std::string_view text)
void ProposalTileCenter(ProposalTile tile, float *x, float *y)
constexpr std::string_view kProposalOverlayFormat
constexpr int kProposalRoomTiles
constexpr int kProposalTilePixels
bool ProposalLayerInStep(const DungeonProposalOverlay &overlay, std::string_view layer_id, int step)
float ProposalDistanceToSegment(float px, float py, float ax, float ay, float bx, float by)
bool ProposalShapeHitTest(const ProposalShape &shape, float px, float py, float tolerance)
std::vector< ProposalShape > shapes
std::vector< ProposalTile > tiles
std::vector< std::string > layer_ids