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
rodzic
af62a4d66c
commit
77496c6301
|
@ -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,
|
||||
|
|
|
@ -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!">
|
|
@ -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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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,
|
||||
});
|
|
@ -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));
|
||||
}
|
|
@ -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) =>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
}),
|
||||
};
|
Ładowanie…
Reference in New Issue