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.pull/48/head
rodzic
593bcc8b9e
commit
154f1fe61a
|
@ -6,12 +6,17 @@ import {
|
||||||
SvgSymbolContext,
|
SvgSymbolContext,
|
||||||
SvgSymbolData,
|
SvgSymbolData,
|
||||||
} from "../svg-symbol";
|
} from "../svg-symbol";
|
||||||
import { AttachmentPointType, PointWithNormal } from "../specs";
|
import {
|
||||||
|
AttachmentPointType,
|
||||||
|
iterAttachmentPoints,
|
||||||
|
PointWithNormal,
|
||||||
|
} from "../specs";
|
||||||
import { getAttachmentTransforms } from "../attach";
|
import { getAttachmentTransforms } from "../attach";
|
||||||
import { scalePointXY } from "../point";
|
import { scalePointXY } from "../point";
|
||||||
import { Point } from "../../vendor/bezier-js";
|
import { Point } from "../../vendor/bezier-js";
|
||||||
import { Random } from "../random";
|
import { Random } from "../random";
|
||||||
import { SymbolContextWidget } from "../symbol-context-widget";
|
import { SymbolContextWidget } from "../symbol-context-widget";
|
||||||
|
import { range } from "../util";
|
||||||
|
|
||||||
const SYMBOL_MAP = new Map(
|
const SYMBOL_MAP = new Map(
|
||||||
SvgVocabulary.map((symbol) => [symbol.name, symbol])
|
SvgVocabulary.map((symbol) => [symbol.name, symbol])
|
||||||
|
@ -84,6 +89,7 @@ type CreatureSymbolProps = AttachmentIndices & {
|
||||||
data: SvgSymbolData;
|
data: SvgSymbolData;
|
||||||
children?: AttachmentChildren;
|
children?: AttachmentChildren;
|
||||||
attachTo?: AttachmentPointType;
|
attachTo?: AttachmentPointType;
|
||||||
|
indices?: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function getAttachmentIndices(ai: AttachmentIndices): number[] {
|
function getAttachmentIndices(ai: AttachmentIndices): number[] {
|
||||||
|
@ -131,7 +137,7 @@ const CreatureSymbol: React.FC<CreatureSymbolProps> = (props) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachmentIndices = getAttachmentIndices(props);
|
const attachmentIndices = props.indices || getAttachmentIndices(props);
|
||||||
const children: JSX.Element[] = [];
|
const children: JSX.Element[] = [];
|
||||||
|
|
||||||
for (let attachIndex of attachmentIndices) {
|
for (let attachIndex of attachmentIndices) {
|
||||||
|
@ -234,6 +240,33 @@ const Leg = createCreatureSymbol("leg");
|
||||||
|
|
||||||
const Tail = createCreatureSymbol("tail");
|
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(
|
||||||
|
<CreatureSymbol
|
||||||
|
data={attachment}
|
||||||
|
key={children.length}
|
||||||
|
attachTo={kind}
|
||||||
|
indices={indices}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return <CreatureSymbol data={root} children={children} />;
|
||||||
|
}
|
||||||
|
|
||||||
const EYE_CREATURE = (
|
const EYE_CREATURE = (
|
||||||
<Eye>
|
<Eye>
|
||||||
<Arm attachTo="arm" left>
|
<Arm attachTo="arm" left>
|
||||||
|
@ -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 {
|
function getSvgMarkup(el: SVGSVGElement): string {
|
||||||
return [
|
return [
|
||||||
`<?xml version="1.0" encoding="utf-8"?>`,
|
`<?xml version="1.0" encoding="utf-8"?>`,
|
||||||
|
@ -348,7 +390,9 @@ export const CreaturePage: React.FC<{}> = () => {
|
||||||
const [bgColor, setBgColor] = useState("#cccccc");
|
const [bgColor, setBgColor] = useState("#cccccc");
|
||||||
const [randomSeed, setRandomSeed] = useState<number | null>(null);
|
const [randomSeed, setRandomSeed] = useState<number | null>(null);
|
||||||
const [symbolCtx, setSymbolCtx] = useState(createSvgSymbolContext());
|
const [symbolCtx, setSymbolCtx] = useState(createSvgSymbolContext());
|
||||||
|
const [complexity, setComplexity] = useState(MAX_COMPLEXITY_LEVEL);
|
||||||
const defaultCtx = useContext(CreatureContext);
|
const defaultCtx = useContext(CreatureContext);
|
||||||
|
const newRandomSeed = () => setRandomSeed(Date.now());
|
||||||
const ctx: CreatureContextType = {
|
const ctx: CreatureContextType = {
|
||||||
...defaultCtx,
|
...defaultCtx,
|
||||||
...symbolCtx,
|
...symbolCtx,
|
||||||
|
@ -357,7 +401,7 @@ export const CreaturePage: React.FC<{}> = () => {
|
||||||
const creature =
|
const creature =
|
||||||
randomSeed === null
|
randomSeed === null
|
||||||
? EYE_CREATURE
|
? EYE_CREATURE
|
||||||
: randomlyReplaceParts(new Random(randomSeed), EYE_CREATURE);
|
: COMPLEXITY_LEVEL_GENERATORS[complexity](new Random(randomSeed));
|
||||||
const handleSvgExport = () =>
|
const handleSvgExport = () =>
|
||||||
exportSvg(getDownloadFilename(randomSeed), svgRef);
|
exportSvg(getDownloadFilename(randomSeed), svgRef);
|
||||||
|
|
||||||
|
@ -373,7 +417,22 @@ export const CreaturePage: React.FC<{}> = () => {
|
||||||
/>{" "}
|
/>{" "}
|
||||||
</SymbolContextWidget>
|
</SymbolContextWidget>
|
||||||
<p>
|
<p>
|
||||||
<button accessKey="r" onClick={() => setRandomSeed(Date.now())}>
|
<label htmlFor="complexity">Random creature complexity: </label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={MAX_COMPLEXITY_LEVEL}
|
||||||
|
step={1}
|
||||||
|
value={complexity}
|
||||||
|
onChange={(e) => {
|
||||||
|
setComplexity(parseInt(e.target.value));
|
||||||
|
newRandomSeed();
|
||||||
|
}}
|
||||||
|
/>{" "}
|
||||||
|
{complexity === MAX_COMPLEXITY_LEVEL ? "bonkers" : complexity}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<button accessKey="r" onClick={newRandomSeed}>
|
||||||
<u>R</u>andomize!
|
<u>R</u>andomize!
|
||||||
</button>{" "}
|
</button>{" "}
|
||||||
<button onClick={() => window.location.reload()}>Reset</button>{" "}
|
<button onClick={() => window.location.reload()}>Reset</button>{" "}
|
||||||
|
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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<T>(array: T[]): T {
|
choice<T>(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);
|
const idx = Math.floor(this.next() * array.length);
|
||||||
return array[idx];
|
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<T>(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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { flatten, float, rad2deg } from "./util";
|
import { flatten, float, rad2deg, range } from "./util";
|
||||||
|
|
||||||
describe("float", () => {
|
describe("float", () => {
|
||||||
it("converts strings", () => {
|
it("converts strings", () => {
|
||||||
|
@ -25,3 +25,9 @@ test("rad2deg() works", () => {
|
||||||
expect(rad2deg(Math.PI - 0.0000001)).toBeCloseTo(180);
|
expect(rad2deg(Math.PI - 0.0000001)).toBeCloseTo(180);
|
||||||
expect(rad2deg(2 * Math.PI)).toBe(360);
|
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]);
|
||||||
|
});
|
||||||
|
|
13
lib/util.ts
13
lib/util.ts
|
@ -27,3 +27,16 @@ export function flatten<T>(arr: T[][]): T[] {
|
||||||
export function rad2deg(radians: number): number {
|
export function rad2deg(radians: number): number {
|
||||||
return (radians * 180) / Math.PI;
|
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;
|
||||||
|
}
|
||||||
|
|
Ładowanie…
Reference in New Issue