From fbeee2bb869254ad279356185968f2b2eb7ab6e1 Mon Sep 17 00:00:00 2001 From: cha0s Date: Mon, 11 Mar 2024 23:03:35 -0500 Subject: [PATCH] chore: initial --- .eslintrc.cjs | 68 + .gitignore | 5 + .vscode/launch.json | 17 + README.md | 78 + app/answers/index.jsx | 116 + app/answers/index.module.css | 171 + app/black-card-text/index.jsx | 86 + app/black-card-text/index.module.css | 27 + app/entry.client.jsx | 18 + app/entry.server.jsx | 155 + app/fluid-text/index.jsx | 60 + app/fluid-text/index.module.css | 13 + app/fluid-text/initializer.jsx | 18 + app/fonts/caladea.woff | Bin 0 -> 32032 bytes app/fonts/flashrogers.ttf | Bin 0 -> 32908 bytes app/fonts/flashrogerschrome.ttf | Bin 0 -> 37464 bytes app/fonts/flashrogershalf.ttf | Bin 0 -> 58040 bytes app/fonts/flashrogerspunch.ttf | Bin 0 -> 82232 bytes app/fonts/index.css | 19 + app/fonts/shark.ttf | Bin 0 -> 40620 bytes app/fonts/smack.ttf | Bin 0 -> 19840 bytes app/hooks/context.js | 5 + app/hooks/index.js | 4 + app/hooks/use-game.js | 7 + app/hooks/use-is-hydrated.js | 7 + app/hooks/use-selection.js | 7 + app/hooks/use-session.js | 7 + app/root.css | 106 + app/root.jsx | 42 + app/root.module.css | 4 + app/routes/_intro._index/landing.module.css | 131 + app/routes/_intro._index/route.jsx | 32 + app/routes/_intro.create/config.js | 3 + app/routes/_intro.create/index.module.css | 257 + app/routes/_intro.create/pack-choice.jsx | 36 + .../_intro.create/pack-choice.module.css | 71 + app/routes/_intro.create/route.jsx | 158 + app/routes/_intro.join/games/game/index.jsx | 49 + .../_intro.join/games/game/index.module.css | 39 + app/routes/_intro.join/games/index.jsx | 16 + app/routes/_intro.join/games/index.module.css | 5 + app/routes/_intro.join/index.jsx | 80 + app/routes/_intro.join/index.module.css | 76 + app/routes/_intro/intro.jpg | Bin 0 -> 235611 bytes app/routes/_intro/intro.module.css | 17 + app/routes/_intro/route.jsx | 14 + app/routes/play.$gameKey.actions/route.jsx | 28 + app/routes/play.$gameKey.messages/route.jsx | 142 + app/routes/play.$gameKey/bar/chat-button.jsx | 19 + app/routes/play.$gameKey/bar/controls.jsx | 95 + app/routes/play.$gameKey/bar/index.jsx | 47 + app/routes/play.$gameKey/bar/index.module.css | 144 + app/routes/play.$gameKey/bar/mute-button.jsx | 46 + .../play.$gameKey/bar/timeout/index.jsx | 56 + .../bar/timeout/index.module.css | 6 + .../bar/timeout/number-initializer.jsx | 21 + .../play.$gameKey/bar/timeout/number.jsx | 50 + .../bar/timeout/number.module.css | 7 + app/routes/play.$gameKey/index.module.css | 49 + .../play.$gameKey/players/chat/form.jsx | 53 + .../play.$gameKey/players/chat/index.jsx | 62 + .../players/chat/index.module.css | 49 + .../players/chat/message/index.jsx | 43 + .../players/chat/message/index.module.css | 84 + app/routes/play.$gameKey/players/index.jsx | 29 + .../play.$gameKey/players/index.module.css | 52 + .../play.$gameKey/players/list/index.jsx | 219 + .../players/list/index.module.css | 157 + app/routes/play.$gameKey/route.jsx | 230 + .../play.$gameKey/status/answering/black.jsx | 19 + .../play.$gameKey/status/answering/index.js | 2 + .../status/answering/index.module.css | 39 + .../play.$gameKey/status/answering/white.jsx | 59 + .../play.$gameKey/status/awarded/black.jsx | 15 + .../play.$gameKey/status/awarded/index.js | 2 + .../status/awarded/index.module.css | 14 + .../play.$gameKey/status/awarded/white.jsx | 26 + .../play.$gameKey/status/awarding/black.jsx | 16 + .../play.$gameKey/status/awarding/index.js | 2 + .../status/awarding/index.module.css | 18 + .../play.$gameKey/status/awarding/white.jsx | 67 + .../play.$gameKey/status/finished/black.jsx | 25 + .../play.$gameKey/status/finished/index.js | 2 + .../status/finished/index.module.css | 49 + .../play.$gameKey/status/finished/white.jsx | 61 + .../play.$gameKey/status/paused/black.jsx | 54 + .../play.$gameKey/status/paused/index.js | 2 + .../status/paused/index.module.css | 39 + .../play.$gameKey/status/paused/white.jsx | 19 + .../play.$gameKey/status/starting/black.jsx | 74 + .../status/starting/black.module.css | 88 + .../play.$gameKey/status/starting/index.js | 2 + .../play.$gameKey/status/starting/white.jsx | 27 + .../status/starting/white.module.css | 54 + app/routes/play.$gameKey_.chat-form/route.jsx | 3 + app/state/cards.js | 142 + app/state/cards.test.js | 24 + app/state/cookie.server.js | 20 + app/state/game.js | 744 + app/state/game.server.js | 255 + app/state/game.test.js | 93 + app/state/globals.js | 20 + app/state/namer.js | 40 + app/state/session.js | 32 + app/utils/action-source.js | 17 + app/utils/emitter.js | 14 + app/utils/event-source.js | 14 + app/utils/long-poll.js | 7 + app/utils/singleton.js | 9 + app/utils/stream.js | 30 + app/utils/top-level-long-poll.js | 33 + data/packs/.gitignore | 2 + data/tokens/.gitignore | 2 + package-lock.json | 13107 ++++++++++++++++ package.json | 49 + public/favicon.ico | Bin 0 -> 43310 bytes screenshot-1.png | Bin 0 -> 939909 bytes screenshot-2.png | Bin 0 -> 1009379 bytes screenshot-3.png | Bin 0 -> 1129349 bytes screenshot-4.png | Bin 0 -> 305629 bytes server.js | 68 + vite.config.js | 6 + 122 files changed, 19218 insertions(+) create mode 100644 .eslintrc.cjs create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 README.md create mode 100644 app/answers/index.jsx create mode 100644 app/answers/index.module.css create mode 100644 app/black-card-text/index.jsx create mode 100644 app/black-card-text/index.module.css create mode 100644 app/entry.client.jsx create mode 100644 app/entry.server.jsx create mode 100644 app/fluid-text/index.jsx create mode 100644 app/fluid-text/index.module.css create mode 100644 app/fluid-text/initializer.jsx create mode 100644 app/fonts/caladea.woff create mode 100644 app/fonts/flashrogers.ttf create mode 100644 app/fonts/flashrogerschrome.ttf create mode 100644 app/fonts/flashrogershalf.ttf create mode 100644 app/fonts/flashrogerspunch.ttf create mode 100644 app/fonts/index.css create mode 100644 app/fonts/shark.ttf create mode 100644 app/fonts/smack.ttf create mode 100644 app/hooks/context.js create mode 100644 app/hooks/index.js create mode 100644 app/hooks/use-game.js create mode 100644 app/hooks/use-is-hydrated.js create mode 100644 app/hooks/use-selection.js create mode 100644 app/hooks/use-session.js create mode 100644 app/root.css create mode 100644 app/root.jsx create mode 100644 app/root.module.css create mode 100644 app/routes/_intro._index/landing.module.css create mode 100644 app/routes/_intro._index/route.jsx create mode 100644 app/routes/_intro.create/config.js create mode 100644 app/routes/_intro.create/index.module.css create mode 100644 app/routes/_intro.create/pack-choice.jsx create mode 100644 app/routes/_intro.create/pack-choice.module.css create mode 100644 app/routes/_intro.create/route.jsx create mode 100644 app/routes/_intro.join/games/game/index.jsx create mode 100644 app/routes/_intro.join/games/game/index.module.css create mode 100644 app/routes/_intro.join/games/index.jsx create mode 100644 app/routes/_intro.join/games/index.module.css create mode 100644 app/routes/_intro.join/index.jsx create mode 100644 app/routes/_intro.join/index.module.css create mode 100644 app/routes/_intro/intro.jpg create mode 100644 app/routes/_intro/intro.module.css create mode 100644 app/routes/_intro/route.jsx create mode 100644 app/routes/play.$gameKey.actions/route.jsx create mode 100644 app/routes/play.$gameKey.messages/route.jsx create mode 100644 app/routes/play.$gameKey/bar/chat-button.jsx create mode 100644 app/routes/play.$gameKey/bar/controls.jsx create mode 100644 app/routes/play.$gameKey/bar/index.jsx create mode 100644 app/routes/play.$gameKey/bar/index.module.css create mode 100644 app/routes/play.$gameKey/bar/mute-button.jsx create mode 100644 app/routes/play.$gameKey/bar/timeout/index.jsx create mode 100644 app/routes/play.$gameKey/bar/timeout/index.module.css create mode 100644 app/routes/play.$gameKey/bar/timeout/number-initializer.jsx create mode 100644 app/routes/play.$gameKey/bar/timeout/number.jsx create mode 100644 app/routes/play.$gameKey/bar/timeout/number.module.css create mode 100644 app/routes/play.$gameKey/index.module.css create mode 100644 app/routes/play.$gameKey/players/chat/form.jsx create mode 100644 app/routes/play.$gameKey/players/chat/index.jsx create mode 100644 app/routes/play.$gameKey/players/chat/index.module.css create mode 100644 app/routes/play.$gameKey/players/chat/message/index.jsx create mode 100644 app/routes/play.$gameKey/players/chat/message/index.module.css create mode 100644 app/routes/play.$gameKey/players/index.jsx create mode 100644 app/routes/play.$gameKey/players/index.module.css create mode 100644 app/routes/play.$gameKey/players/list/index.jsx create mode 100644 app/routes/play.$gameKey/players/list/index.module.css create mode 100644 app/routes/play.$gameKey/route.jsx create mode 100644 app/routes/play.$gameKey/status/answering/black.jsx create mode 100644 app/routes/play.$gameKey/status/answering/index.js create mode 100644 app/routes/play.$gameKey/status/answering/index.module.css create mode 100644 app/routes/play.$gameKey/status/answering/white.jsx create mode 100644 app/routes/play.$gameKey/status/awarded/black.jsx create mode 100644 app/routes/play.$gameKey/status/awarded/index.js create mode 100644 app/routes/play.$gameKey/status/awarded/index.module.css create mode 100644 app/routes/play.$gameKey/status/awarded/white.jsx create mode 100644 app/routes/play.$gameKey/status/awarding/black.jsx create mode 100644 app/routes/play.$gameKey/status/awarding/index.js create mode 100644 app/routes/play.$gameKey/status/awarding/index.module.css create mode 100644 app/routes/play.$gameKey/status/awarding/white.jsx create mode 100644 app/routes/play.$gameKey/status/finished/black.jsx create mode 100644 app/routes/play.$gameKey/status/finished/index.js create mode 100644 app/routes/play.$gameKey/status/finished/index.module.css create mode 100644 app/routes/play.$gameKey/status/finished/white.jsx create mode 100644 app/routes/play.$gameKey/status/paused/black.jsx create mode 100644 app/routes/play.$gameKey/status/paused/index.js create mode 100644 app/routes/play.$gameKey/status/paused/index.module.css create mode 100644 app/routes/play.$gameKey/status/paused/white.jsx create mode 100644 app/routes/play.$gameKey/status/starting/black.jsx create mode 100644 app/routes/play.$gameKey/status/starting/black.module.css create mode 100644 app/routes/play.$gameKey/status/starting/index.js create mode 100644 app/routes/play.$gameKey/status/starting/white.jsx create mode 100644 app/routes/play.$gameKey/status/starting/white.module.css create mode 100644 app/routes/play.$gameKey_.chat-form/route.jsx create mode 100644 app/state/cards.js create mode 100644 app/state/cards.test.js create mode 100644 app/state/cookie.server.js create mode 100644 app/state/game.js create mode 100644 app/state/game.server.js create mode 100644 app/state/game.test.js create mode 100644 app/state/globals.js create mode 100644 app/state/namer.js create mode 100644 app/state/session.js create mode 100644 app/utils/action-source.js create mode 100644 app/utils/emitter.js create mode 100644 app/utils/event-source.js create mode 100644 app/utils/long-poll.js create mode 100644 app/utils/singleton.js create mode 100644 app/utils/stream.js create mode 100644 app/utils/top-level-long-poll.js create mode 100644 data/packs/.gitignore create mode 100644 data/tokens/.gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/favicon.ico create mode 100644 screenshot-1.png create mode 100644 screenshot-2.png create mode 100644 screenshot-3.png create mode 100644 screenshot-4.png create mode 100644 server.js create mode 100644 vite.config.js diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..9d7ed08 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,68 @@ +/** + * This is intended to be a basic starting point for linting in your app. + * It relies on recommended configs out of the box for simplicity, but you can + * and should modify this configuration to best suit your team's needs. + */ + +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, + }, + env: { + browser: true, + commonjs: true, + es6: true, + }, + + // Base config + extends: ["eslint:recommended"], + + overrides: [ + // React + { + files: ["**/*.{js,jsx}"], + plugins: ["react", "jsx-a11y"], + extends: [ + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + "plugin:jsx-a11y/recommended", + ], + settings: { + react: { + version: "detect", + }, + formComponents: ["Form"], + linkComponents: [ + { name: "Link", linkAttribute: "to" }, + { name: "NavLink", linkAttribute: "to" }, + ], + "import/resolver": { + typescript: {}, + }, + }, + }, + + // mocha + { + files: ['**/*.test.js'], + globals: { + it: true, + }, + }, + + // Node + { + files: [".eslintrc.cjs", "server.js"], + env: { + node: true, + }, + }, + ], +}; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80ec311 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules + +/.cache +/build +.env diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..e0cc3c5 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Terrible", + "skipFiles": [ + "/**" + ], + "resolveSourceMapLocations": [], + "cwd": "${workspaceFolder}", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..11d05c4 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# Welcome and Do Terrible! + +Do Terrible is a **Commentary Game**. You might have heard of another similar game called Cards +Against Humanity. I want to make it **absolutely clear** that I have nothing to do with them. + +An instance of the game is hosted at https://doterrible.com. Have fun! + +## JS not required (and other marvels) + +The game is perfectly playable including real-time game state updates and chat (with autoscroll +and user name/presence updates) even when JavaScript is disabled. + +I found the [(Ab)use the Platform](https://github.com/jenseng/abuse-the-platform) project very +inspirational and I've always fancied the idea of doing things without requiring script. This +project is a successful experiment in delivering a polished real-time gaming experience without requiring +a client to execute any script whatsoever. + +A list of interesting technologies and techniques used in this project: + +- [Remix](https://remix.run) +- [Vite](https://vitejs.dev/) +- [Forms](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form) +- [Progressive enhancement](https://developer.mozilla.org/en-US/docs/Glossary/Progressive_Enhancement) +- [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response), [Server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events), and [long polling](https://javascript.info/long-polling) including at the [top level](./app/utils/top-level-long-poll.js) to transform the +[React pipeable stream](https://react.dev/reference/react-dom/server/renderToPipeableStream) +output +- [Algorithm L reservoir sampling](https://en.wikipedia.org/wiki/Reservoir_sampling#Optimal:_Algorithm_L) + to efficiently generate custom decks for each game from a large pool of packs +- Dynamic text scaling (when scripts are enabled) + +## Screenshots + +![A screenshot of the intro page](./screenshot-1.png) +![A screenshot of the game creation page](./screenshot-2.png) +![A screenshot of the game join page](./screenshot-3.png) +![A screenshot of the game being played](./screenshot-4.png) + +## Running locally + +### Install + +Install the dependencies with `npm`: + +```sh +npm install +``` + +### Get some packs + +You must install some packs before you can play. Check out +https://github.com/crhallberg/json-against-humanity and copy e.g. `cah-all-full.json` to the +`data/packs` directory. + +**NOTE**: The grammatical quality of many of those packs (even/especially the Official™ ones) +leaves a great deal to be desired. + +### Play + +Finally, you may: + +```sh +npm run dev +``` + +to run a dev server. + +## Production build + +```sh +npm run build +``` + +After that, you're on your own, hero. :slightly_smiling_face: + +## Hey, you screwed up [whatever] + +Probably! Feel free to create issues and tell me what I screwed up. :) The no-js acrobatics are +probably terrible for accessibility. I'd love to know how to improve! diff --git a/app/answers/index.jsx b/app/answers/index.jsx new file mode 100644 index 0000000..61188a7 --- /dev/null +++ b/app/answers/index.jsx @@ -0,0 +1,116 @@ +import {useFetcher} from '@remix-run/react'; +import PropTypes from 'prop-types'; + +import FluidText from '#fluid-text'; +import {useIsHydrated, useSelection} from '#hooks'; + +import styles from './index.module.css'; + +function Answers({ + choices, + className, + count, + disabled, +}) { + const fetcher = useFetcher({key: 'answers'}); + const isHydrated = useIsHydrated(); + const [selection, setSelection] = useSelection(); + return ( + + + {selection.length > 0 && ( + selection.map((answer) => ( + + )) + )} +
+ { + count === selection.length + ? ( +
+ + +
+ ) + : ( + choices.map((choice, i) => ( + + )) + ) + } +
+
+ ); +} + +Answers.defaultProps = { + choices: [], + className: '', + disabled: false, +}; + +Answers.propTypes = { + choices: PropTypes.arrayOf(PropTypes.oneOfType([ + PropTypes.string, + PropTypes.node, + ])), + count: PropTypes.number.isRequired, + className: PropTypes.string, + disabled: PropTypes.bool, +}; + +Answers.displayName = 'Answers'; + +export default Answers; diff --git a/app/answers/index.module.css b/app/answers/index.module.css new file mode 100644 index 0000000..706f01a --- /dev/null +++ b/app/answers/index.module.css @@ -0,0 +1,171 @@ +.answers { + height: 100%; + width: 100%; +} + +.buttons { + align-content: flex-start; + display: flex; + flex-wrap: wrap; + height: 100%; + width: 100%; +} + +.answer { + align-items: center; + background: #dfdfdf; + border: none; + height: calc(33.333% - 2vh); + margin: 1vh; + padding: 0; + width: calc(33.333% - 2vh); + &:nth-child(1):nth-last-child(2), + &:nth-child(2):nth-last-child(1) { + height: calc(50% - 2vh); + width: calc(100% - 2vh); + } + &:nth-child(1):nth-last-child(3), + &:nth-child(2):nth-last-child(2), + &:nth-child(3):nth-last-child(1) { + width: calc(100% - 2vh); + } + &:nth-child(1):nth-last-child(4), + &:nth-child(2):nth-last-child(3), + &:nth-child(3):nth-last-child(2), + &:nth-child(4):nth-last-child(1) { + height: calc(50% - 2vh); + width: calc(50% - 2vh); + } + &:nth-child(1):nth-last-child(5), + &:nth-child(2):nth-last-child(4), + &:nth-child(3):nth-last-child(3), + &:nth-child(4):nth-last-child(2), + &:nth-child(5):nth-last-child(1) { + width: calc(50% - 2vh); + } + &:nth-child(1):nth-last-child(6), + &:nth-child(2):nth-last-child(5), + &:nth-child(3):nth-last-child(4), + &:nth-child(4):nth-last-child(3), + &:nth-child(5):nth-last-child(2), + &:nth-child(6):nth-last-child(1) { + width: calc(50% - 2vh); + } + &:disabled { + color: #070707; + } + &:focus { + box-shadow: none; + } + &.selected .text { + background-color: #f7f7f7; + &:hover { + background-color: #fff; + }; + } + &:not(:disabled) .text:hover { + background: + linear-gradient(to top left, transparent 90%, #eee), + linear-gradient(to bottom right, transparent 30%, #fff 60%, transparent 90%) + ; + background-color: #f4f4f4; + color: #070707; + cursor: pointer; + }; + &.selected .text { + border: 1px solid #777; + + .left, .top { + background: none; + } + } +} + +.separator { + opacity: 0.4; +} + +.text { + background: + linear-gradient(to top left, transparent 90%, #ccc), + linear-gradient(to bottom right, transparent 30%, #e9e9e9 60%, transparent 90%) + ; + border: 1px solid transparent; + box-shadow: 1px 1px 1px #bbb; + height: 100%; + width: 100%; +} + +.choice { + padding: 2vh 3.5vw; +} + +.left { + height: 100%; + width: 1px; + background: linear-gradient(to top, transparent 80%, #777); + left: -1px; + top: 0px; + position: absolute; +} + +.top { + height: 1px; + width: 100%; + background: linear-gradient(to left, transparent 75%, #777); + left: -1px; + top: -1px; + position: absolute; + transform: translateY(-0px); +} + +@keyframes wiggle { + from { transform: rotate(-12deg); } + 50% { transform: rotate(12deg); } + to { transform: rotate(-12deg); } +} + +.submit { + align-items: center; + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-around; + width: 100%; + button { + border-radius: 5px; + color: #f64030; + max-width: 90%; + padding: 2vh; + &[name="confirm"] { + background-color: #eee; + border-radius: 0; + border: 7px solid #ccc; + color: #d68030; + font-family: Strike; + font-size: 10vw; + height: 50%; + padding: 3.5rem; + transition: box-shadow 200ms; + &:hover { + box-shadow: 1px 1px 5px black; + } + } + &[name="cancel"] { + background-color: transparent; + border: none; + color: #777; + font-size: 5vw; + height: 20%; + text-underline-offset: 0.4em; + &:hover { + text-decoration: underline; + } + } + } +} + +.inside { + height: 100%; + width: 100%; +} diff --git a/app/black-card-text/index.jsx b/app/black-card-text/index.jsx new file mode 100644 index 0000000..e9b97a7 --- /dev/null +++ b/app/black-card-text/index.jsx @@ -0,0 +1,86 @@ +import PropTypes from 'prop-types'; + +import FluidText from '#fluid-text'; +import {useGame} from '#hooks'; + +import styles from './index.module.css'; + +const capitalize = (string) => string.slice(0, 1).toUpperCase() + string.slice(1); + +function BlackCardText(props) { + const {answers} = props; + const game = useGame(); + const parts = game.blackCard.split('_'); + const blanksCount = parts.length - 1; + const decoratedText = parts.reduce((r, part, i) => ( + <> + {r} + { + 0 === i + ? '' + : ( + + { + // Capitalize the first letter if the blank is the first letter. + ( + ( + ( + 1 === i + && '_'.charCodeAt(0) === game.blackCard.charCodeAt(0) + ) + || '. ' === parts[i - 1] + ) + ? capitalize + : (_) => _ + )(answers[i - 1] || '_____') + } + + ) + } + {part} + + ), ''); + return ( +
+ + { + 0 === blanksCount + ? ( + <> + {game.blackCard} +
+ + {answers[0] ? `${capitalize(answers[0])}` : '_____'} + + . +
+ + ) + : decoratedText + } +
+
+ ); +} + +BlackCardText.defaultProps = { + answers: [], + style: {}, +}; + +BlackCardText.propTypes = { + answers: PropTypes.arrayOf(PropTypes.string), + style: PropTypes.shape({}), +}; + +export default BlackCardText; diff --git a/app/black-card-text/index.module.css b/app/black-card-text/index.module.css new file mode 100644 index 0000000..8f268c7 --- /dev/null +++ b/app/black-card-text/index.module.css @@ -0,0 +1,27 @@ +.black-card-text { + font-size: 3vh; + > div { + hyphens: auto; + text-align: left; + } +} + +.answer { + color: #fff; + text-decoration: underline; + text-underline-offset: 0.125em; + &.blank { + font-family: arial; + } +} + +.footer { + margin-top: 1em; +} + +.number { + font-family: caladea; + font-style: italic; + font-weight: bold; + font-size: 0.85em; +} diff --git a/app/entry.client.jsx b/app/entry.client.jsx new file mode 100644 index 0000000..94d5dc0 --- /dev/null +++ b/app/entry.client.jsx @@ -0,0 +1,18 @@ +/** + * By default, Remix will handle hydrating your app on the client for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.client + */ + +import { RemixBrowser } from "@remix-run/react"; +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; + +startTransition(() => { + hydrateRoot( + document, + + + + ); +}); diff --git a/app/entry.server.jsx b/app/entry.server.jsx new file mode 100644 index 0000000..4a63d7d --- /dev/null +++ b/app/entry.server.jsx @@ -0,0 +1,155 @@ +import {readdir, readFile} from 'node:fs/promises'; +import {basename, join} from 'node:path'; +import {PassThrough} from 'node:stream'; + +import {createReadableStreamFromReadable} from '@remix-run/node'; +import {RemixServer} from '@remix-run/react'; +import {isbot} from 'isbot'; +import color from 'picocolors'; +import {renderToPipeableStream} from 'react-dom/server'; + +import {readJsonPacks} from '#state/cards'; +import {Game} from '#state/game'; + +const ABORT_DELAY = 5_000; + +(async () => { + const {createGameServerLoop} = await import('#state/game.server'); + const loadStart = Date.now(); + [Game.packs, Game.tokens] = await Promise.all([ + readdir('data/packs').then(async (paths) => ( + (await Promise.all( + paths + .filter((path) => path.endsWith('.json')) + .map(async (path) => ( + readJsonPacks(JSON.parse(await readFile(join('data/packs', path)))) + )), + )) + .flat() + )), + readdir('data/tokens').then((paths) => ( + paths + .filter((path) => path.endsWith('.json')) + .reduce(async (tokens, path) => ({ + ...(await tokens), + [basename(path, '.json')]: JSON.parse(await readFile(join('data/tokens', path))), + }), {}) + )), + ]); + Game.packs = Game.packs.map((pack, i) => ({...pack, id: i})); + console.log( + 'Loaded %s packs and %s tokens in %s ms', + color.yellow(Game.packs.length), + color.yellow(Object.keys(Game.tokens).length), + color.cyan(Date.now() - loadStart), + ); + createGameServerLoop(); +})(); + +export default function handleRequest( + request, + responseStatusCode, + responseHeaders, + remixContext +) { + return isbot(request.headers.get('user-agent') || '') + ? handleBotRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ) + : handleBrowserRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ); +} + +function handleBotRequest( + request, + responseStatusCode, + responseHeaders, + remixContext +) { + return new Promise((resolve, reject) => { + const {pipe, abort} = renderToPipeableStream( + , + { + onAllReady() { + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set('Content-Type', 'text/html'); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error) { + reject(error); + }, + onError(error) { + responseStatusCode = 500; + console.error(error); + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); +} + +function handleBrowserRequest( + request, + responseStatusCode, + responseHeaders, + remixContext +) { + return new Promise((resolve, reject) => { + const {pipe, abort} = renderToPipeableStream( + , + { + async onShellReady() { + const {requestBody} = await import('#state/game.server'); + const body = await requestBody(request); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set('Content-Type', 'text/html'); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error) { + reject(error); + }, + onError(error) { + console.error(error); + responseStatusCode = 500; + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); +} diff --git a/app/fluid-text/index.jsx b/app/fluid-text/index.jsx new file mode 100644 index 0000000..23e1b3b --- /dev/null +++ b/app/fluid-text/index.jsx @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import {useEffect, useRef} from 'react'; + +import styles from './index.module.css'; + +function FluidText({children}) { + const ref = useRef(); + useEffect(() => { + const {current} = ref; + if (!current) { + return; + } + function resize() { + const started = Date.now(); + current.style.opacity = 0; + const {parentNode} = current; + const doesContain = () => ( + current.clientWidth <= parentNode.clientWidth + && current.clientHeight <= parentNode.clientHeight + ); + let fontSizeInPx = 1; + const setFontSize = () => { + current.style.fontSize = `${fontSizeInPx}px`; + }; + setFontSize(); + while (doesContain() && (Date.now() - started) < 500) { + fontSizeInPx += 16; + setFontSize(); + } + fontSizeInPx -= 16; + setFontSize(); + while (doesContain() && (Date.now() - started) < 500) { + fontSizeInPx += 1; + setFontSize(); + } + fontSizeInPx -= 1; + setFontSize(); + current.style.opacity = 1; + } + resize(); + window.addEventListener('resize', resize); + return () => { + window.removeEventListener('resize', resize); + }; +}, [children, ref]) + return ( +
+
+ {children} +
+
+ ) +} + +FluidText.propTypes = { + children: PropTypes.node, +}; + +export default FluidText; + diff --git a/app/fluid-text/index.module.css b/app/fluid-text/index.module.css new file mode 100644 index 0000000..9c35362 --- /dev/null +++ b/app/fluid-text/index.module.css @@ -0,0 +1,13 @@ +.fluidText { + display: flex; + flex-wrap: wrap; + flex-direction: column; + height: 100%; + text-align: center; + justify-content: center; + width: 100%; +} + +.resize { + display: inline-block; +} diff --git a/app/fluid-text/initializer.jsx b/app/fluid-text/initializer.jsx new file mode 100644 index 0000000..e6e7863 --- /dev/null +++ b/app/fluid-text/initializer.jsx @@ -0,0 +1,18 @@ +import styles from './index.module.css'; + +export default function Initializer() { + return ( +