declare module WorldSeedr { export interface IWorldSeedrSettings { /** * A very large listing of possibility schemas, keyed by title. */ possibilities: IPossibilityContainer; /** * Function used to generate a random number, if not Math.random. */ random?: () => number; /** * Function called in this.generateFull to place a child. */ onPlacement?: (commands: ICommand[]) => void; } export interface IPossibilityContainer { [i: string]: IPossibility; } export interface IPossibility { "width": number; "height": number; "contents": IPossibilityContents; } export interface IPossibilityContents { "direction": string; "mode": string; "snap": string; "children": IPossibilityChild[]; "spacing"?: number | number[]| IPossibilitySpacing; "limit"?: number; "argumentMap"?: IArgumentMap } export interface IPercentageOption { "percent": number; } export interface IPossibilityChild extends IPercentageOption { "title": string; "type": string; "arguments"?: IArgumentPossibilities[]| any; "source"?: string; "sizing"?: { "width"?: number; "height"?: number; }; "stretch"?: { "width"?: number; "height"?: number; }; } export interface IPossibilitySpacing { "min": number; "max": number; "units"?: number; } export interface IPossibilitySpacingOption extends IPercentageOption { "value": IPossibilitySpacing; } export interface IArgumentPossibilities { [i: number]: IArgumentPossibility } export interface IArgumentPossibility extends IPercentageOption { values: any[]; } export interface IArgumentMap { [i: string]: string; } export interface IDirectionsMap { "top": string; "right": string; "bottom": string; "left": string; } export interface IPosition { "width": number; "height": number; "top": number; "right": number; "bottom": number; "left": number; "type"?: string; } export interface ICommand extends IPosition { "title": string; "arguments"?: IArgumentPossibilities | any; } export interface IChoice extends ICommand { "contents"?: IChoice; "children"?: IChoice[]; } export interface IWorldSeedr { getPossibilities(): IPossibilityContainer; setPossibilities(possibilities: IPossibilityContainer): void; getOnPlacement(): (commands: ICommand[]) => void; setOnPlacement(onPlacement: (commands: ICommand[]) => void): void; clearGeneratedCommands(): void; runGeneratedCommands(): void; generate(name: string, command: IPosition | ICommand): IChoice; generateFull(schema: ICommand): void; } } module WorldSeedr { "use strict"; /** * A randomization utility to automate random, recursive generation of * possibilities based on a preset position and probability schema. Each * "possibility" in the schema contains a width, height, and instructions on * what type of contents it contains, which are either a preset listing or * a randomization of other possibilities of certain probabilities. Additional * functionality is provided to stagger layout of children, such as spacing * between possibilities. */ export class WorldSeedr { /** * A very large listing of possibility schemas, keyed by title. */ private possibilities: IPossibilityContainer; /** * Function used to generate a random number */ private random: () => number; /** * Function called in generateFull to place a command. */ private onPlacement: (commands: ICommand[]) => void; /** * Scratch Array of PreThings to be added to during generation. */ private generatedCommands: ICommand[]; /** * A constant listing of direction opposites, like top-bottom. */ private directionOpposites: IDirectionsMap = { "top": "bottom", "right": "left", "bottom": "top", "left": "right" }; /** * A constant listing of what direction the sides of areas correspond to. */ private directionSizing: IDirectionsMap = { "top": "height", "right": "width", "bottom": "height", "left": "width" }; /** * A constant Array of direction names. */ private directionNames: string[] = ["top", "right", "bottom", "left"]; /** * A constant Array of the dimension descriptors. */ private sizingNames: string[] = ["width", "height"]; /** * @param {IWorldSeedrSettings} settings */ constructor(settings: IWorldSeedrSettings) { if (typeof settings.possibilities === "undefined") { throw new Error("No possibilities given to WorldSeedr."); } this.possibilities = settings.possibilities; this.random = settings.random || Math.random.bind(Math); this.onPlacement = settings.onPlacement || console.log.bind(console, "Got:"); this.clearGeneratedCommands(); } /* Simple gets & sets */ /** * @return {Object} The listing of possibilities that may be generated. */ getPossibilities(): IPossibilityContainer { return this.possibilities; } /** * @param {Object} possibilitiesNew A new Object to list possibilities * that may be generated. */ setPossibilities(possibilities: IPossibilityContainer): void { this.possibilities = possibilities; } /** * @return {Function} The Function callback for generated possibilities of * type "known" to be called in runGeneratedCommands. */ getOnPlacement(): (commands: ICommand[]) => void { return this.onPlacement; } /** * @param {Function} onPlacementNew A new Function to be used as the * onPlacement callback. */ setOnPlacement(onPlacement: (commands: ICommand[]) => void): void { this.onPlacement = onPlacement; } /* Generated commands */ /** * Resets the generatedCommands Array so runGeneratedCommands can start. */ clearGeneratedCommands(): void { this.generatedCommands = []; } /** * Runs the onPlacement callback on the generatedCommands Array. */ runGeneratedCommands(): void { this.onPlacement(this.generatedCommands); } /* Hardcore generation functions */ /** * Generates a collection of randomly chosen possibilities based on the * given schema mapping. These does not recursively parse the output; do * do that, use generateFull. * * @param {String} name The name of the possibility schema to start from. * @param {Object} position An Object that contains .left, .right, .top, * and .bottom. * @return {Object} An Object containing a position within the given * position and some number of children. */ generate(name: string, command: IPosition | ICommand): IChoice { var schema: IPossibility = this.possibilities[name]; if (!schema) { throw new Error("No possibility exists under '" + name + "'"); } if (!schema.contents) { throw new Error("Possibility '" + name + "' has no possibile outcomes."); } return this.generateChildren(schema, this.objectCopy(command)); } /** * Recursively generates a schema. The schema's title and itself are given * to this.generate; all outputs of type "Known" are added to the * generatedCommands Array, while everything else is recursed upon. * * @param {Object} schema A simple Object with basic information on the * chosen possibility. * @return {Object} An Object containing a position within the given * position and some number of children. */ generateFull(schema: ICommand): void { var generated: IChoice = this.generate(schema.title, schema), child: IChoice, i: number; if (!generated || !generated.children) { return; } for (i = 0; i < generated.children.length; i += 1) { child = generated.children[i]; switch (child.type) { case "Known": this.generatedCommands.push(child); break; case "Random": this.generateFull(child); break; default: throw new Error("Unknown child type: " + child.type); } } } /** * Generates the children for a given schema, position, and direction. This * is the real hardcore function called by this.generate, which calls the * differnt subroutines based on whether the contents are in "Certain" or * "Random" mode. * * @param {Object} schema A simple Object with basic information on the * chosen possibility. * @param {Object} position The bounding box for where the children may * be generated. * @param {String} [direction] A string direction to check the position * by ("top", "right", "bottom", or "left") * as a default if contents.direction isn't * provided. * @return {Object} An Object containing a position within the given * position and some number of children. */ private generateChildren(schema: IPossibility, position: IPosition, direction: string = undefined): IChoice { var contents: IPossibilityContents = schema.contents, spacing: number | number[]| IPossibilitySpacing = contents.spacing || 0, objectMerged: IPosition = this.objectMerge(schema, position), children: IChoice[]; direction = contents.direction || direction; switch (contents.mode) { case "Random": children = this.generateChildrenRandom(contents, objectMerged, direction, spacing); break; case "Certain": children = this.generateChildrenCertain(contents, objectMerged, direction, spacing); break; case "Repeat": children = this.generateChildrenRepeat(contents, objectMerged, direction, spacing); break; case "Multiple": children = this.generateChildrenMultiple(contents, objectMerged, direction, spacing); break; default: throw new Error("Unknown contents mode: " + contents.mode); } return this.wrapChoicePositionExtremes(children); } /** * Generates a schema's children that are known to follow a set listing of * sub-schemas. * * @param {Object} contents The known possibilities to choose between. * @param {Object} position The bounding box for where the children may * be generated. * @param {String} direction A string direction to check the position by: * "top", "right", "bottom", or "left". * @param {Number} spacing How much space there should be between each * child. * @return {Object} An Object containing a position within the given * position and some number of children. */ private generateChildrenCertain( contents: IPossibilityContents, position: IPosition, direction: string, spacing: number | number[]| IPossibilitySpacing): IChoice[] { var scope: WorldSeedr = this; return contents.children.map(function (choice: IPossibilityChild): IChoice { if (choice.type === "Final") { return scope.parseChoiceFinal(contents, choice, position, direction); } var output: IChoice = scope.parseChoice(choice, position, direction); if (output) { if (output.type !== "Known") { output.contents = scope.generate(output.title, position); } scope.shrinkPositionByChild(position, output, direction, spacing); } return output; }).filter(function (child: IChoice): boolean { return child !== undefined; }); } /** * Generates a schema's children that are known to follow a set listing of * sub-schemas, repeated until there is no space left. * * @param {Object} contents The known possibilities to choose between. * @param {Object} position The bounding box for where the children may * be generated. * @param {String} direction A string direction to check the position by: * "top", "right", "bottom", or "left". * @param {Number} spacing How much space there should be between each * child. * @return {Object} An Object containing a position within the given * position and some number of children. */ private generateChildrenRepeat( contents: IPossibilityContents, position: IPosition, direction: string, spacing: number | number[]| IPossibilitySpacing): IChoice[] { var choices: IPossibilityChild[] = contents.children, children: IChoice[] = [], choice: IPossibilityChild, child: IChoice, i: number = 0; // Continuously loops through the choices and adds them to the output // children, so long as there's still room for them while (this.positionIsNotEmpty(position, direction)) { choice = choices[i]; if (choice.type === "Final") { child = this.parseChoiceFinal(contents, choice, position, direction); } else { child = this.parseChoice(choice, position, direction); if (child) { if (child.type !== "Known") { child.contents = this.generate(child.title, position); } } } if (child && this.choiceFitsPosition(child, position)) { this.shrinkPositionByChild(position, child, direction, spacing); children.push(child); } else { break; } i += 1; if (i >= choices.length) { i = 0; } } return children; } /** * Generates a schema's children that are known to be randomly chosen from a * list of possibilities until there is no more room. * * @param {Object} contents The Array of known possibilities, with * probability percentages. * @param {Object} position An Object that contains .left, .right, .top, * and .bottom. * @param {String} direction A string direction to check the position by: * "top", "right", "bottom", or "left". * @param {Number} spacing How much space there should be between each * child. * @return {Object} An Object containing a position within the given * position and some number of children. */ private generateChildrenRandom( contents: IPossibilityContents, position: IPosition, direction: string, spacing: number | number[]| IPossibilitySpacing): IChoice[] { var children: IChoice[] = [], child: IChoice; // Continuously add random choices to the output children as long as // there's room in the position's bounding box while (this.positionIsNotEmpty(position, direction)) { child = this.generateChild(contents, position, direction); if (!child) { break; } this.shrinkPositionByChild(position, child, direction, spacing); children.push(child); if (contents.limit && children.length > contents.limit) { return; } } return children; } /** * Generates a schema's children that are all to be placed within the same * position. If a direction is provided, each subsequent one is shifted in * that direction by spacing. * * @param {Object} contents The Array of known possibilities, with * probability percentages. * @param {Object} position An Object that contains .left, .right, .top, * and .bottom. * @param {String} [direction] A string direction to check the position by: * "top", "right", "bottom", or "left". * @param {Number} [spacing] How much space there should be between each * child. * @return {Object} An Object containing a position within the given * position and some number of children. */ private generateChildrenMultiple( contents: IPossibilityContents, position: IPosition, direction: string, spacing: number | number[]| IPossibilitySpacing): IChoice[] { var scope: WorldSeedr = this; return contents.children.map(function (choice: IPossibilityChild): IChoice { var output: IChoice = scope.parseChoice(choice, scope.objectCopy(position), direction); if (direction) { (scope).movePositionBySpacing(position, direction, spacing); } return output; }); } /* Choice parsing */ /** * Shortcut function to choose a choice from an allowed set of choices, and * parse it for positioning and sub-choices. * * @param {Object} contents An Array of choice Objects, each of which must * have a .percentage. * @param {Object} position An Object that contains .left, .right, .top, * and .bottom. * @param {String} direction A string direction to check the position by: * "top", "right", "bottom", or "left". * @return {Object} An Object containing the bounding box position of a * parsed child, with the basic schema (.title) info * added as well as any optional .arguments. */ private generateChild(contents: IPossibilityContents, position: IPosition, direction: string): IChoice { var choice: IPossibilityChild = this.chooseAmongPosition(contents.children, position); if (!choice) { return undefined; } return this.parseChoice(choice, position, direction); } /** * Creates a parsed version of a choice given the position and direction. * This is the function that parses and manipulates the positioning of the * new choice. * * @param {Object} choice The simple definition of the Object chosen from * a choices array. It should have at least .title, * and optionally .sizing or .arguments. * @param {Object} position An Object that contains .left, .right, .top, * and .bottom. * @param {String} direction A string direction to shrink the position by: * "top", "right", "bottom", or "left". * @return {Object} An Object containing the bounding box position of a * parsed child, with the basic schema (.title) info * added as well as any optional .arguments. */ private parseChoice(choice: IPossibilityChild, position: IPosition, direction: string): IChoice { var title: string = choice.title, schema: IPossibility = this.possibilities[title], output: IChoice = { "title": title, "type": choice.type, "arguments": choice.arguments instanceof Array ? ((this.chooseAmong(choice.arguments))).values : choice.arguments, "width": undefined, "height": undefined, "top": undefined, "right": undefined, "bottom": undefined, "left": undefined }; this.ensureSizingOnChoice(output, choice, schema); this.ensureDirectionBoundsOnChoice(output, position); output[direction] = output[this.directionOpposites[direction]] + output[this.directionSizing[direction]]; switch (schema.contents.snap) { case "top": output.bottom = output.top - output.height; break; case "right": output.left = output.right - output.width; break; case "bottom": output.top = output.bottom + output.height; break; case "left": output.right = output.left + output.width; break; default: break; } if (choice.stretch) { if (!output.arguments) { output.arguments = {}; } if (choice.stretch.width) { output.left = position.left; output.right = position.right; output.width = output.right - output.left; output.arguments.width = output.width; } if (choice.stretch.height) { output.top = position.top; output.bottom = position.bottom; output.height = output.top - output.bottom; output.arguments.height = output.height; } } this.copySchemaArguments(schema, choice, output); return output; } /** * should conform to parent (contents) via cannonsmall.snap=bottom */ private parseChoiceFinal(parent: IPossibilityContents, choice: IPossibilityChild, position: IPosition, direction: string): IChoice { var schema: IPossibility = this.possibilities[choice.source], output: IChoice = { "type": "Known", "title": choice.title, "arguments": choice.arguments, "width": schema.width, "height": schema.height, "top": position.top, "right": position.right, "bottom": position.bottom, "left": position.left }; this.copySchemaArguments(schema, choice, output); return output; } /* Randomization utilities */ /** * From an Array of potential choice objects, returns one chosen at random. * * @param {Array} choice An Array of objects with .width and .height. * @return {Object} */ private chooseAmong(choices: t[]): t { if (!choices.length) { return undefined; } if (choices.length === 1) { return choices[0]; } var choice: number = this.randomPercentage(), sum: number = 0, i: number; for (i = 0; i < choices.length; i += 1) { sum += choices[i].percent; if (sum >= choice) { return choices[i]; } } } /** * From an Array of potential choice objects, filtered to only include those * within a certain size, returns one chosen at random. * * @param {Array} choice An Array of objects with .width and .height. * @param {Object} position An Object that contains .left, .right, .top, * and .bottom. * @return {Object} * @remarks Functions that use this will have to react to nothing being * chosen. For example, if only 50 percentage is accumulated * among fitting ones but 75 is randomly chosen, something should * still be returned. */ private chooseAmongPosition(choices: IPossibilityChild[], position: IPosition): IPossibilityChild { var width: number = position.right - position.left, height: number = position.top - position.bottom, scope: WorldSeedr = this; return this.chooseAmong(choices.filter(function (choice: IPossibilityChild): boolean { return scope.choiceFits(scope.possibilities[choice.title], width, height); })); } /** * Checks whether a choice can fit within a width and height. * * @param {Object} choice An Object that contains .width and .height. * @param {Number} width * @param {Number} height * @return {Boolean} Whether the choice fits within the position. */ private choiceFits(choice: IPossibility | IChoice, width: number, height: number): boolean { return choice.width <= width && choice.height <= height; } /** * Checks whether a choice can fit within a position. * * @param {Object} choice An Object that contains .width and .height. * @param {Object} position An Object that contains .left, .right, .top, * and .bottom. * @return {Boolean} The boolean equivalent of the choice fits * within the position. * @remarks When calling multiple times on a position (such as in * chooseAmongPosition), it's more efficient to store the width * and height separately and just use doesChoiceFit. */ private choiceFitsPosition(choice: IPossibility | IChoice, position: IPosition): boolean { return this.choiceFits(choice, position.right - position.left, position.top - position.bottom); } /** * @return {Number} A number in [1, 100] at random. */ private randomPercentage(): number { return Math.floor(this.random() * 100) + 1; } /** * @return {Number} A number in [min, max] at random. */ private randomBetween(min: number, max: number): number { return Math.floor(this.random() * (1 + max - min)) + min; } /* Position manipulation utilities */ /** * Creates and returns a copy of a position (really just a shallow copy). * * @param {Object} original * @return {Object} */ private objectCopy(original: any): any { var output: any = {}, i: string; for (i in original) { if (original.hasOwnProperty(i)) { output[i] = original[i]; } } return output; } /** * Creates a new position with all required attributes taking from the * primary source or secondary source, in that order. * * @param {Object} primary * @param {Object} secondary * @return {Object} */ private objectMerge(primary: any, secondary: any): any { var output: IPosition = this.objectCopy(primary), i: string; for (i in secondary) { if (secondary.hasOwnProperty(i) && !output.hasOwnProperty(i)) { output[i] = secondary[i]; } } return output; } /** * Checks and returns whether a position has open room in a particular * direction (horizontally for left/right and vertically for top/bottom). * * @param {Object} position An Object that contains .left, .right, .top, * and .bottom. * @param {String} direction A string direction to check the position in: * "top", "right", "bottom", or "left". */ private positionIsNotEmpty(position: IPosition, direction: string): boolean { if (direction === "right" || direction === "left") { return position.left < position.right; } else { return position.top > position.bottom; } } /** * Shrinks a position by the size of a child, in a particular direction. * * @param {Object} position An Object that contains .left, .right, .top, * and .bottom. * @param {Object} child An Object that contains .left, .right, .top, and * .bottom. * @param {String} direction A string direction to shrink the position by: * "top", "right", "bottom", or "left". * @param {Mixed} [spacing] How much space there should be between each * child (by default, 0). */ private shrinkPositionByChild( position: IPosition, child: IChoice, direction: string, spacing: number | number[]| IPossibilitySpacing | IPossibilitySpacingOption[]): void { switch (direction) { case "top": position.bottom = child.top + this.parseSpacing(spacing); break; case "right": position.left = child.right + this.parseSpacing(spacing); break; case "bottom": position.top = child.bottom - this.parseSpacing(spacing); break; case "left": position.right = child.left - this.parseSpacing(spacing); break; default: break; } } /** * Moves a position by its parsed spacing. This is only useful for content * of type "Multiple", which are allowed to move themselves via spacing * between placements. * * @param {Object} position An Object that contains .left, .right, .top, * and .bottom. * @param {String} direction A string direction to shrink the position by: * "top", "right", "bottom", or "left". * @param {Mixed} [spacing] How much space there should be between each * child (by default, 0). */ private movePositionBySpacing( position: IPosition, direction: string, spacing: number | number[]| IPossibilitySpacing | IPossibilitySpacingOption[] = 0): void { var space: number = this.parseSpacing(spacing); switch (direction) { case "top": position.top += space; position.bottom += space; break; case "right": position.left += space; position.right += space; break; case "bottom": position.top -= space; position.bottom -= space; break; case "left": position.left -= space; position.right -= space; break; default: throw new Error("Unknown direction: " + direction); } } /** * Recursively parses a spacing parameter to eventually return a Number, * which will likely be random. * * @param {Mixed} spacing This may be a Number (returned directly), an * Object[] containing choices for chooseAmong, a * Number[] containing minimum and maximum values, * or an Object containing "min", "max", and * "units" to round to. * @return {Number} */ private parseSpacing(spacing: number | number[]| IPossibilitySpacing | IPossibilitySpacingOption[]): number { if (!spacing) { return 0; } switch (spacing.constructor) { case Array: // Case: [min, max] if ((spacing)[0].constructor === Number) { return this.parseSpacingObject(this.randomBetween((spacing)[0], (spacing)[1])); } // Case: IPossibilitySpacingOption[] return this.parseSpacingObject(this.chooseAmong(spacing).value); case Object: // Case: IPossibilitySpacing return this.parseSpacingObject(spacing); default: // Case: Number return spacing; } } /** * Helper to parse a spacing Object. The minimum and maximum ("min" and * "max", respectively) are the range, and an optional "units" parameter * is what Number it should round to. * * @param {Object} spacing * @return {Number} */ private parseSpacingObject(spacing: number | IPossibilitySpacing): number { if (spacing.constructor === Number) { return spacing; } var min: number = (spacing).min, max: number = (spacing).max, units: number = (spacing).units || 1; return this.randomBetween(min / units, max / units) * units; } /** * Generates the bounding box position Object (think rectangle) for a set of * children. The top, right, etc. member variables become the most extreme * out of all the possibilities. * * @param {Object} children An Array of Objects with .top, .right, * .bottom, and .left. * @return {Object} An Object with .top, .right, .bottom, and .left. */ private wrapChoicePositionExtremes(children: IChoice[]): IChoice { var position: IChoice, child: IChoice, i: number; if (!children || !children.length) { return undefined; } child = children[0]; position = { "title": undefined, "top": child.top, "right": child.right, "bottom": child.bottom, "left": child.left, "width": undefined, "height": undefined, "children": children }; if (children.length === 1) { return position; } for (i = 1; i < children.length; i += 1) { child = children[i]; if (!Object.keys(child).length) { return position; } position.top = Math.max(position.top, child.top); position.right = Math.max(position.right, child.right); position.bottom = Math.min(position.bottom, child.bottom); position.left = Math.min(position.left, child.left); } position.width = position.right - position.left; position.height = position.top - position.bottom; return position; } /** * Copies settings from a parsed choice to its arguments. What settings to * copy over are determined by the schema's content's argumentMap attribute. * * @param {Object} schema A simple Object with basic information on the * chosen possibility. * @param {Object} choice The simple definition of the Object chosen from * a choices array. * @param {Object} output The Object (likely a parsed possibility content) * having its arguments modified. */ private copySchemaArguments(schema: IPossibility, choice: IPossibilityChild, output: IChoice): void { var map: IArgumentMap = schema.contents.argumentMap, i: string; if (!map) { return; } if (!output.arguments) { output.arguments = {}; } for (i in map) { if (map.hasOwnProperty(i)) { output.arguments[map[i]] = choice[i]; } } } /** * Ensures an output from parseChoice contains all the necessary size * measurements, as listed in this.sizingNames. * * @param {Object} output The Object (likely a parsed possibility content) * having its arguments modified. * @param {Object} choice The simple definition of the Object chosen from * a choices array. * @param {Object} schema A simple Object with basic information on the * chosen possibility. */ private ensureSizingOnChoice(output: IChoice, choice: IPossibilityChild, schema: IPossibility): void { var name: string, i: string; for (i in this.sizingNames) { if (!this.sizingNames.hasOwnProperty(i)) { continue; } name = this.sizingNames[i]; output[name] = (choice.sizing && typeof choice.sizing[name] !== "undefined") ? choice.sizing[name] : schema[name]; } } /** * Ensures an output from parseChoice contains all the necessary position * bounding box measurements, as listed in this.directionNames. * * @param {Object} output The Object (likely a parsed possibility content) * having its arguments modified. * chosen possibility. * @param {Object} position An Object that contains .left, .right, .top, * and .bottom. */ private ensureDirectionBoundsOnChoice(output: IChoice, position: IPosition): void { var i: string; for (i in this.directionNames) { if (this.directionNames.hasOwnProperty(i)) { output[this.directionNames[i]] = position[this.directionNames[i]]; } } } } }