1 /// This module contains structs containing data relating to packs.
2 ///
3 /// To load packs, use `isodi.pack_json.getPack`, see examples attached to it.
4 ///
5 /// Public_imports:
6 ///     $(UL
7 ///         $(LI `isodi.pack_list` with pack management functions)
8 ///         $(LI `isodi.pack_json` with pack loading functions)
9 ///     )
10 ///
11 /// Macros:
12 ///     TCOLON = $0:
13 module isodi.pack;
14 
15 import std.conv;
16 import std.path;
17 import std.file;
18 import std.random;
19 import std.string;
20 import std.typecons;
21 import std.exception;
22 import std.algorithm;
23 
24 import rcdata.json;
25 
26 import isodi.model;
27 import isodi.internal;
28 import isodi.resource;
29 import isodi.exceptions;
30 
31 public {
32 
33     import isodi.pack_list;
34     import isodi.pack_json;
35 
36 }
37 
38 
39 @safe:
40 
41 
42 /// Resource options
43 struct ResourceOptions {
44 
45     /// If true, a filter will be applied to smooth out the texture. This should be off for pixel art packs.
46     bool interpolate = true;
47 
48     /// Tile size assumed by the pack. It doesn't affect tiles themselves, but any other resource relying on
49     /// that number will use this field.
50     ///
51     /// For example, decoration sprites will be scaled depending on their size and this field
52     /// (`decoration side / metadata.tileSize`)
53     ///
54     /// Required.
55     uint tileSize;
56 
57     /// Amount of angles each multi-directional texture will provide. All angles should be placed in a single
58     /// row in the image.
59     ///
60     /// 4 angles means the textures have a separate sprite for every 90 degrees, 8 angles — 45 degrees,
61     /// and so on.
62     ///
63     /// Defaults to `4`.
64     uint angles = 4;
65 
66     /// $(TCOLON Decoration) Rectangle in the texture that will stick to the original tile.
67     ///
68     /// $(TCOLON Format) `[position x, position y, size x, size y]`. The top-left corner of each angle texture is at
69     /// `(0, 0)`.
70     ///
71     /// Defaults to a single pixel in the bottom middle part of the texture.
72     uint[4] hardArea;
73 
74     /// $(TCOLON Tiles) Amount of space that will be available to be spanned by decoration. See `decorationWeight`.
75     /// for more info.
76     ///
77     /// This is a range. A random number will be chosen in it to select the actual value.
78     ///
79     /// Defaults to `[50, 100]`.
80     uint[2] decorationSpace = [50, 100];
81 
82     /// $(TCOLON Decoration) Amount of space the decoration will use.
83     ///
84     /// Larger decoration textures should have a higher value set.
85     ///
86     /// Defaults to `20`.
87     uint decorationWeight = 20;
88 
89 }
90 
91 /// Represents a pack.
92 ///
93 /// To read a pack from a JSON file, use `getPack`.
94 struct Pack {
95 
96     /// Represents a resource along with its options.
97     ///
98     /// $(UL
99     ///     $(LI `match` — Matched resource)
100     ///     $(LI `options` — Options of the resource)
101     /// )
102     alias Resource(T) = Tuple!(
103         T,                       "match",
104         const(ResourceOptions)*, "options",
105     );
106 
107     /// Path to the pack directory in the filesystem.
108     @JSONExclude
109     string path;
110 
111     /// Name of the pack.
112     string name;
113 
114     /// Description of the pack.
115     string description;
116 
117     /// Version of the pack.
118     string packVersion;
119 
120     /// Targeted Isodi version
121     string isodiVersion;
122 
123     /// License of the pack.
124     string license;
125 
126     /// Option fields applied to specific files.
127     ///
128     /// The keys are a relative path to a resource or a directory. Options defined under an empty key affect all
129     /// resources. In JSON, you can provide that key with an `options` field instead, for readability.
130     ///
131     /// Fields missing in the JSON will be inherited from parent directories or will use the default value.
132     @JSONExclude
133     ResourceOptions[string] fileOptions;
134 
135 
136     /// Glob search within the pack.
137     string[] glob(string file) @trusted {
138 
139         import std.array : array;
140 
141         // Get paths to the resource
142         const resPath = path.buildPath(file);
143         const resDir = resPath.dirName;
144 
145         // This directory must exist
146         if (!resDir.exists || !resDir.isDir) return null;
147 
148         // List all files inside
149         return resDir.dirEntries(resPath.baseName, SpanMode.shallow).array.to!(string[]);
150 
151     }
152 
153     /// Read options of the given resource.
154     /// Params:
155     ///     res = Relative path to the resource.
156     /// Returns: A pointer to the resource's options.
157     const(ResourceOptions)* getOptions(string res) const {
158 
159         // Remove prefixes
160         res = res.chompPrefix(path).stripLeft("/");
161 
162         /// Search for the closest matching resource
163         foreach (file; res.stripRight("/").DeepAncestors) {
164 
165             // Return the first one found
166             if (auto p = file in fileOptions) return p;
167 
168         }
169 
170         assert(0, name.format!"Internal error: Root options missing for pack %s");
171 
172     }
173 
174     ///
175     unittest {
176 
177         // Load the pack
178         auto pack = getPack("res/samerion-retro/pack.json");
179 
180         // Check root options
181         const rootOptions = pack.getOptions("");
182         assert(!rootOptions.interpolate);
183         assert(rootOptions.tileSize == 32);
184 
185         // Check if getOptions correctly handles resources that don't have any options set directly
186         assert(pack.getOptions("cells/grass") is pack.getOptions("cells/grass/not-existing"));
187 
188     }
189 
190     /// Get a skeleton from this pack
191     /// Params:
192     ///     name = Name of the skeleton to load.
193     /// Returns: A `Resource` tuple, first item is a list of nodes in the skeleton.
194     /// Throws:
195     ///     $(UL
196     ///         $(LI `PackException` if the skeleton doesn't exist.)
197     ///         $(LI `rcdata.json.JSONException` if the skeleton isn't valid.)
198     ///     )
199     Resource!(SkeletonNode[]) getSkeleton(const string name) {
200 
201         // Get the path
202         const path = path.buildPath(name.format!"models/skeleton/%s.json");
203 
204         // Check if the file exists
205         enforce!PackException(path.exists, format!"Skeleton %s wasn't found in pack %s"(name, this.name));
206 
207         // Read the file
208         auto json = JSONParser(path.readText);
209 
210         return Resource!(SkeletonNode[])(
211             getSkeletonImpl(json, name, 0, 0),
212             getOptions(path),
213         );
214 
215     }
216 
217     private SkeletonNode[] getSkeletonImpl(ref JSONParser json, const string name, const size_t parent,
218         const size_t id) @trusted {
219 
220         // Get the nodes
221         SkeletonNode[] children;
222         auto root = json.getStruct!SkeletonNode((ref obj, key) {
223 
224             // Check the key — note most of the keys are handled automatically
225             switch (key) {
226 
227                 case "display":  // possibly deprecated, "hidden" is preferred
228                     obj.hidden = !json.getBoolean;
229                     break;
230 
231                 // Children nodes
232                 case "nodes":
233 
234                     // Check each node
235                     foreach (index; json.getArray) {
236 
237                         children ~= getSkeletonImpl(json, name, id, id + children.length + 1);
238 
239                     }
240                     break;
241 
242                 default:
243                     throw new JSONException(
244                         format!"Unknown field '%s' (skeleton '%s/%s')"(key, this.name, name)
245                     );
246 
247             }
248 
249         });
250 
251         // Assign a parent
252         root.parent = parent;
253 
254         // Set a default ID
255         if (root.id == "") {
256 
257             root.id = root.name;
258 
259         }
260 
261         return [root] ~ children;
262 
263     }
264 
265     /// Get an animation from this pack. Used variant will be chosen randomly.
266     /// Params:
267     ///     name       = Name of the animation to load.
268     ///     frameCount = `out` parameter filled with frame count of the animation.
269     /// Returns: A `Resource` tuple, first item is a list of animation parts.
270     /// Throws:
271     ///     $(UL
272     ///         $(LI `PackException` if the animation doesn't exist.)
273     ///         $(LI `rcdata.json.JSONException` if the animation isn't valid.)
274     ///     )
275     Resource!(AnimationPart[]) getAnimation(const string name, out uint frameCount) @trusted {
276 
277         import std.array : array;
278         import std.algorithm : map;
279 
280         // Search for the animation
281         auto matches = glob(name.format!"models/animation/%s/*.json");
282 
283         enforce!PackException(matches.length, format!"Animation %s wasn't found in pack %s"(name, this.name));
284 
285         // Pick a random match
286         const animation = matches.choice;
287 
288         // Read the JSON
289         auto json = JSONParser(animation.readText);
290         auto partsMap = json.getArray.map!(index => getAnimationPart(json, name, frameCount));
291 
292         // .array method wrongly issues a deprecation warning of Nullable property of the map. This is the workaround.
293         AnimationPart[] parts;
294         foreach (part; partsMap) parts ~= part;
295 
296         return Resource!(AnimationPart[])(parts, getOptions(animation));
297 
298     }
299 
300     private AnimationPart getAnimationPart(ref JSONParser json, const string name, ref uint frameCount) @trusted {
301 
302         AnimationPart result;
303 
304         foreach (key; json.getObject) {
305 
306             switch (key) {
307 
308                 case "length":
309                     result.length = json.get!uint;
310                     break;
311 
312                 case "offset":
313                     result.offset = getAnimationProperty!(float[3])(json);
314                     break;
315 
316                 default:
317                     result.bone[key.to!string] = getAnimationBone(json, name);
318 
319             }
320 
321         }
322 
323         frameCount += result.length;
324 
325         return result;
326 
327     }
328 
329     private AnimationBone getAnimationBone(ref JSONParser json, const string name) @trusted {
330 
331         AnimationBone result;
332 
333         foreach (key; json.getObject) {
334 
335             // Too small for automation
336             switch (key) {
337 
338                 case "rotate":
339                     result.rotate = getAnimationProperty!(float[3])(json);
340                     break;
341 
342                 case "scale":
343                     result.scale = getAnimationProperty!float(json);
344                     break;
345 
346                 default:
347                     throw new JSONException(
348                         format!"Unknown property %s (animation '%s/%s')"(key, this.name, name)
349                     );
350 
351             }
352 
353         }
354 
355         return result;
356 
357     }
358 
359     private T getAnimationProperty(T : Value[N], Value, size_t N)(ref JSONParser json) @trusted {
360 
361         // Get the same value with one value more
362         auto value = json.get!(Value[N+1]);
363         return value[1..$];
364 
365     }
366 
367     private T getAnimationProperty(alias T)(ref JSONParser json) @trusted {
368 
369         return json.get!(T[2])[1];
370     }
371 
372     /// List cells available in the pack.
373     /// Returns: A range with all cells that can be found in the pack.
374     auto listCells() const @trusted {
375 
376         // Return all directories within "cells/"
377         return path.buildPath("cells")
378             .dirEntries(SpanMode.shallow)
379             .filter!((string name) => name.isDir)
380             .map!baseName;
381 
382     }
383 
384     @trusted unittest {
385 
386         // Load the pack
387         auto pack = getPack("res/samerion-retro/pack.json");
388         assert(pack.listCells.canFind("grass"));
389 
390     }
391 
392 }