2D Run-and-gun shooter inspired by One Man's Doomsday, Counter-Strike, and Metal Slug.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

583 lines
17 KiB

  1. import {
  2. ChangeEventHandler, FocusEventHandler,
  3. FormEventHandler,
  4. MouseEventHandler,
  5. useEffect,
  6. useRef,
  7. useState,
  8. } from 'react';
  9. import { getFormValues, setFormValues } from '@theoryofnekomata/formxtra';
  10. import { Button } from '~/components/Button';
  11. import { FileButton } from '~/components/FileButton';
  12. import * as FileModule from '~/modules/file';
  13. import {ComboBox} from '~/components/ComboBox';
  14. type SvgGroup = {
  15. id: string;
  16. name: string;
  17. }
  18. type KeyframeAttribute = {
  19. group: SvgGroup;
  20. translateX: number;
  21. translateY: number;
  22. rotate: number;
  23. }
  24. type Keyframe = {
  25. time: number;
  26. attributes: KeyframeAttribute[];
  27. }
  28. const useAnimationForm = () => {
  29. const [loadedFileName, setLoadedFileName] = useState<string>();
  30. const [loadedFileContents, setLoadedFileContents] = useState<string>();
  31. const [svgAnimations, setSvgAnimations] = useState<string[]>();
  32. const [svgGroups, setSvgGroups] = useState<SvgGroup[]>();
  33. const [isNewAnimationId, setIsNewAnimationId] = useState<boolean>();
  34. const [currentAnimationId, setCurrentAnimationId] = useState<string>();
  35. const [enabledGroupIds, setEnabledGroupIds] = useState<string[]>();
  36. const [savedSvgKeyframes, setSavedSvgKeyframes] = useState<Keyframe[]>();
  37. const svgPreviewRef = useRef<HTMLDivElement>(null);
  38. const loadSvgFile: ChangeEventHandler<HTMLInputElement> = async (e) => {
  39. const filePicker = e.currentTarget;
  40. if (!filePicker.files) {
  41. return;
  42. }
  43. const [file] = Array.from(filePicker.files);
  44. if (!file) {
  45. return;
  46. }
  47. setLoadedFileName(file.name);
  48. setIsNewAnimationId(false);
  49. const result = await FileModule.readFile(file);
  50. setLoadedFileContents(result);
  51. filePicker.value = ''
  52. }
  53. const selectNewAnimationId: FormEventHandler<HTMLInputElement> = (e) => {
  54. const target = e.currentTarget;
  55. setIsNewAnimationId(false);
  56. setCurrentAnimationId(e.currentTarget.value);
  57. setTimeout(() => {
  58. target.blur();
  59. });
  60. }
  61. const proposeNewAnimationId: FormEventHandler<HTMLInputElement> = () => {
  62. setIsNewAnimationId(true);
  63. }
  64. const cancelAnimationIdChange: MouseEventHandler<HTMLButtonElement> = (e) => {
  65. e.preventDefault();
  66. setIsNewAnimationId(false);
  67. setFormValues(e.currentTarget.form!, {
  68. animationId: currentAnimationId,
  69. })
  70. }
  71. const onLoaded = (loadedFileContents?: string, svgPreviewRefCurrent?: HTMLElement | null) => {
  72. if (!loadedFileContents) {
  73. return
  74. }
  75. if (!svgPreviewRefCurrent) {
  76. return
  77. }
  78. const svgPreviewRoot = svgPreviewRefCurrent.children[0];
  79. setSvgAnimations(['default']);
  80. setSvgGroups(
  81. Array
  82. .from(svgPreviewRoot.children)
  83. .map((g) => ({
  84. id: g.id,
  85. name: g.id,
  86. }))
  87. );
  88. setCurrentAnimationId('default');
  89. setEnabledGroupIds([]); // TODO - compute enabled groupIds based on current animation id
  90. setSavedSvgKeyframes([]); // TODO - load keyframes from file
  91. }
  92. useEffect(() => {
  93. onLoaded(loadedFileContents, svgPreviewRef.current);
  94. }, [loadedFileContents, svgPreviewRef.current]);
  95. const focusAnimationIdInput: FocusEventHandler<HTMLInputElement> = (e) => {
  96. e.currentTarget.value = '';
  97. }
  98. const blurAnimationIdInput: FocusEventHandler<HTMLInputElement> = (e) => {
  99. e.currentTarget.value = currentAnimationId as string;
  100. setIsNewAnimationId(false);
  101. }
  102. const addKeyframeAttribute: ChangeEventHandler<HTMLInputElement> = (e) => {
  103. const { currentTarget } = e;
  104. if (currentTarget.checked) {
  105. setEnabledGroupIds((oldEnabledGroupIds = []) => [...oldEnabledGroupIds, currentTarget.value]);
  106. return
  107. }
  108. setEnabledGroupIds((oldEnabledGroupIds = []) => oldEnabledGroupIds.filter((i) => i !== currentTarget.value));
  109. }
  110. const handleAttributeValueChange = (group: SvgGroup): ChangeEventHandler<HTMLInputElement> => (e) => {
  111. const { current: svgPreviewWrapper } = svgPreviewRef;
  112. if (!svgPreviewWrapper) {
  113. return;
  114. }
  115. const svgPreviewRoot = svgPreviewWrapper.children[0] as SVGSVGElement | null;
  116. if (!svgPreviewRoot) {
  117. return;
  118. }
  119. const targetGroup = Array.from(svgPreviewRoot.children).find(g => g.id === group.id) as SVGGraphicsElement;
  120. if (!targetGroup) {
  121. return;
  122. }
  123. const rotationCenterReference = Array.from(targetGroup.children).find(e => e.tagName === 'circle') as SVGCircleElement | undefined
  124. const rotationCenter = {
  125. cx: rotationCenterReference?.getAttribute?.('cx') ?? 0,
  126. cy: rotationCenterReference?.getAttribute?.('cy') ?? 0,
  127. };
  128. const form = e.currentTarget.form as HTMLFormElement
  129. const values = getFormValues(form, { forceNumberValues: true });
  130. const changed = Array.isArray(values.changed) ? values.changed : [values.changed];
  131. const changedIndex = changed.indexOf(group.id);
  132. const rotate = Array.isArray(values.rotate) ? values.rotate : [values.rotate];
  133. const rotateNumber = Array.isArray(values.rotateNumber) ? values.rotateNumber : [values.rotateNumber];
  134. const translateX = Array.isArray(values.translateX) ? values.translateX : [values.translateX];
  135. const translateY = Array.isArray(values.translateY) ? values.translateY : [values.translateY];
  136. const currentRotate = e.currentTarget.name.startsWith('rotate') ? e.currentTarget.valueAsNumber : rotate[changedIndex];
  137. const currentTranslateX = e.currentTarget.name.startsWith('translateX') ? e.currentTarget.valueAsNumber : translateX[changedIndex];
  138. const currentTranslateY = e.currentTarget.name.startsWith('translateY') ? e.currentTarget.valueAsNumber : translateY[changedIndex];
  139. if (e.currentTarget.name === 'rotate') {
  140. setFormValues(form, {
  141. rotateNumber: rotate,
  142. })
  143. } else if (e.currentTarget.name === 'rotateNumber') {
  144. setFormValues(form, {
  145. rotate: rotateNumber,
  146. })
  147. }
  148. targetGroup.setAttribute('transform', `translate(${currentTranslateX} ${currentTranslateY}) rotate(${currentRotate} ${rotationCenter.cx} ${rotationCenter.cy})`);
  149. }
  150. const handleFormSubmit: FormEventHandler<HTMLFormElement> = (e) => {
  151. e.preventDefault();
  152. const { submitter } = e.nativeEvent as unknown as { submitter: HTMLButtonElement };
  153. const values = getFormValues(e.currentTarget, { submitter, forceNumberValues: true });
  154. const changed = Array.isArray(values.changed) ? values.changed : [values.changed];
  155. const translateX = Array.isArray(values.translateX) ? values.translateX : [values.translateX];
  156. const translateY = Array.isArray(values.translateY) ? values.translateY : [values.translateY];
  157. const rotate = Array.isArray(values.rotate) ? values.rotate : [values.rotate];
  158. switch (values.action) {
  159. case 'updateTimeKeyframes':
  160. if ('changed' in values) {
  161. // TODO handle keyframes on different animations
  162. // TODO update SVG preview when updating current time
  163. setSavedSvgKeyframes((oldSavedSvgKeyframes = []) => {
  164. const newKeyframe = {
  165. time: values.time as number,
  166. attributes: changed.map((attribute, i) => ({
  167. group: {
  168. id: attribute,
  169. name: attribute,
  170. },
  171. translateX: translateX[i],
  172. translateY: translateY[i],
  173. rotate: rotate[i],
  174. })),
  175. };
  176. if (oldSavedSvgKeyframes.some((k) => k.time === values.time as number)) {
  177. return oldSavedSvgKeyframes.map((k) => k.time === values.time ? newKeyframe : k);
  178. }
  179. return [
  180. ...oldSavedSvgKeyframes,
  181. newKeyframe,
  182. ]
  183. })
  184. return;
  185. }
  186. setSavedSvgKeyframes((oldSavedSvgKeyframes = []) => oldSavedSvgKeyframes.filter(k => k.time !== values.time as number));
  187. return;
  188. default:
  189. break;
  190. }
  191. console.log(values);
  192. }
  193. const updateTime: ChangeEventHandler<HTMLInputElement> = (e) => {
  194. const value = e.currentTarget.valueAsNumber;
  195. const form = e.currentTarget.form as HTMLFormElement;
  196. const theSavedSvgKeyframes = savedSvgKeyframes ?? [];
  197. const sortedSavedSvgKeyframes = theSavedSvgKeyframes.sort((k1, k2) => k1.time - k2.time);
  198. const previousKeyframe =
  199. [...sortedSavedSvgKeyframes].reverse().find((k) => k.time <= value)
  200. ?? sortedSavedSvgKeyframes[0];
  201. const nextKeyframe =
  202. sortedSavedSvgKeyframes.find((k) => k.time > value)
  203. ?? sortedSavedSvgKeyframes.slice(-1)[0];
  204. const keyframe = savedSvgKeyframes?.find((k) => k.time === value);
  205. const hasKeyframeRange = (previousKeyframe || nextKeyframe);
  206. if (!keyframe) {
  207. setEnabledGroupIds(svgGroups?.map(g => g.id));
  208. // todo interpolate across individual keyframes
  209. // get previous keyframe with respect to group
  210. // get next keyframe with respect to group
  211. // |----------------|
  212. // |-----|
  213. const interpolatedKeyframe = previousKeyframe;
  214. const interpolatedValues = {
  215. changed: svgGroups?.map(() => false),
  216. translateX: svgGroups?.map((g) => {
  217. if (!hasKeyframeRange) {
  218. return 0;
  219. }
  220. })
  221. }
  222. setTimeout(() => {
  223. // todo set interpolation
  224. setFormValues(form, {
  225. changed: svgGroups?.map(() => false),
  226. translateX: svgGroups?.map(() => 0),
  227. translateY: svgGroups?.map(() => 0),
  228. rotate: svgGroups?.map(() => 0),
  229. rotateNumber: svgGroups?.map(() => 0),
  230. });
  231. setEnabledGroupIds([]);
  232. });
  233. return;
  234. }
  235. const changed = keyframe.attributes.map((a) => a.group.id);
  236. setEnabledGroupIds(changed);
  237. setTimeout(() => {
  238. setFormValues(form, {
  239. changed: changed,
  240. translateX: keyframe.attributes.map((a) => a.translateX),
  241. translateY: keyframe.attributes.map((a) => a.translateY),
  242. rotate: keyframe.attributes.map((a) => a.rotate),
  243. rotateNumber: keyframe.attributes.map((a) => a.rotate),
  244. });
  245. });
  246. }
  247. return {
  248. isNewAnimationId,
  249. svgAnimations,
  250. loadedFileName,
  251. loadedFileContents,
  252. loadSvgFile,
  253. svgPreviewRef,
  254. svgGroups,
  255. selectNewAnimationId,
  256. proposeNewAnimationId,
  257. cancelAnimationIdChange,
  258. focusAnimationIdInput,
  259. blurAnimationIdInput,
  260. addKeyframeAttribute,
  261. enabledGroupIds,
  262. handleAttributeValueChange,
  263. handleFormSubmit,
  264. savedSvgKeyframes,
  265. updateTime,
  266. }
  267. }
  268. const IndexPage = () => {
  269. const {
  270. loadedFileName,
  271. loadedFileContents,
  272. loadSvgFile,
  273. svgPreviewRef,
  274. svgGroups,
  275. svgAnimations,
  276. selectNewAnimationId,
  277. proposeNewAnimationId,
  278. isNewAnimationId,
  279. cancelAnimationIdChange,
  280. focusAnimationIdInput,
  281. blurAnimationIdInput,
  282. addKeyframeAttribute,
  283. enabledGroupIds,
  284. handleAttributeValueChange,
  285. handleFormSubmit,
  286. savedSvgKeyframes,
  287. updateTime,
  288. } = useAnimationForm();
  289. return (
  290. <form className="lg:fixed lg:w-full lg:h-full flex flex-col" onSubmit={handleFormSubmit}>
  291. <datalist id="rotationTickMarks">
  292. <option value="-360">-360</option>
  293. <option value="-270">-270</option>
  294. <option value="-180">-180</option>
  295. <option value="-90">-90</option>
  296. <option value="0">0</option>
  297. <option value="90">90</option>
  298. <option value="180">180</option>
  299. <option value="270">270</option>
  300. <option value="360">360</option>
  301. </datalist>
  302. <datalist id="timeTickMarks">
  303. {savedSvgKeyframes?.map((k) => (
  304. <option
  305. key={k.time}
  306. value={k.time}
  307. >
  308. {k.time}
  309. </option>
  310. ))}
  311. </datalist>
  312. <div className="flex gap-4 px-4 py-2 items-center">
  313. <FileButton
  314. name="svgFile"
  315. accept="*.svg"
  316. onChange={loadSvgFile}
  317. className="whitespace-nowrap"
  318. >
  319. Load SVG File
  320. </FileButton>
  321. {
  322. loadedFileContents
  323. && (
  324. <>
  325. <Button
  326. type="submit"
  327. className="whitespace-nowrap"
  328. name="action"
  329. value="saveSvgFile"
  330. >
  331. Save SVG File
  332. </Button>
  333. <div className="text-center w-full">
  334. {loadedFileName}
  335. </div>
  336. </>
  337. )
  338. }
  339. </div>
  340. {
  341. loadedFileContents
  342. && (
  343. <div className="h-full flex flex-col">
  344. <div className="flex gap-4">
  345. <label className="block w-full">
  346. <span className="sr-only">
  347. Animation ID
  348. </span>
  349. <ComboBox
  350. name="animationId"
  351. defaultValue={svgAnimations?.[0] ?? ''}
  352. onOptionSelect={selectNewAnimationId}
  353. onCustomValueInput={proposeNewAnimationId}
  354. onFocus={focusAnimationIdInput}
  355. onBlur={blurAnimationIdInput}
  356. autoComplete="off"
  357. >
  358. {svgAnimations?.map(s => (
  359. <option key={s}>{s}</option>
  360. ))}
  361. </ComboBox>
  362. </label>
  363. {
  364. isNewAnimationId && (
  365. <>
  366. <Button
  367. type="submit"
  368. name="action"
  369. value="renameCurrentAnimationId"
  370. className="whitespace-nowrap"
  371. >
  372. Rename
  373. </Button>
  374. <Button
  375. type="submit"
  376. name="action"
  377. value="createNewAnimation"
  378. className="whitespace-nowrap"
  379. >
  380. Create New
  381. </Button>
  382. <Button
  383. type="reset"
  384. className="whitespace-nowrap"
  385. onClick={cancelAnimationIdChange}
  386. >
  387. Cancel
  388. </Button>
  389. </>
  390. )
  391. }
  392. </div>
  393. <div
  394. className="flex gap-4 h-full"
  395. >
  396. <div
  397. className="overflow-auto bg-[black] relative w-full"
  398. >
  399. <div
  400. className="absolute p-4"
  401. >
  402. <div
  403. className="bg-[white] p-4"
  404. ref={svgPreviewRef}
  405. dangerouslySetInnerHTML={{ __html: loadedFileContents ?? '' }}
  406. />
  407. </div>
  408. </div>
  409. <div
  410. style={{
  411. width: 700
  412. }}
  413. >
  414. {
  415. Array.isArray(svgGroups) && (
  416. <div className="grid grid-cols-10">
  417. <div className="col-span-4">
  418. Group
  419. </div>
  420. <div>
  421. X
  422. </div>
  423. <div>
  424. Y
  425. </div>
  426. <div className="col-span-4">
  427. Rotate
  428. </div>
  429. {svgGroups.map((group) => {
  430. return (
  431. <fieldset
  432. key={group.id}
  433. className="contents"
  434. >
  435. <legend className="sr-only">
  436. {group.name}
  437. </legend>
  438. <div className="col-span-4">
  439. <label
  440. className="whitespace-nowrap"
  441. >
  442. <input type="checkbox" name="changed" value={group.id} onChange={addKeyframeAttribute} />
  443. {' '}
  444. <span>
  445. {group.name}
  446. </span>
  447. </label>
  448. </div>
  449. <label
  450. className="block"
  451. >
  452. <span className="sr-only">Translate X</span>
  453. <input
  454. className="w-full block text-right"
  455. type="number"
  456. name="translateX"
  457. min="-360" max="360"
  458. defaultValue="0"
  459. step="any"
  460. disabled={!enabledGroupIds?.includes(group.name)}
  461. onChange={handleAttributeValueChange(group)}
  462. />
  463. </label>
  464. <label
  465. className="block"
  466. >
  467. <span className="sr-only">Translate Y</span>
  468. <input
  469. className="w-full block text-right"
  470. type="number"
  471. name="translateY"
  472. min="-360" max="360"
  473. defaultValue="0"
  474. step="any"
  475. disabled={!enabledGroupIds?.includes(group.name)}
  476. onChange={handleAttributeValueChange(group)}
  477. />
  478. </label>
  479. <label
  480. className="block col-span-3"
  481. >
  482. <span className="sr-only">Rotate</span>
  483. <input
  484. className="w-full block"
  485. type="range"
  486. name="rotate"
  487. min="-360"
  488. max="360"
  489. defaultValue="0"
  490. step="5"
  491. disabled={!enabledGroupIds?.includes(group.name)}
  492. onChange={handleAttributeValueChange(group)}
  493. list="rotationTickMarks"
  494. />
  495. </label>
  496. <label
  497. className="block"
  498. >
  499. <span className="sr-only">Rotate</span>
  500. <input
  501. className="w-full block text-right"
  502. type="number"
  503. name="rotateNumber"
  504. min="-360"
  505. max="360"
  506. defaultValue="0"
  507. step="5"
  508. disabled={!enabledGroupIds?.includes(group.name)}
  509. onChange={handleAttributeValueChange(group)}
  510. />
  511. </label>
  512. </fieldset>
  513. )
  514. })}
  515. </div>
  516. )
  517. }
  518. </div>
  519. </div>
  520. <div className="flex gap-4 items-center px-4 py-2">
  521. <div
  522. className="w-16"
  523. >
  524. <input type="number" name="length" min="1" defaultValue="10" step="1" className="block w-full" />
  525. </div>
  526. <input
  527. className="w-full"
  528. list="timeTickMarks"
  529. type="range"
  530. name="time"
  531. min="0"
  532. max="10"
  533. defaultValue="0"
  534. step="1"
  535. onChange={updateTime}
  536. />
  537. <Button
  538. type="submit"
  539. name="action"
  540. value="updateTimeKeyframes"
  541. className="whitespace-nowrap"
  542. >
  543. Update Current Time
  544. </Button>
  545. </div>
  546. </div>
  547. )
  548. }
  549. </form>
  550. );
  551. }
  552. export default IndexPage;