mirror of
https://github.com/FullScreenShenanigans/EightBittr.git
synced 2026-08-12 02:18:15 -07:00
feat: revamped WorldSeedr (#341)
## Overview Drastically simplifies the WorldSeedr package by reducing the different kinds of variance allowed. Adds unit tests too. Creates a second example, InfiniteContributionsCalendar, to demonstrate WorldSeedr usage. ### PR Checklist - [x] Fixes #182 - [x] I have run this code to verify it works - [x] This PR includes unit tests for the code change
This commit is contained in:
+4
-1
@@ -29,7 +29,10 @@
|
||||
],
|
||||
|
||||
// Off-by-default rules we want on
|
||||
"simple-import-sort/imports": "error"
|
||||
"simple-import-sort/imports": "error",
|
||||
|
||||
// On-by-default rules we want off
|
||||
"no-case-declarations": "off"
|
||||
},
|
||||
"root": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
dist/
|
||||
lib/
|
||||
test/
|
||||
webpack.config.js
|
||||
@@ -0,0 +1,2 @@
|
||||
*.tsbuildinfo
|
||||
node_modules/
|
||||
@@ -0,0 +1,39 @@
|
||||
<!-- Top -->
|
||||
|
||||
# InfiniteContributionsCalendar
|
||||
|
||||
[](https://prettier.io)
|
||||

|
||||
[](http://badge.fury.io/js/infinite-contributions-calendar)
|
||||
[](https://gitter.im/FullScreenShenanigans/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
||||
|
||||
Generates an infinite GitHub-like contributions calendar using EightBittr and WorldSeedr.
|
||||
|
||||
<!-- /Top -->
|
||||
|
||||
## Usage
|
||||
|
||||
Documentation coming soon™️!
|
||||
|
||||
<!-- Development -->
|
||||
|
||||
## Development
|
||||
|
||||
This repository is a portion of the [EightBittr monorepo](https://raw.githubusercontent.com/FullScreenShenanigans/EightBittr).
|
||||
See its [docs/Development.md](../../docs/Development.md) for details on how to get started. 💖
|
||||
|
||||
### Running Tests
|
||||
|
||||
```shell
|
||||
yarn run test
|
||||
```
|
||||
|
||||
Tests are written in [Mocha](https://github.com/mochajs/mocha) and [Chai](https://github.com/chaijs/chai).
|
||||
Their files are written using alongside source files under `src/` and named `*.test.ts?`.
|
||||
Whenever you add, remove, or rename a `*.test.t*` file under `src/`, `watch` will re-run `yarn run test:setup` to regenerate the list of static test files in `test/index.html`.
|
||||
You can open that file in a browser to debug through the tests, or run `yarn test:run` to run them in headless Chrome.
|
||||
|
||||
<!-- Maps -->
|
||||
<!-- /Maps -->
|
||||
|
||||
<!-- /Development -->
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"browser": "./lib/index.js",
|
||||
"bugs": {
|
||||
"url": "https://github.com/FullScreenShenanigans/EightBittr/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"eightbittr": "^0.8.8",
|
||||
"userwrappr": "^0.8.8",
|
||||
"worldseedr": "^0.8.8"
|
||||
},
|
||||
"description": "Generates an infinite GitHub-like contributions calendar using EightBittr and WorldSeedr.",
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.3.3",
|
||||
"@types/mocha": "^9.1.1",
|
||||
"@types/sinon": "^10.0.13",
|
||||
"@types/sinon-chai": "^3.2.8",
|
||||
"chai": "^4.3.6",
|
||||
"husky": "^8.0.1",
|
||||
"lint-staged": "^13.0.3",
|
||||
"mocha": "^10.0.0",
|
||||
"mocha-headless-chrome": "^4.0.0",
|
||||
"preact": "^10.10.2",
|
||||
"requirejs": "^2.3.6",
|
||||
"shenanigans-manager": "^0.8.8",
|
||||
"sinon": "^14.0.0",
|
||||
"sinon-chai": "^3.7.0",
|
||||
"ts-loader": "^9.3.1",
|
||||
"webpack": "^5.74.0",
|
||||
"webpack-cli": "^4.10.0"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "lint-staged"
|
||||
}
|
||||
},
|
||||
"license": "MIT",
|
||||
"lint-staged": {
|
||||
"**/*.{css,js,json,md,ts,tsx}": [
|
||||
"prettier --ignore-path .prettierignore --write"
|
||||
]
|
||||
},
|
||||
"name": "infinite-contributions-calendar",
|
||||
"package": {
|
||||
"type": "git",
|
||||
"url": "ssh://git@github.com:FullScreenShenanigans/EightBittr.git"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist lib *.tsbuildinfo",
|
||||
"compile": "tsc -b",
|
||||
"dist": "webpack",
|
||||
"format": "prettier --ignore-path .prettierignore --check \"./**/*.{css,js,json,md,ts,tsx}\"",
|
||||
"format:write": "yarn format --write",
|
||||
"hydrate": "yarn shenanigans-manager hydrate",
|
||||
"link": "yarn link",
|
||||
"lint": "eslint --ignore-path .eslintignore \"./src/**/*.{ts,tsx}\"",
|
||||
"prepare": "husky install",
|
||||
"publish:ci": "yarn shenanigans-manager publish-if-updated",
|
||||
"test": "yarn run test:setup && yarn run test:run",
|
||||
"test:run": "yarn mocha-headless-chrome --file test/index.html --polling 1000",
|
||||
"test:setup": "yarn shenanigans-manager generate-tests"
|
||||
},
|
||||
"shenanigans": {
|
||||
"dist": true,
|
||||
"example": true,
|
||||
"game": true,
|
||||
"loading": {
|
||||
"externals": [
|
||||
{
|
||||
"js": {
|
||||
"dev": "preact/dist/preact.umd",
|
||||
"prod": "preact/dist/preact.umd"
|
||||
},
|
||||
"name": "preact"
|
||||
},
|
||||
{
|
||||
"js": {
|
||||
"dev": "preact/hooks/dist/hooks.umd",
|
||||
"prod": "preact/hooks/dist/hooks.umd"
|
||||
},
|
||||
"name": "preact/hooks"
|
||||
},
|
||||
{
|
||||
"js": {
|
||||
"dev": "preact/jsx-runtime/dist/jsxRuntime.umd",
|
||||
"prod": "preact/jsx-runtime/dist/jsxRuntime.umd"
|
||||
},
|
||||
"name": "preact/jsx-runtime"
|
||||
},
|
||||
{
|
||||
"js": {
|
||||
"dev": "userwrappr/dist/UserWrappr-Delayed",
|
||||
"prod": "userwrappr/dist/UserWrappr-Delayed"
|
||||
},
|
||||
"name": "UserWrappr-Delayed",
|
||||
"shenanigansPackage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "InfiniteContributionsCalendar",
|
||||
"web": true
|
||||
},
|
||||
"types": "./lib/index.d.ts",
|
||||
"version": "0.8.14"
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { factory, member } from "autofieldr";
|
||||
import { Actor, EightBittr, EightBittrConstructorSettings } from "eightbittr";
|
||||
import { GroupHoldr } from "groupholdr";
|
||||
import { WorldSeedr } from "worldseedr";
|
||||
|
||||
import { createWorldSeeder } from "./creators/createWorldSeeder";
|
||||
import { Graphics } from "./sections/Graphics";
|
||||
import { Groups } from "./sections/Groups";
|
||||
import { Objects } from "./sections/Objects";
|
||||
|
||||
/**
|
||||
* Generates an infinite GitHub-like contributions calendar using EightBittr and WorldSeedr.
|
||||
*/
|
||||
export class InfiniteContributionsCalendar extends EightBittr {
|
||||
/**
|
||||
* Stores the visual appearance of Actors.
|
||||
*/
|
||||
@member(Graphics)
|
||||
public readonly graphics: Graphics<this>;
|
||||
|
||||
/**
|
||||
* Collection settings for Actor group names.
|
||||
*/
|
||||
@member(Groups)
|
||||
public readonly groups: Groups<this>;
|
||||
|
||||
/**
|
||||
* General storage abstraction for keyed containers of items.
|
||||
*/
|
||||
public readonly groupHolder: GroupHoldr<{
|
||||
Squares: Actor;
|
||||
Players: Actor;
|
||||
Text: Actor;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Raw ObjectMakr factory settings.
|
||||
*/
|
||||
@member(Objects)
|
||||
public readonly objects: Objects<this>;
|
||||
|
||||
/**
|
||||
* Schema-driven pseudorandom recursive generation of possibilities.
|
||||
*/
|
||||
@factory(createWorldSeeder)
|
||||
public readonly worldSeeder: WorldSeedr;
|
||||
|
||||
/**
|
||||
* Initializes a new instance of the InfiniteContributionsCalendar class.
|
||||
*
|
||||
* @param settings Settings to be used for initialization.
|
||||
*/
|
||||
public constructor(settings: EightBittrConstructorSettings) {
|
||||
super(settings);
|
||||
|
||||
this.quadsKeeper.resetQuadrants();
|
||||
|
||||
const height = roundUpTo(settings.height, 12);
|
||||
const width = roundUpTo(settings.width, 12);
|
||||
|
||||
const results = this.worldSeeder.generate("Area", {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: width,
|
||||
top: height,
|
||||
});
|
||||
|
||||
for (const result of results) {
|
||||
this.actors.add(result.title, result.area.left, height - result.area.top);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function roundUpTo(amount: number, rounder: number) {
|
||||
return Math.ceil(amount / rounder) * rounder;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { InfiniteContributionsCalendar } from "InfiniteContributionsCalendar";
|
||||
import { WorldSeedr } from "worldseedr";
|
||||
|
||||
export const createWorldSeeder = (game: InfiniteContributionsCalendar) =>
|
||||
new WorldSeedr({
|
||||
possibilities: {
|
||||
Area: {
|
||||
children: {
|
||||
size: {
|
||||
height: game.settings.height,
|
||||
width: 10,
|
||||
},
|
||||
title: "Column",
|
||||
type: "Possibility",
|
||||
},
|
||||
direction: "right",
|
||||
repeat: Infinity,
|
||||
size: {
|
||||
height: game.settings.height,
|
||||
width: game.settings.width,
|
||||
},
|
||||
spacing: 2,
|
||||
},
|
||||
Column: {
|
||||
children: {
|
||||
size: {
|
||||
height: 10,
|
||||
width: 10,
|
||||
},
|
||||
title: ["SquareL0", "SquareL1", "SquareL2", "SquareL3", "SquareL4"].map(
|
||||
(value) => ({
|
||||
probability: 20,
|
||||
value,
|
||||
})
|
||||
),
|
||||
type: "Result",
|
||||
},
|
||||
direction: "bottom",
|
||||
repeat: Infinity,
|
||||
size: {
|
||||
height: game.settings.height,
|
||||
width: 10,
|
||||
},
|
||||
spacing: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { UserWrappr } from "userwrappr";
|
||||
export * from "./InfiniteContributionsCalendar";
|
||||
|
||||
import { createUserWrapprSettings } from "./interface/InterfaceSettings";
|
||||
|
||||
/**
|
||||
* Creates a UserWrappr interface around a new InfiniteContributionsCalendar game.
|
||||
*
|
||||
* @param container HTML element to create within.
|
||||
*/
|
||||
export const createInfiniteContributionsCalendarInterface = async (
|
||||
container: HTMLElement
|
||||
): Promise<void> => {
|
||||
return new UserWrappr(createUserWrapprSettings()).createDisplay(container);
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
import { GameWindow } from "eightbittr";
|
||||
import { AbsoluteSizeSchema, OptionType, UserWrapprSettings } from "userwrappr";
|
||||
|
||||
import { InfiniteContributionsCalendar } from "../InfiniteContributionsCalendar";
|
||||
|
||||
/**
|
||||
* Global scope around a game, such as a DOM window.
|
||||
*/
|
||||
export interface WrappingGameWindow extends GameWindow {
|
||||
/**
|
||||
* Game instance, once this has created it.
|
||||
*/
|
||||
FSS?: InfiniteContributionsCalendar;
|
||||
}
|
||||
|
||||
export interface InterfaceSettingOverrides {
|
||||
createGame?(size: AbsoluteSizeSchema): InfiniteContributionsCalendar;
|
||||
gameWindow?: WrappingGameWindow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Friendly name of the default game size.
|
||||
*/
|
||||
const defaultSize = "Full";
|
||||
|
||||
/**
|
||||
* Sizes the game is allowed to be, keyed by friendly name.
|
||||
*/
|
||||
const sizes = {
|
||||
[defaultSize]: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates settings for an UserWrappr that will create and wrap a InfiniteContributionsCalendar instance.
|
||||
*
|
||||
* @param gameWindow Global scope around the game interface, if not the global window.
|
||||
*/
|
||||
export const createUserWrapprSettings = ({
|
||||
createGame = (size: AbsoluteSizeSchema) => new InfiniteContributionsCalendar(size),
|
||||
gameWindow = window,
|
||||
}: InterfaceSettingOverrides = {}): UserWrapprSettings => {
|
||||
/**
|
||||
* Game instance, once this has created it.
|
||||
*/
|
||||
let game: InfiniteContributionsCalendar;
|
||||
|
||||
return {
|
||||
defaultSize: sizes[defaultSize],
|
||||
createContents: (size: AbsoluteSizeSchema) => {
|
||||
gameWindow.FSS = game = createGame(size);
|
||||
game.inputs.initializeGlobalPipes(gameWindow);
|
||||
game.frameTicker.play();
|
||||
|
||||
return game.container;
|
||||
},
|
||||
menus: [
|
||||
{
|
||||
options: [
|
||||
{
|
||||
action: (): void => {
|
||||
game.utilities.takeScreenshot(
|
||||
`InfiniteContributionsCalendar ${Date.now()}`
|
||||
);
|
||||
},
|
||||
title: "Screenshot",
|
||||
type: OptionType.Action,
|
||||
},
|
||||
],
|
||||
title: "Options",
|
||||
},
|
||||
],
|
||||
styles: {
|
||||
input: {
|
||||
fontFamily: "Press Start",
|
||||
minWidth: "117px",
|
||||
padding: "3px",
|
||||
},
|
||||
inputButton: {
|
||||
background: "#ffcc33",
|
||||
fontFamily: "Press Start",
|
||||
padding: "7px 3px",
|
||||
},
|
||||
inputButtonAction: {
|
||||
padding: "11px 3px",
|
||||
width: "100%",
|
||||
},
|
||||
inputButtonBoolean: {
|
||||
padding: "7px 21px",
|
||||
},
|
||||
inputButtonOff: {
|
||||
background: "#ccaa33",
|
||||
},
|
||||
inputSelect: {
|
||||
minWidth: "35px",
|
||||
padding: "3px 0",
|
||||
},
|
||||
option: {
|
||||
alignItems: "center",
|
||||
margin: "auto",
|
||||
padding: "7px 0",
|
||||
maxWidth: "calc(100% - 14px)",
|
||||
},
|
||||
options: {
|
||||
left: "4px",
|
||||
right: "4px",
|
||||
width: "auto",
|
||||
padding: "4px 3px 21px",
|
||||
boxShadow: ["0 3px 7px black inset", "0 0 0 4px #99ccff", "0 0 14px black"].join(
|
||||
", "
|
||||
),
|
||||
background: "#005599",
|
||||
},
|
||||
optionsList: {
|
||||
margin: "0 0 21px",
|
||||
},
|
||||
menu: {
|
||||
maxWidth: "385px",
|
||||
minWidth: "280px",
|
||||
padding: "7px",
|
||||
zIndex: "1",
|
||||
},
|
||||
menusInnerArea: {
|
||||
background: "black",
|
||||
color: "white",
|
||||
fontFamily: "Press Start",
|
||||
transition: "700ms color",
|
||||
},
|
||||
menuTitle: {
|
||||
fontSize: "16px",
|
||||
},
|
||||
menuTitleButton: {
|
||||
alignItems: "center",
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "white",
|
||||
display: "flex",
|
||||
fontFamily: "Press Start",
|
||||
fontSize: "16px",
|
||||
justifyContent: "center",
|
||||
},
|
||||
menuTitleButtonFake: {
|
||||
color: "grey",
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createInfiniteContributionsCalendarInterface } from "./index";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const container = document.getElementById("game")!;
|
||||
|
||||
createInfiniteContributionsCalendarInterface(container).catch((error: Error): void => {
|
||||
console.error("An error happened while trying to instantiate InfiniteContributionsCalendar!");
|
||||
console.error("requirejs error:", error);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Graphics as EightBittrGraphics } from "eightbittr";
|
||||
import { Palette } from "pixelrendr";
|
||||
|
||||
import { InfiniteContributionsCalendar } from "../InfiniteContributionsCalendar";
|
||||
|
||||
export class Graphics<
|
||||
Game extends InfiniteContributionsCalendar
|
||||
> extends EightBittrGraphics<Game> {
|
||||
public readonly background = "white";
|
||||
|
||||
public readonly library = {
|
||||
SquareL0: "0x18,0x180,0x18,0",
|
||||
SquareL1: "0x28,0x280,0x28,0",
|
||||
SquareL2: "0x38,0x380,0x38,0",
|
||||
SquareL3: "0x48,0x480,0x48,0",
|
||||
SquareL4: "0x58,0x580,0x58,0",
|
||||
};
|
||||
|
||||
public readonly paletteDefault: Palette = [
|
||||
[0, 0, 0, 0],
|
||||
[255, 255, 255, 255],
|
||||
// --color-calendar-graph-day-L1-bg: #9be9a8;
|
||||
[155, 233, 168, 255],
|
||||
// --color-calendar-graph-day-L2-bg: #40c463;
|
||||
[64, 196, 99, 255],
|
||||
// --color-calendar-graph-day-L3-bg: #30a14e;
|
||||
[48, 161, 78, 255],
|
||||
// --color-calendar-graph-day-L4-bg: #216e39;
|
||||
[33, 110, 57, 255],
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Groups as EightBittrGroups } from "eightbittr";
|
||||
|
||||
import { InfiniteContributionsCalendar } from "../InfiniteContributionsCalendar";
|
||||
|
||||
export class Groups<Game extends InfiniteContributionsCalendar> extends EightBittrGroups<Game> {
|
||||
public readonly groupNames = ["Players", "Squares", "Text"];
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Objects as ObjectsBase } from "eightbittr";
|
||||
import { ClassInheritance, ClassProperties } from "objectmakr";
|
||||
|
||||
import { InfiniteContributionsCalendar } from "../InfiniteContributionsCalendar";
|
||||
|
||||
/**
|
||||
* Raw ObjectMakr factory settings.
|
||||
*/
|
||||
export class Objects<Game extends InfiniteContributionsCalendar> extends ObjectsBase<Game> {
|
||||
public readonly inheritance: ClassInheritance = {
|
||||
Actor: {
|
||||
Square: {
|
||||
SquareL0: {},
|
||||
SquareL1: {},
|
||||
SquareL2: {},
|
||||
SquareL3: {},
|
||||
SquareL4: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
public readonly onMake = "onMake";
|
||||
|
||||
public readonly properties: ClassProperties = {
|
||||
Actor: {
|
||||
onMake: this.game.actors.process.bind(this.game.actors),
|
||||
},
|
||||
Square: {
|
||||
height: 10,
|
||||
groupType: "Squares",
|
||||
width: 10,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"experimentalDecorators": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"module": "amd",
|
||||
"moduleResolution": "node",
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"outDir": "./lib",
|
||||
"pretty": true,
|
||||
"rootDir": "./src",
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"strictFunctionTypes": false,
|
||||
"strictPropertyInitialization": false,
|
||||
"target": "ES2021"
|
||||
},
|
||||
"exclude": ["**/*.d.ts", "**/lib"],
|
||||
"include": ["./src/**/*.ts", "./src/**/*.tsx"]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { member } from "autofieldr";
|
||||
import { FilterContainer, Palette } from "pixelrendr";
|
||||
import { FilterContainer, LibraryRaws, Palette } from "pixelrendr";
|
||||
|
||||
import { EightBittr } from "../EightBittr";
|
||||
import { Actor } from "../types";
|
||||
@@ -34,7 +34,7 @@ export class Graphics<Game extends EightBittr> extends Section<Game> {
|
||||
/**
|
||||
* A nested library of sprites to process.
|
||||
*/
|
||||
public readonly library?: any;
|
||||
public readonly library?: LibraryRaws;
|
||||
|
||||
/**
|
||||
* The default palette of colors to use for sprites.
|
||||
|
||||
@@ -146,41 +146,6 @@ export class NumberMakr {
|
||||
return this.seed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The length of the state vector.
|
||||
*/
|
||||
public getStateLength(): number {
|
||||
return this.stateLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The length of the state vector.
|
||||
*/
|
||||
public getStatePeriod(): number {
|
||||
return this.statePeriod;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The length of the state vector.
|
||||
*/
|
||||
public getMatrixA(): number {
|
||||
return this.matrixA;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The length of the state vector.
|
||||
*/
|
||||
public getMaskUpper(): number {
|
||||
return this.maskUpper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The length of the state vector.
|
||||
*/
|
||||
public getMaskLower(): number {
|
||||
return this.maskLower;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes state from a new seed.
|
||||
*
|
||||
|
||||
@@ -7,10 +7,409 @@
|
||||
[](http://badge.fury.io/js/worldseedr)
|
||||
[](https://gitter.im/FullScreenShenanigans/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
||||
|
||||
Automates random, recursive generation of possibilities from JSON schemas.
|
||||
Schema-driven pseudorandom recursive generation of possibilities.
|
||||
|
||||
<!-- /Top -->
|
||||
|
||||
## Usage
|
||||
|
||||
WorldSeedr allows generating descriptions of objects in a two-dimensional space based on probabilities in input schemas.
|
||||
Think random map generation for video games, particularly rogue-likes.
|
||||
|
||||
```ts
|
||||
const worldSeeder = new WorldSeedr({
|
||||
possibilities: {
|
||||
Stage: {
|
||||
children: [
|
||||
{
|
||||
probability: 80,
|
||||
value: {
|
||||
size: 3,
|
||||
title: "GoodActor",
|
||||
type: "Result",
|
||||
},
|
||||
},
|
||||
{
|
||||
probability: 20,
|
||||
value: {
|
||||
size: 3,
|
||||
title: "GreatActor",
|
||||
type: "Result",
|
||||
},
|
||||
},
|
||||
],
|
||||
direction: "right",
|
||||
repeat: Infinity,
|
||||
size: {
|
||||
height: 8,
|
||||
width: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
random: ((values) => {
|
||||
let index = -1;
|
||||
return () => values[(index = (index + 1) % values.length)];
|
||||
})([0.1, 0.9]),
|
||||
});
|
||||
|
||||
worldSeeder.generate("Stage", { bottom: 0, left: 0, top: 8, right: 12 });
|
||||
```
|
||||
|
||||
```ts
|
||||
// Randomized results may look something like:
|
||||
[
|
||||
{ area: { left: 0, right: 3, bottom: 0, top: 8 }, title: "GoodActor" },
|
||||
{ area: { left: 3, right: 6, bottom: 0, top: 8 }, title: "GreatActor" },
|
||||
];
|
||||
```
|
||||
|
||||
WorldSeedr's constructor takes in an object that may have two properties:
|
||||
|
||||
- `possibilities` _(required)_: Object mapping names of possibilities to their details
|
||||
- `random` _(optional)_: A replacement for `Math.random`
|
||||
|
||||
### `possibilities`
|
||||
|
||||
Each Possibility represents a set of instructions for placing children along a line.
|
||||
Each of the following properties of the `possibilities` object may be provided either as a single value or as an array of objects with a `probability` number between 0 and 100 and `value`.
|
||||
Probability arrays will be chosen from at random, with weighting based on the probability.
|
||||
|
||||
For example, the `children` in this `Obstacle` Possibility have an 60% change of placing nothing, a 30% chance of placing two `"Enemy"`s, and a 10% chance of placing a `"DifficultEnemy"`:
|
||||
|
||||
```ts
|
||||
new WorldSeedr({
|
||||
possibilities: {
|
||||
Obstacle: {
|
||||
children: [
|
||||
{
|
||||
probability: 60,
|
||||
value: [],
|
||||
},
|
||||
{
|
||||
probability: 30,
|
||||
value: [
|
||||
{
|
||||
size: 2,
|
||||
title: "Enemy",
|
||||
type: "Result",
|
||||
},
|
||||
{
|
||||
size: 2,
|
||||
title: "Enemy",
|
||||
type: "Result",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
probability: 10,
|
||||
value: {
|
||||
size: 4,
|
||||
title: "DifficultEnemy",
|
||||
type: "Result",
|
||||
},
|
||||
},
|
||||
],
|
||||
direction: "right",
|
||||
size: {
|
||||
height: 4,
|
||||
width: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### `children`
|
||||
|
||||
An ordered array of descendants to place in the output space.
|
||||
These each have a `type` property equal to either:
|
||||
|
||||
- `"Possibility"`: Indicating to recurse on another Possibility
|
||||
- `"Result"`: Indicating the child should be directly returned as results
|
||||
|
||||
Both types of children must have a `title` property indicating the name of their specified output.
|
||||
|
||||
##### Possibility Children
|
||||
|
||||
It's common for Possibilities to recursively include other Possibilities, or even themselves.
|
||||
WorldSeedr will call `.generate` on the nested Possibility within the space allocated for the child.
|
||||
|
||||
For example, given this WorldSeedr that defines a `"Dungeon"` Possibility as having a 50-50 chance of either recursing into a `"HappySpace"` Possibility or an `"UnhappyPlace"` Possibility:
|
||||
|
||||
```ts
|
||||
const worldSeeder = new WorldSeedr({
|
||||
possibilities: {
|
||||
Dungeon: {
|
||||
children: [
|
||||
{
|
||||
probability: 50,
|
||||
value: {
|
||||
size: 10,
|
||||
title: "HappySpace",
|
||||
type: "Possibility",
|
||||
},
|
||||
},
|
||||
{
|
||||
probability: 50,
|
||||
value: {
|
||||
size: 10,
|
||||
title: "UnhappySpace",
|
||||
type: "Possibility",
|
||||
},
|
||||
},
|
||||
],
|
||||
direction: "right",
|
||||
repeat: 2,
|
||||
size: {
|
||||
height: 10,
|
||||
width: 20,
|
||||
},
|
||||
},
|
||||
HappySpace: {
|
||||
children: [
|
||||
{
|
||||
probability: 70,
|
||||
value: [],
|
||||
},
|
||||
{
|
||||
probability: 30,
|
||||
value: {
|
||||
size: 10,
|
||||
title: "Item",
|
||||
type: "Result",
|
||||
},
|
||||
},
|
||||
],
|
||||
direction: "right",
|
||||
size: {
|
||||
height: 10,
|
||||
width: 10,
|
||||
},
|
||||
},
|
||||
UnhappySpace: {
|
||||
children: [
|
||||
{
|
||||
probability: 80,
|
||||
value: {
|
||||
size: 10,
|
||||
title: "EasyEnemy",
|
||||
type: "Result",
|
||||
},
|
||||
},
|
||||
{
|
||||
probability: 20,
|
||||
value: {
|
||||
size: 10,
|
||||
title: "HardEnemy",
|
||||
type: "Result",
|
||||
},
|
||||
},
|
||||
],
|
||||
direction: "right",
|
||||
size: {
|
||||
height: 10,
|
||||
width: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
...generated output might look something like:
|
||||
|
||||
```ts
|
||||
[
|
||||
{
|
||||
// area: { ... }
|
||||
title: "HardEnemy",
|
||||
},
|
||||
{
|
||||
// area: { ... }
|
||||
title: "Item",
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
##### Result Children
|
||||
|
||||
Result children may optionally include a `properties` object to be added to the results
|
||||
|
||||
For example, given this `children.value` that only adds `properties` to one of its two results:
|
||||
|
||||
```ts
|
||||
[
|
||||
{
|
||||
// size: ...
|
||||
title: "Scenery",
|
||||
type: "Result",
|
||||
},
|
||||
{
|
||||
// size: ...
|
||||
properties: {
|
||||
dialog: "Roar!",
|
||||
},
|
||||
title: "Enemy",
|
||||
type: "Result",
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
...generated output might look something like:
|
||||
|
||||
```ts
|
||||
[
|
||||
{
|
||||
// area: { ... },
|
||||
title: "Scenery",
|
||||
},
|
||||
{
|
||||
// area: { ... },
|
||||
properties: {
|
||||
dialog: "Roar!",
|
||||
},
|
||||
title: "Enemy",
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
Note that results children, like other properties, can come in three forms:
|
||||
|
||||
- Direct value: like `children: { ... }`
|
||||
- Array of values: like `children: [ { ... }, { ... } ]`
|
||||
- Array of value probabilities: like `children: [ { probability: 100, value: { ... }]`
|
||||
|
||||
#### `align`
|
||||
|
||||
Possibility children won't always take up all the space to their side (perpendicular to their direction).
|
||||
By default, they align to `"stretch"`: taking up all available space to their side.
|
||||
They can alternately be given an `align` property set to one of the directions to align to.
|
||||
|
||||
For example, given this Possibility snippet that aligns one result to the default `"stretch"`, one to `"bottom"`, and one to `"top"`:
|
||||
|
||||
```ts
|
||||
{
|
||||
children: [
|
||||
{
|
||||
size: {
|
||||
height: 5,
|
||||
width: 5,
|
||||
},
|
||||
title: "First",
|
||||
type: "Result",
|
||||
},
|
||||
{
|
||||
align: "bottom",
|
||||
size: {
|
||||
height: 5,
|
||||
width: 5,
|
||||
},
|
||||
title: "Second",
|
||||
type: "Result",
|
||||
},
|
||||
{
|
||||
align: "top",
|
||||
size: {
|
||||
height: 5,
|
||||
width: 5,
|
||||
},
|
||||
title: "Third",
|
||||
type: "Result",
|
||||
},
|
||||
],
|
||||
direction: "right",
|
||||
size: {
|
||||
height: 15,
|
||||
width: 15,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
...generated output in a 15x15 space might look like:
|
||||
|
||||
```ts
|
||||
[
|
||||
{
|
||||
area: {
|
||||
left: 0,
|
||||
right: 5,
|
||||
bottom: 0,
|
||||
top: 15,
|
||||
},
|
||||
title: "First",
|
||||
},
|
||||
{
|
||||
area: {
|
||||
left: 5,
|
||||
right: 10,
|
||||
bottom: 0,
|
||||
top: 15,
|
||||
},
|
||||
title: "Second",
|
||||
},
|
||||
{
|
||||
area: {
|
||||
left: 10,
|
||||
right: 15,
|
||||
bottom: 0,
|
||||
top: 15,
|
||||
},
|
||||
title: "Third",
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
#### `direction`
|
||||
|
||||
Possibility children are placed along one of the cardinal directions.
|
||||
The `direction` property may be one of:
|
||||
|
||||
- `"bottom"`: Start at the top edge of the available space, and go downwards
|
||||
- `"left"`: Start at the right edge of the available space, and go to the left
|
||||
- `"right"`: Start at the left edge of the available space, and go to the right
|
||||
- `"top"`: Start at the bottom edge of the available space, and go upwards
|
||||
|
||||
#### `repeat`
|
||||
|
||||
How many times the children should be repeated.
|
||||
It defaults to `1`, for only placing the children value(s) once.
|
||||
|
||||
> Tip: use `Infinity` to continuously repeat children as many times as allowed.
|
||||
|
||||
#### `size`
|
||||
|
||||
How much space this takes up, as an object containing `height` and `width`.
|
||||
|
||||
#### `spacing`
|
||||
|
||||
How much space should be between children.
|
||||
It defaults to `0`, for no space between them.
|
||||
The `spacing` property may be one of:
|
||||
|
||||
- A number: always use much space between children
|
||||
- An object describing a range of random numbers:
|
||||
- `max`: Maximum amount for the spacing
|
||||
- `min`: Minimum amount for the spacing
|
||||
- `roundTo`: Number unit to round to, if not 1 (no rounding)
|
||||
- An array of objects containing `probability` and `value` numbers, where the values are the objects describing a range of random numbers
|
||||
|
||||
### `random`
|
||||
|
||||
Applications that use deterministic seeds to generate maps generally need to use their own random number generator instead of `Math.random()`.
|
||||
See [NumberMakr](../numbermakr/README.md) for the recommended EightBittr package.
|
||||
|
||||
```ts
|
||||
const numberMaker = new NumberMakr();
|
||||
|
||||
const worldSeeder = new WorldSeedr({
|
||||
possibilities: {
|
||||
/* ... */
|
||||
},
|
||||
random: () => numberMaker.random(),
|
||||
});
|
||||
```
|
||||
|
||||
### `generate`
|
||||
|
||||
<!-- Development -->
|
||||
|
||||
## Development
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/FullScreenShenanigans/EightBittr/issues"
|
||||
},
|
||||
"description": "Automates random, recursive generation of possibilities from JSON schemas.",
|
||||
"description": "Schema-driven pseudorandom recursive generation of possibilities.",
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.3.3",
|
||||
"@types/mocha": "^9.1.1",
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { expect } from "chai";
|
||||
|
||||
import { RandomChooser } from "./RandomChooser";
|
||||
|
||||
describe("RandomChooser", () => {
|
||||
describe("chooseAmong", () => {
|
||||
it("returns the only choice when there is only one", () => {
|
||||
// Arrange
|
||||
const randomChooser = new RandomChooser(() => 0.99);
|
||||
const value = "val";
|
||||
|
||||
// Act
|
||||
const result = randomChooser.chooseAmong([{ probability: 100, value }]);
|
||||
|
||||
// Assert
|
||||
expect(result).to.be.equal(value);
|
||||
});
|
||||
|
||||
it("returns the first choice when its probability is the first reached", () => {
|
||||
// Arrange
|
||||
const randomChooser = new RandomChooser(() => 0.5);
|
||||
const value = "first";
|
||||
|
||||
// Act
|
||||
const result = randomChooser.chooseAmong([
|
||||
{ probability: 60, value },
|
||||
{ probability: 40, value: "incorrect" },
|
||||
]);
|
||||
|
||||
// Assert
|
||||
expect(result).to.be.equal(value);
|
||||
});
|
||||
|
||||
it("returns the second choice when its probability is the first reached", () => {
|
||||
// Arrange
|
||||
const randomChooser = new RandomChooser(() => 0.5);
|
||||
const value = "first";
|
||||
|
||||
// Act
|
||||
const result = randomChooser.chooseAmong([
|
||||
{ probability: 40, value: "incorrect" },
|
||||
{ probability: 60, value },
|
||||
]);
|
||||
|
||||
// Assert
|
||||
expect(result).to.be.equal(value);
|
||||
});
|
||||
|
||||
it("throws an error when the goal probability is never reached", () => {
|
||||
// Arrange
|
||||
const randomChooser = new RandomChooser(() => 0.99);
|
||||
const value = "val";
|
||||
|
||||
// Act
|
||||
const act = () => randomChooser.chooseAmong([{ probability: 5, value }]);
|
||||
|
||||
// Assert
|
||||
expect(act).to.throw("Choices only reached sum 5 out of goal 99.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chooseFixedOrRandom", () => {
|
||||
it("returns the value when given a value", () => {
|
||||
// Arrange
|
||||
const randomChooser = new RandomChooser(() => 0);
|
||||
const value = "val";
|
||||
|
||||
// Act
|
||||
const result = randomChooser.chooseFixedOrRandom(value);
|
||||
|
||||
// Assert
|
||||
expect(result).to.be.equal(value);
|
||||
});
|
||||
|
||||
it("returns a choice value when given choices", () => {
|
||||
// Arrange
|
||||
const randomChooser = new RandomChooser(() => 0.5);
|
||||
const value = "first";
|
||||
|
||||
// Act
|
||||
const result = randomChooser.chooseFixedOrRandom([
|
||||
{ probability: 40, value: "incorrect" },
|
||||
{ probability: 60, value },
|
||||
]);
|
||||
|
||||
// Assert
|
||||
expect(result).to.be.equal(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chooseFixedOrRandomOr", () => {
|
||||
it("returns the default value when the value doesn't exist", () => {
|
||||
// Arrange
|
||||
const randomChooser = new RandomChooser(() => 0);
|
||||
const value = "val";
|
||||
|
||||
// Act
|
||||
const result = randomChooser.chooseFixedOrRandomOr(undefined, value);
|
||||
|
||||
// Assert
|
||||
expect(result).to.be.equal(value);
|
||||
});
|
||||
|
||||
it("returns the value when given a value", () => {
|
||||
// Arrange
|
||||
const randomChooser = new RandomChooser(() => 0);
|
||||
const value = "val";
|
||||
|
||||
// Act
|
||||
const result = randomChooser.chooseFixedOrRandomOr(value, "incorrect");
|
||||
|
||||
// Assert
|
||||
expect(result).to.be.equal(value);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { FixedOrRandomized, RandomizedValue, RandomNumberGenerator } from "./types";
|
||||
|
||||
/**
|
||||
* Chooses values from arrays of weighted random values.
|
||||
*/
|
||||
export class RandomChooser {
|
||||
public constructor(
|
||||
/**
|
||||
* Random number generator that returns a decimal within [0, 1).
|
||||
*/
|
||||
private readonly random: RandomNumberGenerator
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Chooses a value from an array of weighted random values.
|
||||
*
|
||||
* @param choices Weighted choices to pick from.
|
||||
* @returns Random value from the choices.
|
||||
*/
|
||||
public chooseAmong<Value>(choices: RandomizedValue<Value>[]) {
|
||||
const goal = this.randomBetween(0, 100);
|
||||
let sum = 0;
|
||||
|
||||
for (const choice of choices) {
|
||||
sum += choice.probability;
|
||||
if (sum >= goal) {
|
||||
return choice.value;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Choices only reached sum ${sum} out of goal ${goal}.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses a value from either just the value or an array of weighted random values.
|
||||
*
|
||||
* @param possibility The value or weighted choices to pick from.
|
||||
* @returns Random value from the choices.
|
||||
*/
|
||||
public chooseFixedOrRandom<Value>(possibility: FixedOrRandomized<Value>) {
|
||||
return isRandomizedPossibility<Value>(possibility)
|
||||
? this.chooseAmong(possibility)
|
||||
: possibility;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses a value from an array of weighted random values, or a default if no values exist.
|
||||
*
|
||||
* @param possibility Weighted choices to pick from.
|
||||
* @returns Random value from the choices, or the default value.
|
||||
*/
|
||||
public chooseFixedOrRandomOr<Value>(
|
||||
possibility: FixedOrRandomized<Value> | undefined,
|
||||
defaultValue: Value
|
||||
) {
|
||||
return possibility === undefined ? defaultValue : this.chooseFixedOrRandom(possibility);
|
||||
}
|
||||
|
||||
/**
|
||||
* Random number generator that returns a decimal within [min, max).
|
||||
*
|
||||
* @param min Minimum number to return within.
|
||||
* @param max Maximum number to return within.
|
||||
* @returns Number within [min, max] at random.
|
||||
*/
|
||||
public randomBetween(min: number, max: number) {
|
||||
return Math.floor(this.random() * (max - min + 1)) + min;
|
||||
}
|
||||
}
|
||||
|
||||
function isRandomizedPossibility<Value>(
|
||||
possibility: FixedOrRandomized<Value>
|
||||
): possibility is RandomizedValue<Value>[] {
|
||||
return Array.isArray(possibility) && "probability" in possibility[0];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user