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
22// Accepts 135, "135", "0x87", or "$87".
23absl::StatusOr<int> ParseRoomId(const json& value, const std::string& where,
24 int max_room_id) {
25 int room = -1;
26 if (value.is_number_integer()) {
27 room = value.get<int>();
28 } else if (value.is_string()) {
29 std::string text = value.get<std::string>();
30 int base = 10;
31 if (text.rfind("0x", 0) == 0 || text.rfind("0X", 0) == 0) {
32 text = text.substr(2);
33 base = 16;
34 } else if (!text.empty() && text[0] == '$') {
35 text = text.substr(1);
36 base = 16;
37 }
38 if (text.empty()) {
39 return Invalid(where, "empty room id");
40 }
41 size_t used = 0;
42 try {
43 room = std::stoi(text, &used, base);
44 } catch (...) {
45 return Invalid(where, "room id is not a number");
46 }
47 if (used != text.size()) {
48 return Invalid(where, "room id is not a number");
49 }
50 } else {
51 return Invalid(where, "room id must be a number or string");
52 }
53 if (room < 0 || room > max_room_id) {
54 return Invalid(where, absl::StrFormat("room 0x%X is out of range", room));
55 }
56 return room;
57}
58
59absl::StatusOr<ProposalTile> ParseTile(const json& value,
60 const std::string& where) {
61 if (!value.is_array() || value.size() != 2 || !value[0].is_number_integer() ||
62 !value[1].is_number_integer()) {
63 return Invalid(where, "tile must be [x, y] integers");
64 }
65 ProposalTile tile{value[0].get<int>(), value[1].get<int>()};
66 if (tile.x < 0 || tile.x >= kProposalRoomTiles || tile.y < 0 ||
67 tile.y >= kProposalRoomTiles) {
68 return Invalid(where, absl::StrFormat("tile (%d,%d) is outside the room",
69 tile.x, tile.y));
70 }
71 return tile;
72}
73
74std::string OptionalString(const json& object, const char* key) {
75 auto it = object.find(key);
76 if (it == object.end() || !it->is_string()) {
77 return {};
78 }
79 return it->get<std::string>();
80}
81
82absl::StatusOr<ProposalShape> ParseShape(const json& value,
83 const std::string& where,
84 const std::set<int>& rooms,
85 int max_room_id) {
86 if (!value.is_object()) {
87 return Invalid(where, "shape must be an object");
88 }
89 ProposalShape shape;
90 auto room_it = value.find("room");
91 if (room_it == value.end()) {
92 return Invalid(where, "missing \"room\"");
93 }
94 auto room = ParseRoomId(*room_it, where, max_room_id);
95 if (!room.ok()) {
96 return room.status();
97 }
98 if (!rooms.contains(*room)) {
99 return Invalid(
100 where, absl::StrFormat("room 0x%X is not listed in \"rooms\"", *room));
101 }
102 shape.room_id = *room;
103 shape.label = OptionalString(value, "label");
104 shape.detail = OptionalString(value, "detail");
105
106 const std::string type = OptionalString(value, "type");
107 if (type == "rect") {
109 auto it = value.find("tiles");
110 if (it == value.end() || !it->is_array() || it->size() != 4) {
111 return Invalid(where, "rect needs \"tiles\": [x0, y0, x1, y1]");
112 }
113 auto a = ParseTile(json::array({(*it)[0], (*it)[1]}), where);
114 auto b = ParseTile(json::array({(*it)[2], (*it)[3]}), where);
115 if (!a.ok())
116 return a.status();
117 if (!b.ok())
118 return b.status();
119 if (b->x < a->x || b->y < a->y) {
120 return Invalid(where,
121 "rect corners must be [x0, y0, x1, y1] with "
122 "x0<=x1 and y0<=y1");
123 }
124 shape.tiles = {*a, *b};
125 } else if (type == "path") {
127 auto it = value.find("tiles");
128 if (it == value.end() || !it->is_array() || it->size() < 2) {
129 return Invalid(where, "path needs \"tiles\": [[x, y], [x, y], ...]");
130 }
131 for (const auto& point : *it) {
132 auto tile = ParseTile(point, where);
133 if (!tile.ok())
134 return tile.status();
135 shape.tiles.push_back(*tile);
136 }
137 } else if (type == "marker" || type == "remove") {
138 shape.type = type == "marker" ? ProposalShapeType::kMarker
140 auto it = value.find("tile");
141 if (it == value.end()) {
142 return Invalid(where, "missing \"tile\": [x, y]");
143 }
144 auto tile = ParseTile(*it, where);
145 if (!tile.ok())
146 return tile.status();
147 shape.tiles = {*tile};
148 } else {
149 return Invalid(where,
150 absl::StrFormat("unknown shape type \"%s\" (use rect, path, "
151 "marker, remove)",
152 type));
153 }
154 return shape;
155}
156
157} // namespace
158
159absl::StatusOr<uint32_t> ParseProposalColor(std::string_view text) {
160 // absl::StrFormat gets std::string, not std::string_view: Ubuntu 22.04's
161 // Abseil (20210324, absl::string_view != std::string_view) cannot format a
162 // std::string_view argument.
163 const std::string shown(text);
164 if (text.size() != 7 && text.size() != 9) {
165 return absl::InvalidArgumentError(
166 absl::StrFormat("color \"%s\" must be #RRGGBB or #RRGGBBAA", shown));
167 }
168 if (text[0] != '#') {
169 return absl::InvalidArgumentError(
170 absl::StrFormat("color \"%s\" must start with #", shown));
171 }
172 uint32_t value = 0;
173 for (size_t i = 1; i < text.size(); ++i) {
174 const char c = text[i];
175 int digit = -1;
176 if (c >= '0' && c <= '9')
177 digit = c - '0';
178 if (c >= 'a' && c <= 'f')
179 digit = c - 'a' + 10;
180 if (c >= 'A' && c <= 'F')
181 digit = c - 'A' + 10;
182 if (digit < 0) {
183 return absl::InvalidArgumentError(
184 absl::StrFormat("color \"%s\" has a non-hex digit", shown));
185 }
186 value = (value << 4) | static_cast<uint32_t>(digit);
187 }
188 if (text.size() == 7) {
189 value = (value << 8) | 0xFF;
190 }
191 return value;
192}
193
194absl::StatusOr<DungeonProposalOverlay> ParseDungeonProposalOverlay(
195 std::string_view json_text, int max_room_id) {
196 json root = json::parse(json_text.begin(), json_text.end(), nullptr,
197 /*allow_exceptions=*/false);
198 if (root.is_discarded()) {
199 return absl::InvalidArgumentError("proposal overlay is not valid JSON");
200 }
201 if (!root.is_object()) {
202 return Invalid("root", "must be an object");
203 }
204 if (OptionalString(root, "format") != kProposalOverlayFormat) {
205 return Invalid("root",
206 absl::StrFormat("\"format\" must be \"%s\"",
207 std::string(kProposalOverlayFormat)));
208 }
209 auto version = root.find("version");
210 if (version == root.end() || !version->is_number_integer() ||
211 version->get<int>() != kProposalOverlayVersion) {
212 return Invalid("root",
213 absl::StrFormat("unsupported \"version\" (expected %d)",
215 }
216
218 overlay.title = OptionalString(root, "title");
219 overlay.status = OptionalString(root, "status");
220 if (auto notes = root.find("notes");
221 notes != root.end() && notes->is_array()) {
222 for (const auto& note : *notes) {
223 if (note.is_string())
224 overlay.notes.push_back(note.get<std::string>());
225 }
226 }
227
228 auto rooms = root.find("rooms");
229 if (rooms == root.end() || !rooms->is_array() || rooms->empty()) {
230 return Invalid("root", "\"rooms\" must be a non-empty list");
231 }
232 std::set<int> room_ids;
233 for (size_t i = 0; i < rooms->size(); ++i) {
234 const std::string where = absl::StrFormat("rooms[%d]", i);
235 const json& entry = (*rooms)[i];
236 const json& id =
237 entry.is_object() && entry.contains("room") ? entry["room"] : entry;
238 auto room = ParseRoomId(id, where, max_room_id);
239 if (!room.ok())
240 return room.status();
241 if (!room_ids.insert(*room).second) {
242 return Invalid(where,
243 absl::StrFormat("room 0x%X is listed twice", *room));
244 }
245 overlay.rooms.push_back(
246 {*room, entry.is_object() ? OptionalString(entry, "label") : ""});
247 }
248
249 auto layers = root.find("layers");
250 if (layers == root.end() || !layers->is_array()) {
251 return Invalid("root", "\"layers\" must be a list");
252 }
253 std::set<std::string> layer_ids;
254 for (size_t li = 0; li < layers->size(); ++li) {
255 const json& entry = (*layers)[li];
256 std::string where = absl::StrFormat("layers[%d]", li);
257 if (!entry.is_object()) {
258 return Invalid(where, "must be an object");
259 }
260 ProposalLayer layer;
261 layer.id = OptionalString(entry, "id");
262 if (layer.id.empty()) {
263 return Invalid(where, "missing \"id\"");
264 }
265 if (!layer_ids.insert(layer.id).second) {
266 return Invalid(where,
267 absl::StrFormat("duplicate layer id \"%s\"", layer.id));
268 }
269 where = absl::StrFormat("layer \"%s\"", layer.id);
270 layer.label = OptionalString(entry, "label");
271 if (layer.label.empty())
272 layer.label = layer.id;
273 if (auto color = entry.find("color"); color != entry.end()) {
274 if (!color->is_string()) {
275 return Invalid(where, "\"color\" must be a string");
276 }
277 auto rgba = ParseProposalColor(color->get<std::string>());
278 if (!rgba.ok()) {
279 return Invalid(where, std::string(rgba.status().message()));
280 }
281 layer.color_rgba = *rgba;
282 }
283 if (auto visible = entry.find("visible");
284 visible != entry.end() && visible->is_boolean()) {
285 layer.visible = visible->get<bool>();
286 }
287 auto shapes = entry.find("shapes");
288 if (shapes == entry.end() || !shapes->is_array()) {
289 return Invalid(where, "\"shapes\" must be a list");
290 }
291 for (size_t si = 0; si < shapes->size(); ++si) {
292 auto shape =
293 ParseShape((*shapes)[si], absl::StrFormat("%s shapes[%d]", where, si),
294 room_ids, max_room_id);
295 if (!shape.ok())
296 return shape.status();
297 layer.shapes.push_back(std::move(*shape));
298 }
299 overlay.layers.push_back(std::move(layer));
300 }
301 return overlay;
302}
303
305 const int x0 = std::min(a.x, b.x);
306 const int y0 = std::min(a.y, b.y);
307 const int x1 = std::max(a.x, b.x) + 1;
308 const int y1 = std::max(a.y, b.y) + 1;
309 return {static_cast<float>(x0 * kProposalTilePixels),
310 static_cast<float>(y0 * kProposalTilePixels),
311 static_cast<float>(x1 * kProposalTilePixels),
312 static_cast<float>(y1 * kProposalTilePixels)};
313}
314
315void ProposalTileCenter(ProposalTile tile, float* x, float* y) {
316 *x = (tile.x + 0.5f) * kProposalTilePixels;
317 *y = (tile.y + 0.5f) * kProposalTilePixels;
318}
319
320float ProposalRoomOriginX(int index, float gap_pixels) {
321 return index * (kProposalRoomTiles * kProposalTilePixels + gap_pixels);
322}
323
324float ProposalDistanceToSegment(float px, float py, float ax, float ay,
325 float bx, float by) {
326 const float dx = bx - ax;
327 const float dy = by - ay;
328 const float len2 = dx * dx + dy * dy;
329 float t = 0.0f;
330 if (len2 > 0.0f) {
331 t = std::clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0.0f, 1.0f);
332 }
333 const float cx = ax + t * dx - px;
334 const float cy = ay + t * dy - py;
335 return std::sqrt(cx * cx + cy * cy);
336}
337
338bool ProposalShapeHitTest(const ProposalShape& shape, float px, float py,
339 float tolerance) {
340 switch (shape.type) {
342 const auto r = ProposalTileRectToPixels(shape.tiles[0], shape.tiles[1]);
343 return px >= r.x0 && px < r.x1 && py >= r.y0 && py < r.y1;
344 }
346 for (size_t i = 1; i < shape.tiles.size(); ++i) {
347 float ax, ay, bx, by;
348 ProposalTileCenter(shape.tiles[i - 1], &ax, &ay);
349 ProposalTileCenter(shape.tiles[i], &bx, &by);
350 if (ProposalDistanceToSegment(px, py, ax, ay, bx, by) <= tolerance) {
351 return true;
352 }
353 }
354 return false;
355 }
358 float cx, cy;
359 ProposalTileCenter(shape.tiles[0], &cx, &cy);
360 const float dx = px - cx;
361 const float dy = py - cy;
362 return std::sqrt(dx * dx + dy * dy) <= tolerance;
363 }
364 }
365 return false;
366}
367
368} // namespace yaze::editor
absl::Status Invalid(const std::string &where, const std::string &what)
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 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
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