diff --git a/CHANGELOG.md b/CHANGELOG.md index 65893a18..d88b5dd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# Development + +- #58 - New `generateRelative` function to generate a face that is similar to an exisiting face, like a sibling/parent/child. + # 4.2.2 (2024-08-04) - The `Face` React component should now be imported as: diff --git a/README.md b/README.md index 3e203627..55dfeeef 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,18 @@ Or both together: const face = generate(undefined, { gender: "female", race: "asian" }); ``` +### Relatives + +There is a separate `generateRelative` function to make a relative of an existing face object. Call it like: + +```javascript +const { generate, generateRelative } = require("facesjs"); +const face1 = generate(); +const face2 = generateRelative({ gender: "male", relative: face1 }); +``` + +This works by randomizing only some features of the existing face, so the new face is fairly similar to the existing one, like an immediate family member. + ### React integration You can use the `display` function within any frontend JS framework, but for ease of use with the most popular one, this package includes a `Face` component for React. diff --git a/package.json b/package.json index a31c8000..8f29e72d 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@phosphor-icons/react": "^2.1.6", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.13", + "@types/dlv": "^1.1.5", "@types/node": "^20.14.6", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -76,6 +77,8 @@ "zustand": "^4.5.2" }, "dependencies": { + "dlv": "^1.1.3", + "dset": "^3.1.4", "svg-path-bbox": "^2.0.0" }, "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 717831b2..c9a48736 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + dlv: + specifier: ^1.1.3 + version: 1.1.3 + dset: + specifier: ^3.1.4 + version: 3.1.4 svg-path-bbox: specifier: ^2.0.0 version: 2.0.0 @@ -42,6 +48,9 @@ importers: '@tailwindcss/typography': specifier: ^0.5.13 version: 0.5.13(tailwindcss@3.4.4) + '@types/dlv': + specifier: ^1.1.5 + version: 1.1.5 '@types/node': specifier: ^20.14.6 version: 20.14.6 @@ -2091,6 +2100,9 @@ packages: resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} + '@types/dlv@1.1.5': + resolution: {integrity: sha512-JHOWNfiWepAhfwlSw17kiWrWrk6od2dEQgHltJw9AS0JPFoLZJBge5+Dnil2NfdjAvJ/+vGSX60/BRW20PpUXw==} + '@types/estree@1.0.5': resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} @@ -2556,6 +2568,10 @@ packages: domutils@3.1.0: resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -6596,6 +6612,8 @@ snapshots: '@trysound/sax@0.2.0': {} + '@types/dlv@1.1.5': {} + '@types/estree@1.0.5': {} '@types/lodash.debounce@4.0.9': @@ -7089,6 +7107,8 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dset@3.1.4: {} + eastasianwidth@0.2.0: {} electron-to-chromium@1.4.807: {} diff --git a/public/editor/EditJsonModal.tsx b/public/editor/EditJsonModal.tsx index 8755df0c..a77251ef 100644 --- a/public/editor/EditJsonModal.tsx +++ b/public/editor/EditJsonModal.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import override from "../../src/override"; -import { Overrides } from "../../src/types"; +import { Overrides } from "../../src/common"; import { useStateStore } from "./stateStore"; import { Textarea, diff --git a/public/editor/FeatureGallery.tsx b/public/editor/FeatureGallery.tsx index c34187e5..ae808757 100644 --- a/public/editor/FeatureGallery.tsx +++ b/public/editor/FeatureGallery.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import override from "../../src/override"; -import { FaceConfig } from "../../src/types"; +import { FaceConfig } from "../../src/common"; import { useStateStore } from "./stateStore"; import { Shuffle, LockSimpleOpen, LockSimple } from "@phosphor-icons/react"; import { diff --git a/public/editor/TopBar.tsx b/public/editor/TopBar.tsx index 963bcb40..4f43cc58 100644 --- a/public/editor/TopBar.tsx +++ b/public/editor/TopBar.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Gender, Race } from "../../src/types"; +import { Gender, genders, Race, races } from "../../src/common"; import { useStateStore } from "./stateStore"; import { House, @@ -23,6 +23,7 @@ import { } from "@nextui-org/react"; import { capitalizeFirstLetter } from "./utils"; import { shuffleEntireFace } from "./shuffleFace"; +import { OtherSetting } from "./types"; export const TopBar = () => { const stateStore = useStateStore(); @@ -33,11 +34,11 @@ export const TopBar = () => { gallerySectionConfigList, shuffleGenderSettingObject, shuffleRaceSettingObject, + shuffleOtherSettingObject, setShuffleGenderSettingObject, setShuffleRaceSettingObject, + setShuffleOtherSettingObject, } = stateStore; - const genders: Gender[] = ["male", "female"]; - const races: Race[] = ["white", "black", "brown", "asian"]; const [genderInvalidOptions, setGenderInvalidOptions] = useState(false); const [raceInvalidOptions, setRaceInvalidOptions] = useState(false); @@ -115,6 +116,17 @@ export const TopBar = () => { ); })} + { + setShuffleOtherSettingObject( + otherList as OtherSetting[], + ); + }} + > + Relative + )} diff --git a/public/editor/defaultColors.ts b/public/editor/defaultColors.ts index 61af59ae..18d0ddd2 100644 --- a/public/editor/defaultColors.ts +++ b/public/editor/defaultColors.ts @@ -1,5 +1,5 @@ import { colors } from "../../src/generate"; -import { TeamColors } from "../../src/types"; +import { TeamColors } from "../../src/common"; import { distinct } from "./utils"; export const jerseyColorOptions: TeamColors[] = [ diff --git a/public/editor/downloadFace.ts b/public/editor/downloadFace.ts index 4f2521ce..a3ab21f3 100644 --- a/public/editor/downloadFace.ts +++ b/public/editor/downloadFace.ts @@ -1,4 +1,4 @@ -import { FaceConfig } from "../../src/types"; +import { FaceConfig } from "../../src/common"; import { getCurrentTimestampAsString } from "./utils"; // https://blog.logrocket.com/programmatically-downloading-files-browser/ diff --git a/public/editor/overrideList.ts b/public/editor/overrideList.ts index ba3dc85c..7f0ec7c0 100644 --- a/public/editor/overrideList.ts +++ b/public/editor/overrideList.ts @@ -1,6 +1,6 @@ import override from "../../src/override"; import { svgsIndex } from "../../src/svgs-index"; -import { FaceConfig as FaceType, Overrides } from "../../src/types"; +import { FaceConfig as FaceType, Overrides } from "../../src/common"; import { deepCopy } from "../../src/utils"; import { GallerySectionConfig, OverrideListItem } from "./types"; import { doesStrLookLikeColor, luma, setToDict } from "./utils"; @@ -15,7 +15,7 @@ export const getOverrideListForItem = ( const featureName = gallerySectionConfig.key.split(".")[0]; const svgNames = [ - ...(svgsIndex as Record)[featureName], + ...(svgsIndex as Record)[featureName], ]; svgNames.sort((a, b) => { if (a === "none" || a === "bald") return -1; diff --git a/public/editor/shuffleFace.ts b/public/editor/shuffleFace.ts index 7ee4e215..748fa019 100644 --- a/public/editor/shuffleFace.ts +++ b/public/editor/shuffleFace.ts @@ -1,9 +1,10 @@ -import { FaceConfig, Overrides } from "../../src/types"; +import { FaceConfig, Overrides } from "../../src/common"; import { generate } from "../../src/generate"; import { CombinedState, GallerySectionConfig } from "./types"; import { deleteFromDict, pickRandom } from "./utils"; import { jerseyColorOptions } from "./defaultColors"; -import { deepCopy } from "../../src/utils"; +import { deepCopy, randChoice } from "../../src/utils"; +import { generateRelative } from "../../src/generateRelative"; type GenerateOptions = Parameters[1]; @@ -12,8 +13,26 @@ export const shuffleEntireFace = ( gallerySectionConfigList: GallerySectionConfig[], stateStore: CombinedState, ) => { - const { setFaceStore, shuffleGenderSettingObject, shuffleRaceSettingObject } = - stateStore; + const { + setFaceStore, + shuffleGenderSettingObject, + shuffleRaceSettingObject, + shuffleOtherSettingObject, + } = stateStore; + + if (shuffleOtherSettingObject.includes("relative")) { + // Special case for relative - ignore randomizeEnabled and race + const gender = + shuffleGenderSettingObject.length === 1 + ? shuffleGenderSettingObject[0] + : randChoice(["male", "female"] as const); + + const newFace = generateRelative({ gender, relative: faceConfig }); + setFaceStore(newFace); + + return; + } + const faceConfigCopy: Overrides = deepCopy(faceConfig); const options: GenerateOptions = {}; diff --git a/public/editor/stateStore.ts b/public/editor/stateStore.ts index d9849b65..bf61e75f 100644 --- a/public/editor/stateStore.ts +++ b/public/editor/stateStore.ts @@ -12,7 +12,7 @@ import { distinctSkinColors, jerseyColorOptions, } from "./defaultColors"; -import { FaceConfig, Gender, Race } from "../../src/types"; +import { FaceConfig } from "../../src/common"; const gallerySectionInfos: (Pick< GallerySectionConfig, @@ -401,21 +401,29 @@ const createGallerySlice: StateCreator = ( shuffleGenderSettingObject: ["male"], shuffleRaceSettingObject: ["white", "brown", "black", "asian"], + shuffleOtherSettingObject: [], - setShuffleGenderSettingObject: (options: Gender[]) => - set((state: CombinedState) => { + setShuffleGenderSettingObject: (options) => + set((state) => { return { ...state, shuffleGenderSettingObject: options, }; }), - setShuffleRaceSettingObject: (options: Race[]) => - set((state: CombinedState) => { + setShuffleRaceSettingObject: (options) => + set((state) => { return { ...state, shuffleRaceSettingObject: options, }; }), + setShuffleOtherSettingObject: (options) => + set((state) => { + return { + ...state, + shuffleOtherSettingObject: options, + }; + }), }); export const useStateStore = create()( diff --git a/public/editor/types.ts b/public/editor/types.ts index 9bb25be5..42c54bb4 100644 --- a/public/editor/types.ts +++ b/public/editor/types.ts @@ -8,6 +8,8 @@ export type FaceState = { setFaceStore: (newFace: FaceConfig) => void; }; +export type OtherSetting = "relative"; + export type GalleryState = { gallerySize: GallerySize; gallerySectionConfigList: GallerySectionConfig[]; @@ -23,8 +25,10 @@ export type GalleryState = { shuffleGenderSettingObject: Gender[]; shuffleRaceSettingObject: Race[]; + shuffleOtherSettingObject: OtherSetting[]; setShuffleGenderSettingObject: (options: Gender[]) => void; setShuffleRaceSettingObject: (options: Race[]) => void; + setShuffleOtherSettingObject: (options: OtherSetting[]) => void; }; type GallerySectionConfigBase = { diff --git a/public/index.html b/public/index.html index 5688b863..d89064b2 100644 --- a/public/index.html +++ b/public/index.html @@ -60,51 +60,87 @@

Installation

npm install --save facesjs

Use

Import it with ES modules:

- +
import { display, generate } from "facesjs";

or CommonJS:

- +
const { display, generate } = require("facesjs");

Then, generate a random face:

- +
const face = generate();

And display it:

- -

If you’d like a non-random face, look inside the face variable and you’ll see all the available options for a manually constructed face.

+
// Display in a div with id "my-div-id"
+display("my-div-id", face);
+
+// Display in a div you already have a reference to
+const element = document.getElementById("my-div-id");
+display(element, face);
+

If you’d like a non-random face, look inside the face +variable and you’ll see all the available options for a manually +constructed face.

Overrides

-

Both display and generate accept an optional argument, specifying values to override either the randomly generated face (for generate) or the supplied face (for display). For instance:

- +

Both display and generate accept an +optional argument, specifying values to override either the randomly +generated face (for generate) or the supplied face (for +display). For instance:

+
// Generate a random face that always has blue skin
+const face = generate({ body: { color: "blue" } });
+
+// Display a face, but impose that it has blue skin
+display("my-div-id", face, { body: { color: "blue" } });

Options

-

The generate function takes a second optional argument, which takes in extra parameters for player creation, in the form of an object.

+

The generate function takes a second optional argument, +which takes in extra parameters for player creation, in the form of an +object.

Generate a female/male face (default is male):

- -

Assign a race attribute that can be white, black, asian, or brown (default is random):

- +
const face = generate(undefined, { gender: "female" });
+

Assign a race attribute that can be white, black, asian, or brown +(default is random):

+
const face = generate(undefined, { race: "white" });

Or both together:

- +
const face = generate(undefined, { gender: "female", race: "asian" });
+

Relatives

+

There is a separate generateRelative function to make a +relative of an existing face object. Call it like:

+
const { generate, generateRelative } = require("facesjs");
+const face1 = generate();
+const face2 = generateRelative({ gender: "male", relative: face1 });
+

This works by randomizing only some features of the existing face, so +the new face is fairly similar to the existing one, like an immediate +family member.

React integration

-

You can use the display function within any frontend JS framework, but for ease of use with the most popular one, this package includes a Face component for React.

- +

You can use the display function within any frontend JS +framework, but for ease of use with the most popular one, this package +includes a Face component for React.

+
import { generate } from "facesjs";
+import { Face } from "facesjs/react";
+import { useEffect } from "react";
+
+export const MyFace = ({ face }) => {
+    return <Face
+        face={face}
+        lazy
+        style={{
+            width: 100,
+        }}
+    >;
+};

Props of the Face component:

+++++++ @@ -134,14 +170,19 @@

React integration

- + - + @@ -161,16 +202,25 @@

React integration

Prop boolean falseIf true, then any errors when internally running display will be suppressed. This is useful if you accept user-defined faces and you don’t want errors from them to clog up your error logs.If true, then any errors when internally running +display will be suppressed. This is useful if you accept +user-defined faces and you don’t want errors from them to clog up your +error logs.
lazy boolean falseIf true, then application of overrides and rendering of the face will be delayed until this component is actually visible (as determined by an intersection observer).If true, then application of overrides and rendering of +the face will be delayed until this component is actually visible (as +determined by an intersection observer).
className

Exporting SVGs

API

-

You can use faceToSvgString to convert a face object to an SVG string.

- +

You can use faceToSvgString to convert a face object to +an SVG string.

+
import { faceToSvgString, generate } from "facesjs";
+
+const face = generate();
+const svg = faceToSvgString(face);

You can also specify overrides, similar to display:

- -

faceToSvgString is intended to be used in Node.js If you are doing client-side JS, it would be more efficient to render a face to the DOM using display and then convert it to a blob like this.

+
const svg = faceToSvgString(face, { body: { color: "blue" } });
+

faceToSvgString is intended to be used in Node.js If you +are doing client-side JS, it would be more efficient to render a face to +the DOM using display and then convert +it to a blob like this.

CLI

-

You can also use facesjs as a CLI program. All of the functionality from generate and display are available on the CLI too.

+

You can also use facesjs as a CLI program. All of the +functionality from generate and display are +available on the CLI too.

Examples

Output a random face to stdout:

$ npx facesjs
@@ -185,7 +235,9 @@

Options

-j, --input-json String faces.js JSON object to convert to SVG -r, --race Race - white/black/asian/brown, default is random -g, --gender Gender - male/female, default is male -

--input-file and --input-json can specify either an entire face object or a partial face object. If it’s a partial face object, the other features will be random.

+

--input-file and --input-json can specify +either an entire face object or a partial face object. If it’s a partial +face object, the other features will be random.

Development

Running pnpm run dev will do a few things:

    @@ -196,13 +248,42 @@

    Development

This lets you immediately see your changes as you work.

Adding new facial features

-

Each face is assembled from multiple SVGs. You can see them within the “svg” folder. If you want to add another feature, just create an SVG (using a vector graphics editor like Inkscape) and put it in the appropriate folder. It should automatically work. If not, it’s a bug, please let me know!

-

When creating SVGs, assume the size of the canvas is 400x600. For most features, it doesn’t matter where you draw on the canvas because it will automatically identify your object and position it in the appropriate place. But for head and hair SVGs, position does matter. For those you do need to make sure they are in the correct place on a 400x600 canvas, same as the existing head and hair SVGs. Otherwise it won’t know where to place the other facial features relative to the head and hair.

-

If you find it not quite placing a facial feature exactly where you want, it’s because by default it finds the center of the eye/eyebrow/mouth/nose SVG and places that in a specific location. If that’s not good for a certain facial feature, that behavior can be overridden in code. For instance, see how it’s done in display.js for the “pinocchio” nose which uses the left side of the SVG rather than the center to place it.

-

If you want a brand new “class” of facial features (like facial hair, or earrings, or hats) you’ll have to create a new subfolder within the “svg” folder and edit the code to recognize your new feature.

-

If you find any of this confusing, feel free to reach out to me for help! I would love for someone to help me make better looking faces :)

+

Each face is assembled from multiple SVGs. You can see them within +the “svg” folder. If you want to add another feature, just create an SVG +(using a vector graphics editor like Inkscape) and put it in the appropriate +folder. It should automatically work. If not, it’s a bug, please let me +know!

+

When creating SVGs, assume the size of the canvas is 400x600. For +most features, it doesn’t matter where you draw on the canvas because it +will automatically identify your object and position it in the +appropriate place. But for head and hair SVGs, position does matter. For +those you do need to make sure they are in the correct place on a +400x600 canvas, same as the existing head and hair SVGs. Otherwise it +won’t know where to place the other facial features relative to the head +and hair.

+

If you find it not quite placing a facial feature exactly where you +want, it’s because by default it finds the center of the +eye/eyebrow/mouth/nose SVG and places that in a specific location. If +that’s not good for a certain facial feature, that behavior can be +overridden in code. For instance, see how it’s done in display.js for +the “pinocchio” nose which uses the left side of the SVG rather than the +center to place it.

+

If you want a brand new “class” of facial features (like facial hair, +or earrings, or hats) you’ll have to create a new subfolder within the +“svg” folder and edit the code to recognize your new feature.

+

If you find any of this confusing, feel free to reach out to me for +help! I would love for someone to help me make better looking faces +:)

Credits

-

dumbmatter wrote most of the code, TravisJB89 made most of the graphics, Lia Cui made most of the female graphics, gurushida wrote the code to export faces as SVG strings, and tomkennedy22 wrote most of the editor UI code.

+

dumbmatter wrote most of +the code, TravisJB89 made +most of the graphics, Lia Cui +made most of the female graphics, gurushida wrote the code to +export faces as SVG strings, and tomkennedy22 wrote most of +the editor UI code.

diff --git a/src/Face.tsx b/src/Face.tsx index e305c478..c6d1fd1c 100644 --- a/src/Face.tsx +++ b/src/Face.tsx @@ -6,7 +6,7 @@ import { useState, type CSSProperties, } from "react"; -import { FaceConfig, Overrides } from "./types"; +import { FaceConfig, Overrides } from "./common"; import { display } from "./display"; import { deepCopy } from "./utils"; diff --git a/src/cli.ts b/src/cli.ts index eec5d82f..c33786c8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,7 +4,7 @@ import fs from "node:fs"; import { parseArgs } from "node:util"; import { faceToSvgString } from "./faceToSvgString.js"; import { generate } from "./generate.js"; -import { Gender, Overrides, Race } from "./types.js"; +import { Gender, Overrides, Race } from "./common.js"; const { values: options } = parseArgs({ options: { diff --git a/src/common.ts b/src/common.ts new file mode 100644 index 00000000..05e70eab --- /dev/null +++ b/src/common.ts @@ -0,0 +1,44 @@ +import type { generate } from "./generate"; + +export type Overrides = { + [key: string]: boolean | string | number | any[] | Overrides; +}; + +export const genders = ["male", "female"] as const; +export type Gender = (typeof genders)[number]; + +export const features = [ + "accessories", + "body", + "ear", + "eye", + "eyebrow", + "eyeLine", + "facialHair", + "glasses", + "hair", + "hairBg", + "head", + "jersey", + "miscLine", + "mouth", + "nose", + "smileLine", +] as const; +export type Feature = (typeof features)[number]; + +export const races = ["white", "black", "brown", "asian"] as const; +export type Race = (typeof races)[number]; + +export type TeamColors = [string, string, string]; + +export type FeatureInfo = { + id?: string; + name: Feature; + positions: [null] | [number, number][]; + scaleFatness?: boolean; + shiftWithEyes?: boolean; + opaqueLines?: boolean; +}; + +export type FaceConfig = ReturnType; diff --git a/src/display.ts b/src/display.ts index e4db2610..6dd95640 100644 --- a/src/display.ts +++ b/src/display.ts @@ -1,6 +1,6 @@ import override from "./override.js"; import svgs from "./svgs.js"; -import { FaceConfig, Overrides } from "./types.js"; +import { FaceConfig, Overrides } from "./common.js"; const addWrapper = (svgString: string) => `${svgString}`; diff --git a/src/faceToSvgString.ts b/src/faceToSvgString.ts index 3c7858eb..3650d52d 100644 --- a/src/faceToSvgString.ts +++ b/src/faceToSvgString.ts @@ -1,6 +1,6 @@ import { svgPathBbox } from "svg-path-bbox"; import { display } from "./display.js"; -import { FaceConfig, Overrides } from "./types.js"; +import { FaceConfig, Overrides } from "./common.js"; /** * An instance of this object can pretend to be the global "document" diff --git a/src/generate.ts b/src/generate.ts index a589d5a0..f37205c8 100644 --- a/src/generate.ts +++ b/src/generate.ts @@ -1,17 +1,14 @@ import override from "./override.js"; import { svgsGenders, svgsIndex } from "./svgs-index.js"; -import { Feature, Gender, Overrides, Race, TeamColors } from "./types.js"; - -function randomInt( - minInclusive: number, - max: number, - inclusiveMax: boolean = false, -) { - if (inclusiveMax) { - max += 1; - } - return Math.floor(Math.random() * (max - minInclusive)) + minInclusive; -} +import { + type Feature, + type Gender, + type Overrides, + type Race, + races, + type TeamColors, +} from "./common.js"; +import { randChoice, randInt, randUniform } from "./utils.js"; const getID = (type: Feature, gender: Gender): string => { const validIDs = svgsIndex[type].filter((_id, index) => { @@ -20,7 +17,7 @@ const getID = (type: Feature, gender: Gender): string => { ); }); - return validIDs[randomInt(0, validIDs.length)]; + return randChoice(validIDs); }; export const colors = { @@ -53,6 +50,51 @@ const defaultTeamColors: TeamColors = ["#89bfd3", "#7a1319", "#07364f"]; const roundTwoDecimals = (x: number) => Math.round(x * 100) / 100; +export const numberRanges = { + "body.size": { + female: [0.8, 0.9], + male: [0.95, 1.05], + }, + fatness: { + female: [0, 0.4], + male: [0, 1], + }, + "ear.size": { + female: [0.5, 1], + male: [0.5, 1.5], + }, + "eye.angle": { + female: [-10, 15], + male: [-10, 15], + }, + "eyebrow.angle": { + female: [-15, 20], + male: [-15, 20], + }, + "head.shave": { + female: [0, 0], + male: [0, 0.2], + }, + "nose.size": { + female: [0.5, 1], + male: [0.5, 1.25], + }, + "smileLine.size": { + female: [0.25, 2.25], + male: [0.25, 2.25], + }, +} as const; + +const getRandInt = (key: keyof typeof numberRanges, gender: Gender) => { + return randInt(numberRanges[key][gender][0], numberRanges[key][gender][1]); +}; + +const getRandUniform = (key: keyof typeof numberRanges, gender: Gender) => { + return roundTwoDecimals( + randUniform(numberRanges[key][gender][0], numberRanges[key][gender][1]), + ); +}; + export const generate = ( overrides?: Overrides, options?: { gender?: Gender; race?: Race }, @@ -61,22 +103,11 @@ export const generate = ( if (options && options.race) { return options.race; } - switch (randomInt(0, 4)) { - case 0: - return "white"; - case 1: - return "asian"; - case 2: - return "brown"; - default: - return "black"; - } + return randChoice(races); })(); const gender = options && options.gender ? options.gender : "male"; - const eyeAngle = randomInt(-10, 15, true); - const palette = (() => { switch (playerRace) { case "white": @@ -89,12 +120,11 @@ export const generate = ( return colors.black; } })(); - const skinColor = palette.skin[randomInt(0, palette.skin.length)]; - const hairColor = palette.hair[randomInt(0, palette.hair.length)]; - const isFlipped = Math.random() < 0.5; + const skinColor = randChoice(palette.skin); + const hairColor = randChoice(palette.hair); const face = { - fatness: roundTwoDecimals((gender === "female" ? 0.4 : 1) * Math.random()), + fatness: getRandUniform("fatness", gender), teamColors: defaultTeamColors, hairBg: { id: @@ -105,24 +135,20 @@ export const generate = ( body: { id: getID("body", gender), color: skinColor, - size: roundTwoDecimals( - Math.random() * 0.1 + (gender === "female" ? 0.8 : 0.95), - ), + size: getRandUniform("body.size", gender), }, jersey: { id: getID("jersey", gender), }, ear: { id: getID("ear", gender), - size: roundTwoDecimals( - 0.5 + (gender === "female" ? 0.5 : 1) * Math.random(), - ), + size: getRandUniform("ear.size", gender), }, head: { id: getID("head", gender), shave: `rgba(0,0,0,${ gender === "male" && Math.random() < 0.25 - ? roundTwoDecimals(Math.random() / 5) + ? getRandUniform("head.shave", gender) : 0 })`, }, @@ -134,7 +160,7 @@ export const generate = ( Math.random() < (gender === "male" ? 0.75 : 0.1) ? getID("smileLine", gender) : "none", - size: roundTwoDecimals(0.25 + 2 * Math.random()), + size: getRandUniform("smileLine.size", gender), }, miscLine: { id: Math.random() < 0.5 ? getID("miscLine", gender) : "none", @@ -142,26 +168,24 @@ export const generate = ( facialHair: { id: Math.random() < 0.5 ? getID("facialHair", gender) : "none", }, - eye: { id: getID("eye", gender), angle: eyeAngle }, + eye: { id: getID("eye", gender), angle: getRandInt("eye.angle", gender) }, eyebrow: { id: getID("eyebrow", gender), - angle: randomInt(-15, 20, true), + angle: getRandInt("eyebrow.angle", gender), }, hair: { id: getID("hair", gender), color: hairColor, - flip: isFlipped, + flip: Math.random() < 0.5, }, mouth: { id: getID("mouth", gender), - flip: isFlipped, + flip: Math.random() < 0.5, }, nose: { id: getID("nose", gender), - flip: isFlipped, - size: roundTwoDecimals( - 0.5 + Math.random() * (gender === "female" ? 0.5 : 0.75), - ), + flip: Math.random() < 0.5, + size: getRandUniform("nose.size", gender), }, glasses: { id: Math.random() < 0.1 ? getID("glasses", gender) : "none", diff --git a/src/generateRelative.ts b/src/generateRelative.ts new file mode 100644 index 00000000..5bbef6ee --- /dev/null +++ b/src/generateRelative.ts @@ -0,0 +1,125 @@ +import delve from "dlv"; +import { dset } from "dset"; +import { colors, generate, numberRanges } from "./generate"; +import { features, races, type FaceConfig, type Gender } from "./common"; +import { deepCopy } from "./utils"; +import { svgsGenders, svgsIndex } from "./svgs-index"; + +// Currently, race just affects skin color and hair color. Let's ignore hair color (since you could imagine it being dyed anyway) and figure out someone's race just based on skin color. If no race is found, return undefined. +const imputeRace = (face: FaceConfig) => { + return races.find((race) => colors[race].skin.includes(face.body.color)); +}; + +export const generateRelative = ({ + gender, + relative, +}: { + gender: Gender; + relative: FaceConfig; +}) => { + const face = deepCopy(relative); + + const race = imputeRace(face); + + const randomFace = generate(undefined, { + gender, + race, + }); + + // Regenerate some properties always, and others with some probability + type RegenerateType = "always" | "sometimes" | "sometimesIfRaceIsKnown"; + type ToRegenerateProperties = T extends object + ? T extends any[] + ? U + : { + [K in keyof T]: T[K] extends object + ? ToRegenerateProperties | U + : U; + } + : U; + type RegenerateProperties = ToRegenerateProperties< + FaceConfig, + RegenerateType + >; + + const regenerateProperties: RegenerateProperties = { + accessories: "always", + body: { + color: "sometimesIfRaceIsKnown", + id: "sometimes", + size: "always", + }, + ear: "sometimes", + eye: "sometimes", + eyebrow: "sometimes", + eyeLine: "sometimes", + facialHair: "always", + fatness: "always", + glasses: "always", + hair: { + color: "sometimesIfRaceIsKnown", + flip: "always", + id: "always", + }, + hairBg: "always", + head: { + id: "sometimes", + shave: "always", + }, + jersey: "always", + miscLine: "sometimes", + mouth: "sometimes", + nose: "sometimes", + smileLine: "sometimes", + teamColors: "always", + }; + + const probRegenerate = 0.25; + const processRegenerateProperties = ( + objOutput: any, + objRandom: any, + regeneratePropertiesLocal: + | RegenerateProperties + | Record, + ) => { + for (const [key, value] of Object.entries(regeneratePropertiesLocal)) { + if (typeof value === "string") { + if ( + value === "always" || + ((value === "sometimes" || + (value === "sometimesIfRaceIsKnown" && race !== undefined)) && + Math.random() < probRegenerate) + ) { + objOutput[key] = objRandom[key]; + } + } else { + processRegenerateProperties(objOutput[key], objRandom[key], value); + } + } + }; + processRegenerateProperties(face, randomFace, regenerateProperties); + + // Override any ID properties that are not valid for the specified gender + for (const key of features) { + const svgIndex = svgsIndex[key].findIndex((id) => id === face[key].id); + const svgGender = svgsGenders[key][svgIndex]; + if ( + svgIndex < 0 || + (svgGender === "male" && gender === "female") || + (svgGender === "female" && gender === "male") + ) { + face[key].id = randomFace[key].id; + } + } + + // Override any numeric properties that are not valid for the specified gender + for (const [path, ranges] of Object.entries(numberRanges)) { + const current = delve(face, path); + const range = ranges[gender]; + if (current < range[0] || current > range[1]) { + dset(face, path, delve(randomFace, path)); + } + } + + return face; +}; diff --git a/src/index.ts b/src/index.ts index 8f4053d9..e8eb68ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,10 @@ export { display } from "./display.js"; export { generate } from "./generate.js"; +export { generateRelative } from "./generateRelative.js"; export { faceToSvgString } from "./faceToSvgString.js"; // Usually not needed, but just in case... export { default as svgs } from "./svgs.js"; export { svgsIndex } from "./svgs-index.js"; -export type * from "./types.js"; +export type * from "./common.js"; diff --git a/src/override.ts b/src/override.ts index 09bc7f0d..b3fd53a0 100644 --- a/src/override.ts +++ b/src/override.ts @@ -1,4 +1,4 @@ -import { Overrides } from "./types"; +import { Overrides } from "./common"; const override = (obj: Overrides, overrides?: Overrides) => { if (!overrides || !obj) { diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 7efca0ae..00000000 --- a/src/types.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { generate } from "./generate"; - -export type Overrides = { - [key: string]: boolean | string | number | any[] | Overrides; -}; - -export type Gender = "male" | "female"; - -export type Feature = - | "accessories" - | "body" - | "ear" - | "eye" - | "eyebrow" - | "eyeLine" - | "facialHair" - | "glasses" - | "hair" - | "hairBg" - | "head" - | "jersey" - | "miscLine" - | "mouth" - | "nose" - | "smileLine"; - -export type Race = "asian" | "black" | "brown" | "white"; - -export type TeamColors = [string, string, string]; - -export type FeatureInfo = { - id?: string; - name: Feature; - positions: [null] | [number, number][]; - scaleFatness?: boolean; - shiftWithEyes?: boolean; - opaqueLines?: boolean; -}; - -export type FaceConfig = ReturnType; diff --git a/src/utils.ts b/src/utils.ts index 5eaf9c66..45de147a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,15 @@ export const deepCopy = (object: T): T => { return JSON.parse(JSON.stringify(object)); }; + +export const randInt = (a: number, b: number): number => { + return Math.floor(Math.random() * (1 + b - a)) + a; +}; + +export const randUniform = (a: number, b: number): number => { + return Math.random() * (b - a) + a; +}; + +export const randChoice = (array: readonly T[]): T => { + return array[Math.floor(Math.random() * array.length)]; +}; diff --git a/tools/lib/process-svgs.js b/tools/lib/process-svgs.js index 6651107e..69d5800f 100644 --- a/tools/lib/process-svgs.js +++ b/tools/lib/process-svgs.js @@ -74,7 +74,7 @@ const processSVGs = async () => { path.join(import.meta.dirname, "..", "..", "src", "svgs-index.ts"), `${warning}\n\nexport const svgsIndex = ${JSON.stringify( svgsIndex, - )};\n\nexport const svgsGenders = ${JSON.stringify(svgsGenders)};`, + )} as const;\n\nexport const svgsGenders = ${JSON.stringify(svgsGenders)} as const;`, ); console.log(