Browse Source

Add animation workbench proof of concept

The animation workbench is implemented using Remix.
master
TheoryOfNekomata 1 year ago
parent
commit
c4e15bc3d5
15 changed files with 8016 additions and 0 deletions
  1. +22
    -0
      assets/internal/default/weapon-operator/sprite.svg
  2. BIN
      assets_src/gfx/weapons-rigged.cdr
  3. +4
    -0
      tools/animation-workbench/.eslintrc.js
  4. +6
    -0
      tools/animation-workbench/.gitignore
  5. +53
    -0
      tools/animation-workbench/README.md
  6. +39
    -0
      tools/animation-workbench/app/root.tsx
  7. +476
    -0
      tools/animation-workbench/app/routes/index.tsx
  8. +2
    -0
      tools/animation-workbench/app/tailwind.css
  9. +31
    -0
      tools/animation-workbench/package.json
  10. BIN
      tools/animation-workbench/public/favicon.ico
  11. +11
    -0
      tools/animation-workbench/remix.config.js
  12. +2
    -0
      tools/animation-workbench/remix.env.d.ts
  13. +8
    -0
      tools/animation-workbench/tailwind.config.js
  14. +22
    -0
      tools/animation-workbench/tsconfig.json
  15. +7340
    -0
      tools/animation-workbench/yarn.lock

+ 22
- 0
assets/internal/default/weapon-operator/sprite.svg View File

@@ -17,6 +17,28 @@
<path fill="#333333" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="22.9256" d="M301.044 263.808l2.844 -1.464c0.165,-0.085 0.369,-0.019 0.454,0.146l0.275 0.533c0.085,0.165 0.019,0.369 -0.146,0.454l-2.844 1.464c-0.166,0.085 -0.37,0.019 -0.455,-0.146l-0.274 -0.533c-0.085,-0.165 -0.02,-0.369 0.146,-0.454z"/>
</g>
<g id="Trigger">
<animateTransform
data-anim-name="fire.start"
attributeName="transform"
attributeType="XML"
type="rotate"
from="0 176.603 124.939"
to="15 176.603 124.939"
dur="250ms"
fill="freeze"
begin="indefinite"
/>
<animateTransform
data-anim-name="fire.end"
attributeName="transform"
attributeType="XML"
type="rotate"
to="0 176.603 124.939"
from="15 176.603 124.939"
dur="250ms"
fill="freeze"
begin="indefinite"
/>
<circle fill="none" cx="176.603" cy="124.939" r="46.58"/>
<path fill="#4D4D4D" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="22.9256" d="M181.116 159.21c0.909,1.3 -0.548,3.34 -2.484,1.735 -7.259,-6.018 -8.088,-13.992 -7.94,-21.195l0.055 -2.64c0.06,-2.944 -0.26,-4.789 -1.826,-5.208 -1.508,-0.403 -4.355,-4.984 -3.766,-6.43 1.583,-3.885 4.311,-4.3 8.44,-5.039 5.041,-0.903 14.517,-2.799 17.554,1.324 1.416,1.923 1.94,4.21 -2.23,4.822 -1.489,0.219 -4.474,4.517 -6.876,5.323 -4.156,1.395 -6.234,7.144 -6.349,9.384 -0.34,6.631 0.586,11.023 5.422,17.924z"/>
</g>


BIN
assets_src/gfx/weapons-rigged.cdr View File


+ 4
- 0
tools/animation-workbench/.eslintrc.js View File

@@ -0,0 +1,4 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"],
};

+ 6
- 0
tools/animation-workbench/.gitignore View File

@@ -0,0 +1,6 @@
node_modules

/.cache
/build
/public/build
.env

+ 53
- 0
tools/animation-workbench/README.md View File

@@ -0,0 +1,53 @@
# Welcome to Remix!

- [Remix Docs](https://remix.run/docs)

## Development

From your terminal:

```sh
npm run dev
```

This starts your app in development mode, rebuilding assets on file changes.

## Deployment

First, build your app for production:

```sh
npm run build
```

Then run the app in production mode:

```sh
npm start
```

Now you'll need to pick a host to deploy it to.

### DIY

If you're familiar with deploying node applications, the built-in Remix app server is production-ready.

Make sure to deploy the output of `remix build`

- `build/`
- `public/build/`

### Using a Template

When you ran `npx create-remix@latest` there were a few choices for hosting. You can run that again to create a new project, then copy over your `app/` folder to the new project that's pre-configured for your target server.

```sh
cd ..
# create a new project, and pick a pre-configured host
npx create-remix@latest
cd my-new-remix-app
# remove the new project's app (not the old one!)
rm -rf app
# copy your app over
cp -R ../my-old-remix-app/app app
```

+ 39
- 0
tools/animation-workbench/app/root.tsx View File

@@ -0,0 +1,39 @@
import type {LinksFunction, MetaFunction} from "@remix-run/node";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
import stylesheet from "./tailwind.css";

export const links: LinksFunction = () => [
{ rel: "stylesheet", href: stylesheet },
];

export const meta: MetaFunction = () => ({
charset: "utf-8",
title: "Izanagi | Animation Workbench",
viewport: "width=device-width,initial-scale=1",
});

const App = () => {
return (
<html lang="en-PH">
<head>
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}

export default App;

+ 476
- 0
tools/animation-workbench/app/routes/index.tsx View File

@@ -0,0 +1,476 @@
import {
ChangeEventHandler, FocusEventHandler,
FormEventHandler,
MouseEventHandler,
useEffect,
useId,
useRef,
useState,
} from 'react';
import { getFormValues, setFormValues } from '@theoryofnekomata/formxtra';

type SvgGroup = {
id: string;
name: string;
}

const useAnimationForm = () => {
const [loadedFileName, setLoadedFileName] = useState<string>();
const [loadedFileContents, setLoadedFileContents] = useState<string>();

const [svgAnimations, setSvgAnimations] = useState<string[]>();
const [svgGroups, setSvgGroups] = useState<SvgGroup[]>();
const [isNewAnimationId, setIsNewAnimationId] = useState<boolean>();
const [currentAnimationId, setCurrentAnimationId] = useState<string>();
const [enabledGroupIds, setEnabledGroupIds] = useState<string[]>();

const svgAnimationIdsListId = useId();

const svgPreviewRef = useRef<HTMLDivElement>(null);

const readFile = async (file: File) => {
const fileReader = new FileReader()

return new Promise<string>((resolve, reject) => {
fileReader.addEventListener('load', (e) => {
if (!e.target) {
reject(new Error('Invalid target'))
return;
}

resolve(e.target.result as string);
})

fileReader.addEventListener('error', () => {
reject(new Error('Error reading file'))
});

fileReader.readAsText(file);
});
}

const loadSvgFile: ChangeEventHandler<HTMLInputElement> = async (e) => {
const filePicker = e.currentTarget;
if (!filePicker.files) {
return;
}
const [file] = Array.from(filePicker.files);
if (!file) {
return;
}
setLoadedFileName(file.name);
setIsNewAnimationId(false);
const result = await readFile(file);
setLoadedFileContents(result);
filePicker.value = ''
}

const handleAnimationIdInput: FormEventHandler<HTMLInputElement> = (e) => {
const nativeEvent = e.nativeEvent as unknown as { inputType?: string };
switch (nativeEvent.inputType) {
case 'deleteContentBackward':
case 'deleteContentForward':
case 'insertText':
// we type into the input
setIsNewAnimationId(true);
break;
default:
// we select from the datalist
setIsNewAnimationId(false);
setCurrentAnimationId(e.currentTarget.value);
const target = e.currentTarget;
setTimeout(() => {
target.blur();
});
}
}

const cancelAnimationIdChange: MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault();
setIsNewAnimationId(false);
setFormValues(e.currentTarget.form!, {
animationId: currentAnimationId,
})
}

useEffect(() => {
if (!loadedFileContents) {
return
}

if (!svgPreviewRef.current) {
return
}

setSvgAnimations(['default']);
setSvgGroups(
Array
.from(svgPreviewRef.current.children[0].children)
.map((g) => ({
id: g.id,
name: g.id,
}))
);
setCurrentAnimationId('default');
setEnabledGroupIds([]); // TODO - compute enabled groupIds based on current animation id
}, [loadedFileContents, svgPreviewRef.current]);

const focusAnimationIdInput: FocusEventHandler<HTMLInputElement> = (e) => {
e.currentTarget.value = '';
}

const blurAnimationIdInput: FocusEventHandler<HTMLInputElement> = (e) => {
e.currentTarget.value = currentAnimationId as string;
setIsNewAnimationId(false);
}

const addKeyframeAttribute: ChangeEventHandler<HTMLInputElement> = (e) => {
const { currentTarget } = e;
if (currentTarget.checked) {
setEnabledGroupIds((oldEnabledGroupIds = []) => [...oldEnabledGroupIds, currentTarget.value]);
return
}
setEnabledGroupIds((oldEnabledGroupIds = []) => oldEnabledGroupIds.filter((i) => i !== currentTarget.value));
}

const translateXGroup = (group: SvgGroup): ChangeEventHandler<HTMLInputElement> => (e) => {
const { current: svgPreviewWrapper } = svgPreviewRef;
if (!svgPreviewWrapper) {
return;
}

const svgPreviewRoot = svgPreviewWrapper.children[0] as SVGSVGElement | null;
if (!svgPreviewRoot) {
return;
}

const targetGroup = Array.from(svgPreviewRoot.children).find(g => g.id === group.id) as SVGGraphicsElement;
if (!targetGroup) {
return;
}

const rotationCenterReference = Array.from(targetGroup.children).find(e => e.tagName === 'circle') as SVGCircleElement | undefined
const rotationCenter = {
cx: rotationCenterReference?.getAttribute?.('cx') ?? 0,
cy: rotationCenterReference?.getAttribute?.('cy') ?? 0,
};

const values = getFormValues(e.currentTarget.form!, { forceNumberValues: true });
const changed = Array.isArray(values.changed) ? values.changed : [values.changed];
const changedIndex = changed.indexOf(group.id);
const rotate = Array.isArray(values.rotate) ? values.rotate : [values.rotate];
const translateY = Array.isArray(values.translateY) ? values.translateY : [values.translateY];
const currentRotate = rotate[changedIndex];
const currentTranslateX = e.currentTarget.value;
const currentTranslateY = translateY[changedIndex];
targetGroup.setAttribute('transform', `rotate(${currentRotate} ${rotationCenter.cx} ${rotationCenter.cy}) translate(${currentTranslateX} ${currentTranslateY})`);
}

const translateYGroup = (group: SvgGroup): ChangeEventHandler<HTMLInputElement> => (e) => {
const { current: svgPreviewWrapper } = svgPreviewRef;
if (!svgPreviewWrapper) {
return;
}

const svgPreviewRoot = svgPreviewWrapper.children[0] as SVGSVGElement | null;
if (!svgPreviewRoot) {
return;
}

const targetGroup = Array.from(svgPreviewRoot.children).find(g => g.id === group.id) as SVGGraphicsElement;
if (!targetGroup) {
return;
}

const rotationCenterReference = Array.from(targetGroup.children).find(e => e.tagName === 'circle') as SVGCircleElement | undefined
const rotationCenter = {
cx: rotationCenterReference?.getAttribute?.('cx') ?? 0,
cy: rotationCenterReference?.getAttribute?.('cy') ?? 0,
};

const values = getFormValues(e.currentTarget.form!, { forceNumberValues: true });
const changed = Array.isArray(values.changed) ? values.changed : [values.changed];
const changedIndex = changed.indexOf(group.id);
const rotate = Array.isArray(values.rotate) ? values.rotate : [values.rotate];
const translateX = Array.isArray(values.translateX) ? values.translateX : [values.translateX];
const currentRotate = rotate[changedIndex];
const currentTranslateX = translateX[changedIndex];
const currentTranslateY = e.currentTarget.value;
targetGroup.setAttribute('transform', `rotate(${currentRotate} ${rotationCenter.cx} ${rotationCenter.cy}) translate(${currentTranslateX} ${currentTranslateY})`);
}

const rotateGroup = (group: SvgGroup): ChangeEventHandler<HTMLInputElement> => (e) => {
const { current: svgPreviewWrapper } = svgPreviewRef;
if (!svgPreviewWrapper) {
return;
}

const svgPreviewRoot = svgPreviewWrapper.children[0] as SVGSVGElement | null;
if (!svgPreviewRoot) {
return;
}

const targetGroup = Array.from(svgPreviewRoot.children).find(g => g.id === group.id) as SVGGraphicsElement;
if (!targetGroup) {
return;
}

const rotationCenterReference = Array.from(targetGroup.children).find(e => e.tagName === 'circle') as SVGCircleElement | undefined
const rotationCenter = {
cx: rotationCenterReference?.getAttribute?.('cx') ?? 0,
cy: rotationCenterReference?.getAttribute?.('cy') ?? 0,
};

const values = getFormValues(e.currentTarget.form!, { forceNumberValues: true });
const changed = Array.isArray(values.changed) ? values.changed : [values.changed];
const changedIndex = changed.indexOf(group.id);
const translateX = Array.isArray(values.translateX) ? values.translateX : [values.translateX];
const translateY = Array.isArray(values.translateY) ? values.translateY : [values.translateY];
const currentRotate = e.currentTarget.value;
const currentTranslateX = translateX[changedIndex];
const currentTranslateY = translateY[changedIndex];
targetGroup.setAttribute('transform', `rotate(${currentRotate} ${rotationCenter.cx} ${rotationCenter.cy}) translate(${currentTranslateX} ${currentTranslateY})`);
}

const handleFormSubmit: FormEventHandler<HTMLFormElement> = (e) => {
e.preventDefault();
const { submitter } = e.nativeEvent as unknown as { submitter: HTMLButtonElement };
const values = getFormValues(e.currentTarget, { submitter });
console.log(values);
}

return {
isNewAnimationId,
svgAnimations,
loadedFileName,
loadedFileContents,
loadSvgFile,
svgPreviewRef,
svgGroups,
svgAnimationIdsListId,
handleAnimationIdInput,
cancelAnimationIdChange,
focusAnimationIdInput,
blurAnimationIdInput,
addKeyframeAttribute,
enabledGroupIds,
rotateGroup,
translateXGroup,
translateYGroup,
handleFormSubmit,
}
}

const IndexPage = () => {
const {
loadedFileName,
loadedFileContents,
loadSvgFile,
svgPreviewRef,
svgGroups,
svgAnimations,
svgAnimationIdsListId,
handleAnimationIdInput,
isNewAnimationId,
cancelAnimationIdChange,
focusAnimationIdInput,
blurAnimationIdInput,
addKeyframeAttribute,
enabledGroupIds,
rotateGroup,
translateXGroup,
translateYGroup,
handleFormSubmit,
} = useAnimationForm();

return (
<form className="lg:fixed lg:w-full lg:h-full flex flex-col" onSubmit={handleFormSubmit}>
<div className="flex gap-4">
<label>
<input
type="file"
name="svgFile"
accept="*.svg"
onChange={loadSvgFile}
className="sr-only"
/>
<span className="whitespace-nowrap">
Load SVG File
</span>
</label>
{
loadedFileContents
&& (
<>
<button
type="submit"
className="whitespace-nowrap"
>
Save SVG File
</button>
<div className="text-center w-full">
{loadedFileName}
</div>
</>
)
}
</div>
{
loadedFileContents
&& (
<div className="h-full flex flex-col">
<div className="flex gap-4">
<datalist
id={svgAnimationIdsListId}
>
{svgAnimations?.map(s => (
<option key={s}>{s}</option>
))}
</datalist>
<label className="block w-full">
<span className="sr-only">
Animation ID
</span>
<input
list={svgAnimationIdsListId}
name="animationId"
className="w-full block"
defaultValue={svgAnimations?.[0] ?? ''}
onInput={handleAnimationIdInput}
onFocus={focusAnimationIdInput}
onBlur={blurAnimationIdInput}
autoComplete="off"
/>
</label>
{
isNewAnimationId && (
<>
<button
type="submit"
name="action"
value="renameCurrentAnimationId"
className="whitespace-nowrap"
>
Rename
</button>
<button
type="submit"
name="action"
value="createNewAnimation"
className="whitespace-nowrap"
>
Create New
</button>
<button
type="reset"
className="whitespace-nowrap"
onClick={cancelAnimationIdChange}
>
Cancel
</button>
</>
)
}
</div>
<div
className="flex gap-4 h-full"
>
<div className="w-full" ref={svgPreviewRef} dangerouslySetInnerHTML={{ __html: loadedFileContents ?? '' }} />
<div className="w-full">
{
Array.isArray(svgGroups) && (
<>
{svgGroups.map((group) => {
return (
<fieldset
key={group.id}
className="contents"
>
<legend className="sr-only">
{group.name}
</legend>
<div className="flex">
<div className="w-full">
<label>
<input type="checkbox" name="changed" value={group.id} onChange={addKeyframeAttribute} />
{' '}
<span>
{group.name}
</span>
</label>
</div>
<label
className="w-16 block"
>
<span className="sr-only">Translate X</span>
<input
className="w-full block"
type="range"
name="translateX"
min="-360" max="360"
defaultValue="0"
step="any"
disabled={!enabledGroupIds?.includes(group.name)}
onChange={translateXGroup(group)}
/>
</label>
<label
className="w-16 block"
>
<span className="sr-only">Translate Y</span>
<input
className="w-full block"
type="range"
name="translateY"
min="-360" max="360"
defaultValue="0"
step="any"
disabled={!enabledGroupIds?.includes(group.name)}
onChange={translateYGroup(group)}
/>
</label>
<label
className="w-16 block"
>
<span className="sr-only">Rotate</span>
<input
className="w-full block"
type="range"
name="rotate"
min="-360"
max="360"
defaultValue="0"
step="any"
disabled={!enabledGroupIds?.includes(group.name)}
onChange={rotateGroup(group)}
/>
</label>
</div>
</fieldset>
)
})}
</>
)
}
</div>
</div>
<div className="flex gap-4">
<input type="number" name="length" min="1" defaultValue="10" step="1" />
<input className="w-full" type="range" name="time" min="0" max="10" defaultValue="0" step="1" />
<button
type="submit"
name="action"
value="addKeyframe"
className="whitespace-nowrap"
>
Add Keyframe
</button>
</div>
</div>
)
}
</form>
);
}

export default IndexPage;

+ 2
- 0
tools/animation-workbench/app/tailwind.css View File

@@ -0,0 +1,2 @@
@tailwind base;
@tailwind utilities;

+ 31
- 0
tools/animation-workbench/package.json View File

@@ -0,0 +1,31 @@
{
"private": true,
"sideEffects": false,
"scripts": {
"build": "remix build",
"dev": "remix dev",
"start": "remix-serve build",
"typecheck": "tsc"
},
"dependencies": {
"@remix-run/node": "^1.14.3",
"@remix-run/react": "^1.14.3",
"@remix-run/serve": "^1.14.3",
"@theoryofnekomata/formxtra": "^1.0.3",
"isbot": "^3.6.5",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@remix-run/dev": "^1.14.3",
"@remix-run/eslint-config": "^1.14.3",
"@types/react": "^18.0.25",
"@types/react-dom": "^18.0.8",
"eslint": "^8.27.0",
"tailwindcss": "^3.2.7",
"typescript": "^4.8.4"
},
"engines": {
"node": ">=14"
}
}

BIN
tools/animation-workbench/public/favicon.ico View File

Before After

+ 11
- 0
tools/animation-workbench/remix.config.js View File

@@ -0,0 +1,11 @@
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
ignoredRouteFiles: ["**/.*"],
future: {
unstable_tailwind: true,
},
// appDirectory: "app",
// assetsBuildDirectory: "public/build",
// serverBuildPath: "build/index.js",
// publicPath: "/build/",
};

+ 2
- 0
tools/animation-workbench/remix.env.d.ts View File

@@ -0,0 +1,2 @@
/// <reference types="@remix-run/dev" />
/// <reference types="@remix-run/node" />

+ 8
- 0
tools/animation-workbench/tailwind.config.js View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./app/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {},
},
plugins: [],
}

+ 22
- 0
tools/animation-workbench/tsconfig.json View File

@@ -0,0 +1,22 @@
{
"include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2019"],
"isolatedModules": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"moduleResolution": "node",
"resolveJsonModule": true,
"target": "ES2019",
"strict": true,
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
},

// Remix takes care of building everything in `remix build`.
"noEmit": true
}
}

+ 7340
- 0
tools/animation-workbench/yarn.lock
File diff suppressed because it is too large
View File


Loading…
Cancel
Save