From 154f1fe61a8559d593ed38abf46c19f9f0301887 Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Mon, 22 Feb 2021 21:50:14 -0500 Subject: [PATCH] Add multiple complexity levels for creature generation. (#28) Fixes #18 by adding a random creature complexity slider. Currently slider values are 0, 1, 2, 3, 4, and bonkers. --- lib/pages/creature-page.tsx | 67 ++++++++++++++++++++++++++++++++++--- lib/random.test.ts | 28 ++++++++++++++++ lib/random.ts | 26 +++++++++++++- lib/util.test.tsx | 8 ++++- lib/util.ts | 13 +++++++ 5 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 lib/random.test.ts diff --git a/lib/pages/creature-page.tsx b/lib/pages/creature-page.tsx index 9dfb840..25fa4f9 100644 --- a/lib/pages/creature-page.tsx +++ b/lib/pages/creature-page.tsx @@ -6,12 +6,17 @@ import { SvgSymbolContext, SvgSymbolData, } from "../svg-symbol"; -import { AttachmentPointType, PointWithNormal } from "../specs"; +import { + AttachmentPointType, + iterAttachmentPoints, + PointWithNormal, +} from "../specs"; import { getAttachmentTransforms } from "../attach"; import { scalePointXY } from "../point"; import { Point } from "../../vendor/bezier-js"; import { Random } from "../random"; import { SymbolContextWidget } from "../symbol-context-widget"; +import { range } from "../util"; const SYMBOL_MAP = new Map( SvgVocabulary.map((symbol) => [symbol.name, symbol]) @@ -84,6 +89,7 @@ type CreatureSymbolProps = AttachmentIndices & { data: SvgSymbolData; children?: AttachmentChildren; attachTo?: AttachmentPointType; + indices?: number[]; }; function getAttachmentIndices(ai: AttachmentIndices): number[] { @@ -131,7 +137,7 @@ const CreatureSymbol: React.FC = (props) => { ); } - const attachmentIndices = getAttachmentIndices(props); + const attachmentIndices = props.indices || getAttachmentIndices(props); const children: JSX.Element[] = []; for (let attachIndex of attachmentIndices) { @@ -234,6 +240,33 @@ const Leg = createCreatureSymbol("leg"); const Tail = createCreatureSymbol("tail"); +function getSymbolWithAttachments( + numAttachmentKinds: number, + rng: Random +): JSX.Element { + const children: JSX.Element[] = []; + const root = rng.choice(SvgVocabulary); + if (root.specs) { + const attachmentKinds = rng.uniqueChoices( + Array.from(iterAttachmentPoints(root.specs)).map((point) => point.type), + numAttachmentKinds + ); + for (let kind of attachmentKinds) { + const attachment = rng.choice(SvgVocabulary); + const indices = range(root.specs[kind]?.length ?? 0); + children.push( + + ); + } + } + return ; +} + const EYE_CREATURE = ( @@ -262,6 +295,15 @@ function randomlyReplaceParts(rng: Random, creature: JSX.Element): JSX.Element { }); } +type CreatureGenerator = (rng: Random) => JSX.Element; + +const COMPLEXITY_LEVEL_GENERATORS: CreatureGenerator[] = [ + ...range(5).map((i) => getSymbolWithAttachments.bind(null, i)), + (rng) => randomlyReplaceParts(rng, EYE_CREATURE), +]; + +const MAX_COMPLEXITY_LEVEL = COMPLEXITY_LEVEL_GENERATORS.length - 1; + function getSvgMarkup(el: SVGSVGElement): string { return [ ``, @@ -348,7 +390,9 @@ export const CreaturePage: React.FC<{}> = () => { const [bgColor, setBgColor] = useState("#cccccc"); const [randomSeed, setRandomSeed] = useState(null); const [symbolCtx, setSymbolCtx] = useState(createSvgSymbolContext()); + const [complexity, setComplexity] = useState(MAX_COMPLEXITY_LEVEL); const defaultCtx = useContext(CreatureContext); + const newRandomSeed = () => setRandomSeed(Date.now()); const ctx: CreatureContextType = { ...defaultCtx, ...symbolCtx, @@ -357,7 +401,7 @@ export const CreaturePage: React.FC<{}> = () => { const creature = randomSeed === null ? EYE_CREATURE - : randomlyReplaceParts(new Random(randomSeed), EYE_CREATURE); + : COMPLEXITY_LEVEL_GENERATORS[complexity](new Random(randomSeed)); const handleSvgExport = () => exportSvg(getDownloadFilename(randomSeed), svgRef); @@ -373,7 +417,22 @@ export const CreaturePage: React.FC<{}> = () => { />{" "}

- {" "} {" "} diff --git a/lib/random.test.ts b/lib/random.test.ts new file mode 100644 index 0000000..fb9ccdc --- /dev/null +++ b/lib/random.test.ts @@ -0,0 +1,28 @@ +import { Random } from "./random"; + +describe("choice()", () => { + it("works", () => { + expect(new Random().choice([1])).toBe(1); + expect(new Random(134).choice([1, 5, 9, 7])).toBe(5); + }); + + it("throws an error if passed an empty array", () => { + expect(() => new Random().choice([])).toThrow( + /Cannot choose randomly from an empty array/ + ); + }); +}); + +describe("uniqueChoices()", () => { + it("works", () => { + expect(new Random(3).uniqueChoices([1, 2, 3], 2)).toEqual([1, 3]); + }); + + it("returns fewer choices than asked if necessary", () => { + expect(new Random().uniqueChoices([1, 1, 1], 5)).toEqual([1]); + }); + + it("returns an empty array if needed", () => { + expect(new Random().uniqueChoices([], 5)).toEqual([]); + }); +}); diff --git a/lib/random.ts b/lib/random.ts index cfe0ab4..63a1285 100644 --- a/lib/random.ts +++ b/lib/random.ts @@ -36,10 +36,34 @@ export class Random { } /** - * Return a random item from the given array. + * Return a random item from the given array. If the array is + * empty, an exception is thrown. */ choice(array: T[]): T { + if (array.length === 0) { + throw new Error("Cannot choose randomly from an empty array!"); + } const idx = Math.floor(this.next() * array.length); return array[idx]; } + + /** + * Attempt to randomly choose *at most* the given number of unique items from + * the array. If the array is too small, fewer items are returned. + */ + uniqueChoices(array: T[], howMany: number): T[] { + let choicesLeft = [...array]; + const result: T[] = []; + + for (let i = 0; i < howMany; i++) { + if (choicesLeft.length === 0) { + break; + } + const choice = this.choice(choicesLeft); + choicesLeft = choicesLeft.filter((item) => item !== choice); + result.push(choice); + } + + return result; + } } diff --git a/lib/util.test.tsx b/lib/util.test.tsx index b542c5c..0c912cd 100644 --- a/lib/util.test.tsx +++ b/lib/util.test.tsx @@ -1,4 +1,4 @@ -import { flatten, float, rad2deg } from "./util"; +import { flatten, float, rad2deg, range } from "./util"; describe("float", () => { it("converts strings", () => { @@ -25,3 +25,9 @@ test("rad2deg() works", () => { expect(rad2deg(Math.PI - 0.0000001)).toBeCloseTo(180); expect(rad2deg(2 * Math.PI)).toBe(360); }); + +test("range() works", () => { + expect(range(0)).toEqual([]); + expect(range(1)).toEqual([0]); + expect(range(5)).toEqual([0, 1, 2, 3, 4]); +}); diff --git a/lib/util.ts b/lib/util.ts index 522de77..687fcea 100644 --- a/lib/util.ts +++ b/lib/util.ts @@ -27,3 +27,16 @@ export function flatten(arr: T[][]): T[] { export function rad2deg(radians: number): number { return (radians * 180) / Math.PI; } + +/** + * Return an array containing the numbers from 0 to one + * less than the given value, increasing. + */ +export function range(count: number): number[] { + const result: number[] = []; + for (let i = 0; i < count; i++) { + result.push(i); + } + + return result; +}