Store creature state in querystring (#210)

This is an attempt to help with #61 by storing the creature state in the querystring.

However, a crucial issue with this implementation as it stands is that it stores the random number seed used to generate the creature.  This is great for representing the state in a compact form, but it's also likely to break easily as the vocabulary and randomization algorithm changes.

So, in the future, we might want to represent the creature state by enumerating the actual structure of the creature, which is likely to be a bit more future-proof.  (It also makes it possible for us to add features in the future that allow users to tweak the randomly-generated creature.)

But for now at least, this will allow users to use the back and forward buttons in their browser to navigate between creatures, so that if they click randomize and skip past something that looked cool, it's easy to go back and visit it.
pull/212/head
Atul Varma 2021-07-24 14:00:42 -04:00 zatwierdzone przez GitHub
rodzic af62a4d66c
commit 77496c6301
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
9 zmienionych plików z 200 dodań i 86 usunięć

Wyświetl plik

@ -7,7 +7,10 @@ const { avroToTypeScript } = require("avro-typescript");
* These are all the Avro AVSC JSON files we want to have
* TypeScript representations for.
*/
const AVSC_FILES = ["./lib/pages/mandala-page/mandala-design.avsc.json"];
const AVSC_FILES = [
"./lib/pages/mandala-page/mandala-design.avsc.json",
"./lib/pages/creature-page/creature-design.avsc.json",
];
/**
* Convert the given Avro AVSC JSON file into its TypeScript representation,

Wyświetl plik

@ -1,38 +1,46 @@
import React, { useContext, useMemo, useRef, useState } from "react";
import { SvgVocabulary, SvgVocabularyWithBlank } from "../svg-vocabulary";
import React, {
useCallback,
useContext,
useMemo,
useRef,
useState,
} from "react";
import { SvgVocabulary, SvgVocabularyWithBlank } from "../../svg-vocabulary";
import {
EMPTY_SVG_SYMBOL_DATA,
noFillIfShowingSpecs,
SvgSymbolData,
} from "../svg-symbol";
} from "../../svg-symbol";
import {
AttachmentPointType,
ATTACHMENT_POINT_TYPES,
iterAttachmentPoints,
} from "../specs";
import { Random } from "../random";
import { range } from "../util";
} from "../../specs";
import { Random } from "../../random";
import { range } from "../../util";
import { AutoSizingSvg } from "../auto-sizing-svg";
import { ExportWidget } from "../export-svg";
import { AutoSizingSvg } from "../../auto-sizing-svg";
import { ExportWidget } from "../../export-svg";
import {
CreatureContext,
CreatureContextType,
CreatureSymbol,
NestedCreatureSymbol,
} from "../creature-symbol";
import { HoverDebugHelper } from "../hover-debug-helper";
import { svgScale, SvgTransform } from "../svg-transform";
import { NumericSlider } from "../numeric-slider";
import { Checkbox } from "../checkbox";
} from "../../creature-symbol";
import { HoverDebugHelper } from "../../hover-debug-helper";
import { svgScale, SvgTransform } from "../../svg-transform";
import { NumericSlider } from "../../numeric-slider";
import { Checkbox } from "../../checkbox";
import {
CompositionContextWidget,
createSvgCompositionContext,
} from "../svg-composition-context";
import { Page } from "../page";
import { RandomizerWidget } from "../randomizer-widget";
import { VocabularyWidget } from "../vocabulary-widget";
import { createDistribution } from "../distribution";
} from "../../svg-composition-context";
import { Page } from "../../page";
import { RandomizerWidget } from "../../randomizer-widget";
import { VocabularyWidget } from "../../vocabulary-widget";
import { createDistribution } from "../../distribution";
import { ComponentWithShareableStateProps } from "../../page-with-shareable-state";
import { useDebouncedEffect } from "../../use-debounced-effect";
/**
* The minimum number of attachment points that any symbol used as the main body
@ -236,12 +244,24 @@ function repeatUntilSymbolIsIncluded(
return createCreature(rng);
}
export const CreaturePage: React.FC<{}> = () => {
export const CREATURE_DESIGN_DEFAULTS = {
randomSeed: 0,
randomlyInvert: true,
complexity: INITIAL_COMPLEXITY_LEVEL,
alwaysIncludeSymbol: EMPTY_SVG_SYMBOL_DATA,
compCtx: createSvgCompositionContext(),
};
export type CreatureDesign = typeof CREATURE_DESIGN_DEFAULTS;
export const CreaturePageWithDefaults: React.FC<
ComponentWithShareableStateProps<CreatureDesign>
> = ({ defaults, onChange }) => {
const svgRef = useRef<SVGSVGElement>(null);
const [randomSeed, setRandomSeed] = useState<number>(Date.now());
const [randomlyInvert, setRandomlyInvert] = useState(true);
const [compCtx, setCompCtx] = useState(createSvgCompositionContext());
const [complexity, setComplexity] = useState(INITIAL_COMPLEXITY_LEVEL);
const [randomSeed, setRandomSeed] = useState(defaults.randomSeed);
const [randomlyInvert, setRandomlyInvert] = useState(defaults.randomlyInvert);
const [compCtx, setCompCtx] = useState(defaults.compCtx);
const [complexity, setComplexity] = useState(defaults.complexity);
const defaultCtx = useContext(CreatureContext);
const newRandomSeed = () => setRandomSeed(Date.now());
const ctx: CreatureContextType = noFillIfShowingSpecs({
@ -249,7 +269,7 @@ export const CreaturePage: React.FC<{}> = () => {
...compCtx,
});
const [alwaysInclude, setAlwaysInclude] = useState<SvgSymbolData>(
EMPTY_SVG_SYMBOL_DATA
defaults.alwaysIncludeSymbol
);
const creature = useMemo(
() =>
@ -264,6 +284,21 @@ export const CreaturePage: React.FC<{}> = () => {
),
[alwaysInclude, complexity, randomSeed, randomlyInvert]
);
const design: CreatureDesign = useMemo(
() => ({
randomSeed,
randomlyInvert,
complexity,
alwaysIncludeSymbol: alwaysInclude,
compCtx,
}),
[randomSeed, randomlyInvert, complexity, alwaysInclude, compCtx]
);
useDebouncedEffect(
250,
useCallback(() => onChange(design), [onChange, design])
);
return (
<Page title="Creature!">

Wyświetl plik

@ -0,0 +1,24 @@
{
"type": "record",
"name": "AvroCreatureDesign",
"fields": [
{ "name": "randomSeed", "type": "long" },
{ "name": "randomlyInvert", "type": "boolean" },
{ "name": "complexity", "type": "int" },
{ "name": "alwaysIncludeSymbol", "type": "string" },
{
"name": "compCtx",
"type": {
"name": "AvroSvgCompositionContext",
"type": "record",
"fields": [
{ "name": "stroke", "type": "int" },
{ "name": "fill", "type": "int" },
{ "name": "background", "type": "int" },
{ "name": "uniformStrokeWidth", "type": "float" },
{ "name": "disableGradients", "type": "boolean", "default": true }
]
}
}
]
}

Wyświetl plik

@ -0,0 +1,13 @@
import { createPageWithShareableState } from "../../page-with-shareable-state";
import { CreaturePageWithDefaults, CREATURE_DESIGN_DEFAULTS } from "./core";
import {
deserializeCreatureDesign,
serializeCreatureDesign,
} from "./serialization";
export const CreaturePage = createPageWithShareableState({
defaultValue: CREATURE_DESIGN_DEFAULTS,
serialize: serializeCreatureDesign,
deserialize: deserializeCreatureDesign,
component: CreaturePageWithDefaults,
});

Wyświetl plik

@ -0,0 +1,41 @@
import * as avro from "avro-js";
import { CreatureDesign } from "./core";
import { AvroCreatureDesign } from "./creature-design.avsc";
import { fromBase64, toBase64 } from "../../base64";
import CreatureAvsc from "./creature-design.avsc.json";
import { SvgVocabularyWithBlank } from "../../svg-vocabulary";
import { Packer, SvgCompositionContextPacker } from "../../serialization";
const LATEST_VERSION = "v1";
const avroCreatureDesign = avro.parse<AvroCreatureDesign>(CreatureAvsc);
const DesignConfigPacker: Packer<CreatureDesign, AvroCreatureDesign> = {
pack: (value) => {
return {
...value,
alwaysIncludeSymbol: value.alwaysIncludeSymbol.name,
compCtx: SvgCompositionContextPacker.pack(value.compCtx),
};
},
unpack: (value) => {
return {
...value,
alwaysIncludeSymbol: SvgVocabularyWithBlank.get(
value.alwaysIncludeSymbol
),
compCtx: SvgCompositionContextPacker.unpack(value.compCtx),
};
},
};
export function serializeCreatureDesign(value: CreatureDesign): string {
const buf = avroCreatureDesign.toBuffer(DesignConfigPacker.pack(value));
return `${LATEST_VERSION}.${toBase64(buf)}`;
}
export function deserializeCreatureDesign(value: string): CreatureDesign {
const [_version, serialized] = value.split(".", 2);
const buf = fromBase64(serialized);
return DesignConfigPacker.unpack(avroCreatureDesign.fromBuffer(buf));
}

Wyświetl plik

@ -1,20 +1,9 @@
import {
ColorPacker,
serializeMandalaDesign,
deserializeMandalaDesign,
} from "./serialization";
import { MANDALA_DESIGN_DEFAULTS } from "./core";
describe("AvroColorConverter", () => {
it("converts strings to numbers", () => {
expect(ColorPacker.pack("#abcdef")).toEqual(0xabcdef);
});
it("converts numbers to strings", () => {
expect(ColorPacker.unpack(0xabcdef)).toEqual("#abcdef");
});
});
describe("Mandala design serialization/desrialization", () => {
// Helper to make it easy for us to copy/paste from URLs.
const decodeAndDeserialize = (s: string) =>

Wyświetl plik

@ -1,12 +1,7 @@
import { SvgVocabulary } from "../../svg-vocabulary";
import { SvgCompositionContext } from "../../svg-composition-context";
import MandalaAvsc from "./mandala-design.avsc.json";
import MandalaAvscV1 from "./mandala-design.v1.avsc.json";
import type {
AvroCircle,
AvroMandalaDesign,
AvroSvgCompositionContext,
} from "./mandala-design.avsc";
import type { AvroCircle, AvroMandalaDesign } from "./mandala-design.avsc";
import * as avro from "avro-js";
import {
MANDALA_DESIGN_DEFAULTS,
@ -15,22 +10,12 @@ import {
getCirclesFromDesign,
} from "./core";
import { fromBase64, toBase64 } from "../../base64";
import { clampedBytesToRGBColor, parseHexColor } from "../../color-util";
import { Packer, SvgCompositionContextPacker } from "../../serialization";
const LATEST_VERSION = "v2";
const avroMandalaDesign = avro.parse<AvroMandalaDesign>(MandalaAvsc);
/**
* A generic interface for "packing" one type to a different representation
* for the purposes of serialization, and "unpacking" the packed type
* back to its original representation (for deserialization).
*/
interface Packer<UnpackedType, PackedType> {
pack(value: UnpackedType): PackedType;
unpack(value: PackedType): UnpackedType;
}
const CirclePacker: Packer<ExtendedMandalaCircleParams, AvroCircle> = {
pack: ({ data, ...circle }) => ({
...circle,
@ -42,39 +27,6 @@ const CirclePacker: Packer<ExtendedMandalaCircleParams, AvroCircle> = {
}),
};
const SvgCompositionContextPacker: Packer<
SvgCompositionContext,
AvroSvgCompositionContext
> = {
pack: (ctx) => ({
...ctx,
fill: ColorPacker.pack(ctx.fill),
stroke: ColorPacker.pack(ctx.stroke),
background: ColorPacker.pack(ctx.background),
uniformStrokeWidth: ctx.uniformStrokeWidth || 1,
}),
unpack: (ctx) => ({
...ctx,
fill: ColorPacker.unpack(ctx.fill),
stroke: ColorPacker.unpack(ctx.stroke),
background: ColorPacker.unpack(ctx.background),
showSpecs: false,
}),
};
export const ColorPacker: Packer<string, number> = {
pack: (string) => {
const [red, green, blue] = parseHexColor(string);
return (red << 16) + (green << 8) + blue;
},
unpack: (number) => {
const red = (number >> 16) & 0xff;
const green = (number >> 8) & 0xff;
const blue = number & 0xff;
return clampedBytesToRGBColor([red, green, blue]);
},
};
const DesignConfigPacker: Packer<MandalaDesign, AvroMandalaDesign> = {
pack: (value) => {
return {

Wyświetl plik

@ -0,0 +1,11 @@
import { ColorPacker } from "./serialization";
describe("ColorPacker", () => {
it("converts strings to numbers", () => {
expect(ColorPacker.pack("#abcdef")).toEqual(0xabcdef);
});
it("converts numbers to strings", () => {
expect(ColorPacker.unpack(0xabcdef)).toEqual("#abcdef");
});
});

Wyświetl plik

@ -0,0 +1,46 @@
import { clampedBytesToRGBColor, parseHexColor } from "./color-util";
import { AvroSvgCompositionContext } from "./pages/mandala-page/mandala-design.avsc";
import { SvgCompositionContext } from "./svg-composition-context";
/**
* A generic interface for "packing" one type to a different representation
* for the purposes of serialization, and "unpacking" the packed type
* back to its original representation (for deserialization).
*/
export interface Packer<UnpackedType, PackedType> {
pack(value: UnpackedType): PackedType;
unpack(value: PackedType): UnpackedType;
}
export const ColorPacker: Packer<string, number> = {
pack: (string) => {
const [red, green, blue] = parseHexColor(string);
return (red << 16) + (green << 8) + blue;
},
unpack: (number) => {
const red = (number >> 16) & 0xff;
const green = (number >> 8) & 0xff;
const blue = number & 0xff;
return clampedBytesToRGBColor([red, green, blue]);
},
};
export const SvgCompositionContextPacker: Packer<
SvgCompositionContext,
AvroSvgCompositionContext
> = {
pack: (ctx) => ({
...ctx,
fill: ColorPacker.pack(ctx.fill),
stroke: ColorPacker.pack(ctx.stroke),
background: ColorPacker.pack(ctx.background),
uniformStrokeWidth: ctx.uniformStrokeWidth || 1,
}),
unpack: (ctx) => ({
...ctx,
fill: ColorPacker.unpack(ctx.fill),
stroke: ColorPacker.unpack(ctx.stroke),
background: ColorPacker.unpack(ctx.background),
showSpecs: false,
}),
};