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 }