chore: initial

This commit is contained in:
cha0s 2024-03-11 23:03:35 -05:00
commit fbeee2bb86
122 changed files with 19218 additions and 0 deletions

68
.eslintrc.cjs Normal file
View File

@ -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,
},
},
],
};

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
/.cache
/build
.env

17
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,17 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Terrible",
"skipFiles": [
"<node_internals>/**"
],
"resolveSourceMapLocations": [],
"cwd": "${workspaceFolder}",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
}
]
}

78
README.md Normal file
View File

@ -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!

116
app/answers/index.jsx Normal file
View File

@ -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 (
<fetcher.Form
method={count === selection.length ? 'post' : 'get'}
className={styles.answers}
>
<input name="action" value="answer" type="hidden" />
{selection.length > 0 && (
selection.map((answer) => (
<input key={answer} name="selection" type="hidden" value={answer} />
))
)}
<div className={styles.buttons}>
{
count === selection.length
? (
<div className={styles.submit}>
<button name="confirm" type="submit">
<div className={styles.inside}>
<FluidText>
Send it
</FluidText>
</div>
</button>
<button
name="cancel"
onClick={() => {
setSelection([]);
}}
type="submit"
>
<div className={styles.inside}>
<FluidText>
Nevermind
</FluidText>
</div>
</button>
</div>
)
: (
choices.map((choice, i) => (
<button
className={[
className,
styles.answer,
selection.includes(i) && styles.selected,
].filter(Boolean).join(' ')}
disabled={disabled || (!isHydrated && selection.includes(i))}
key={i}
name="selection"
value={i}
onClick={(event) => {
if (selection.includes(i)) {
selection.splice(selection.indexOf(i), 1);
setSelection([...selection]);
}
else {
setSelection([...selection, i]);
}
event.preventDefault();
}}
type="submit"
>
<div className={styles.top} />
<div className={styles.left} />
<div className={styles.text}>
<FluidText>
<div className={styles.choice}>
{choice}
</div>
</FluidText>
</div>
</button>
))
)
}
</div>
</fetcher.Form>
);
}
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;

View File

@ -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%;
}

View File

@ -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
? ''
: (
<span
className={[
styles.answer,
!answers[i - 1] && styles.blank,
].filter(Boolean).join(' ')}
>
{
// 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] || '_____')
}
</span>
)
}
{part}
</>
), '');
return (
<div className={styles['black-card-text']}>
<FluidText>
{
0 === blanksCount
? (
<>
{game.blackCard}
<div className="footer">
<span
className={[
styles.answer,
!answers[0] && styles.blank,
].filter(Boolean).join(' ')}
>
{answers[0] ? `${capitalize(answers[0])}` : '_____'}
</span>
.
</div>
</>
)
: decoratedText
}
</FluidText>
</div>
);
}
BlackCardText.defaultProps = {
answers: [],
style: {},
};
BlackCardText.propTypes = {
answers: PropTypes.arrayOf(PropTypes.string),
style: PropTypes.shape({}),
};
export default BlackCardText;

View File

@ -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;
}

18
app/entry.client.jsx Normal file
View File

@ -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,
<StrictMode>
<RemixBrowser />
</StrictMode>
);
});

155
app/entry.server.jsx Normal file
View File

@ -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(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
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(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
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);
});
}

60
app/fluid-text/index.jsx Normal file
View File

@ -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 (
<div className={styles.fluidText}>
<div className={styles.resize} ref={ref}>
{children}
</div>
</div>
)
}
FluidText.propTypes = {
children: PropTypes.node,
};
export default FluidText;

View File

@ -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;
}

View File

@ -0,0 +1,18 @@
import styles from './index.module.css';
export default function Initializer() {
return (
<script
// so dramatic...
dangerouslySetInnerHTML={{
__html: `
{
const styleSheet = document.createElement('style');
styleSheet.innerText = '.${styles.resize} { opacity: 0; }';
document.head.appendChild(styleSheet);
}
`,
}}
/>
);
}

BIN
app/fonts/caladea.woff Normal file

Binary file not shown.

BIN
app/fonts/flashrogers.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

19
app/fonts/index.css Normal file
View File

@ -0,0 +1,19 @@
@font-face {
font-family: "Shark";
src: url("./flashrogers.ttf") format("truetype");
}
@font-face {
font-family: "Smack";
src: url("./flashrogerspunch.ttf") format("truetype");
}
@font-face {
font-family: "Strike";
src: url("./flashrogershalf.ttf") format("truetype");
}
@font-face {
font-family: "Line";
src: url("./flashrogerschrome.ttf") format("truetype");
}

BIN
app/fonts/shark.ttf Normal file

Binary file not shown.

BIN
app/fonts/smack.ttf Normal file

Binary file not shown.

5
app/hooks/context.js Normal file
View File

@ -0,0 +1,5 @@
import {createContext} from 'react';
export const GameContext = createContext(undefined);
export const SelectionContext = createContext(undefined);
export const SessionContext = createContext(undefined);

4
app/hooks/index.js Normal file
View File

@ -0,0 +1,4 @@
export {default as useGame} from './use-game';
export {default as useIsHydrated} from './use-is-hydrated';
export {default as useSelection} from './use-selection';
export {default as useSession} from './use-session';

7
app/hooks/use-game.js Normal file
View File

@ -0,0 +1,7 @@
import {useContext} from 'react';
import {GameContext} from './context';
export default function useGame() {
return useContext(GameContext);
}

View File

@ -0,0 +1,7 @@
import {useState, useEffect, useLayoutEffect} from "react";
export default function useIsHydrated() {
const [isHydrated, setIsHydrated] = useState(false);
('undefined' === typeof window ? useEffect : useLayoutEffect)(() => setIsHydrated(true), []);
return isHydrated;
}

View File

@ -0,0 +1,7 @@
import {useContext} from 'react';
import {SelectionContext} from './context';
export default function useSelection() {
return useContext(SelectionContext);
}

7
app/hooks/use-session.js Normal file
View File

@ -0,0 +1,7 @@
import {useContext} from 'react';
import {SessionContext} from './context';
export default function useSession() {
return useContext(SessionContext);
}

106
app/root.css Normal file
View File

@ -0,0 +1,106 @@
* {
box-sizing: border-box;
}
*:focus {
box-shadow: 0 0 2px 0 #d68030ff;
outline: none;
}
body {
margin: 0;
--default-font: Shark, 'Times New Roman', Times, serif;
font-family: var(--default-font);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scrollbar-width: thin;
scrollbar-color: #777 #333;
}
::-webkit-scrollbar {
width: 12px;
}
::-webkit-scrollbar-track {
background: #333;
}
::-webkit-scrollbar-thumb {
background-color: #777;
border-radius: 20px;
border: 3px solid #333;
}
button {
font-family: var(--default-font);
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
html, body, #root {
background-color: #111111;
width: 100%;
min-height: 100vh;
padding: 0;
margin: 0;
}
input {
border: none;
}
fieldset {
background-color: #151515;
border: 1px solid #000000;
display: inline-block;
margin: 0;
position: relative;
top: -0.5em;
width: 100%;
}
form {
line-height: 0;
}
button,
input[type="checkbox"],
input[type="checkbox"] + label {
&:not(:disabled) {
cursor: pointer;
}
}
fieldset {
margin-bottom: 1em;
padding: 0.5em;
}
label {
color: #ffffff;
font-family: var(--default-font);
font-size: 1em;
text-transform: uppercase;
user-select: none;
white-space: nowrap;
}
select {
background: #222222;
border: 1px solid #000000;
color: #ffffff;
font-size: 1em;
line-height: 0.75em;
margin: 0 1em;
appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
padding: 0.5em;
}
:global {
@font-face {
font-family: "Caladea";
src: url("./fonts/caladea.woff") format("woff");
}
}

42
app/root.jsx Normal file
View File

@ -0,0 +1,42 @@
import PropTypes from 'prop-types';
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
import './root.css';
import locals from './root.module.css';
import FluidTextInitializer from './fluid-text/initializer';
import NumberInitializer from './routes/play.$gameKey/bar/timeout/number-initializer';
export function Layout({children}) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body className={locals.terrible}>
<FluidTextInitializer />
<NumberInitializer />
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
Layout.propTypes = {
children: PropTypes.node,
};
export default function App() {
return <Outlet />;
}

4
app/root.module.css Normal file
View File

@ -0,0 +1,4 @@
.terrible {
background-color: #222222;
color: #AAAAAA;
}

View File

@ -0,0 +1,131 @@
.landing {
background: inherit;
display: flex;
flex-direction: column;
height: 100vh;
justify-content: space-between;
overflow: hidden;
padding: 8vh 8vw 4vh;
width: 100%;
}
.doSide {
font-size: 0.5em;
}
.terribleSide {
color: #d68030;
font-family: Strike;
position: relative;
}
.title {
display: flex;
flex-direction: column;
height: 40%;
font-family: Line;
text-align: center;
div {
display: inline-block;
line-height: 1;
font-size: 18vw;
@media(min-width: 50em) {
font-size: 145px;
}
@media(max-height: 55rem) {
font-size: 13vh;
}
text-shadow:
0.125vw 0 0 black,
0 0.125vw 0 black,
-0.125vw 0 0 black,
0 -0.125vw 0 black,
0 0 30px #000,
0 0 7px #000,
0 0 7px #000,
0 0 7px #000,
0 0 7px #000,
0 0 7px #000,
0 0 7px #000,
0 0 7px #000,
0 0 7px #000,
0 0 7px #000,
0 0 7px #000,
0 0 7px #000,
0 0 30px #000,
0 0 30px #000
;
}
transform: translateY(-20px) scaleY(1.2) perspective(100px) rotateX(25deg) translateZ(-0.5vw);
}
.buttons {
align-items: center;
background: inherit;
display: flex;
flex-basis: 50%;
flex-direction: column;
justify-content: space-between;
}
.buttons a {
background: inherit;
color: #aaa;
max-width: 70vw;
overflow: hidden;
padding: 2vh 8vw;
position: relative;
text-align: center;
text-transform: uppercase;
text-decoration: none;
width: 100%;
&:first-child {
flex-basis: 55%;
}
&:nth-child(2) {
flex-basis: 35%;
}
&:before {
content: ' ';
background: inherit;
position: absolute;
left: -4vw;
right: 0;
top: -4vh;
height: calc(100% + 8vh);
width: calc(100% + 8vw);
bottom: 0;
filter: blur(0.025em) brightness(0.4);
transition: 0.4s filter;
}
&:hover {
&:before {
filter: blur(0.25em) brightness(0.6);
}
text-shadow: 0 0 5em #ffff0022;
color: #ff0;
}
div {
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
width: 100%;
height: 100%;
}
}
.createButton {
font-size: 10vw;
@media(min-width: 50em) {
font-size: 6vw;
}
}
.joinButton {
font-size: 8vw;
@media(min-width: 50em) {
font-size: 4vw;
}
}

View File

@ -0,0 +1,32 @@
import {Link} from 'react-router-dom';
import styles from './landing.module.css';
export const meta = () => {
return [{title: 'Welcome | Do Terrible'}];
};
export default function Landing() {
return (
<div className={styles.landing}>
<div className={styles.title}>
<div className={styles.doSide}>Do</div>
<div className={styles.terribleSide}>Terrible</div>
</div>
<div className={styles.buttons}>
<Link
className={styles.createButton}
to="/create"
>
<div>Create a game</div>
</Link>
<Link
className={styles.joinButton}
to="/join"
>
<div>Join a game</div>
</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,3 @@
export const scoreToWin = ['20', '15', '10', '5'];
export const secondsPerRound = ['120', '90', '60', '30'];
export const bots = ['0', '1', '2', '3', '4'];

View File

@ -0,0 +1,257 @@
.createWrapper {
background-attachment: fixed;
background-position-x: center;
background-repeat: no-repeat;
background-size: cover;
display: flex;
flex-direction: column;
min-height: 100vh;
justify-content: center;
width: 100vw;
}
.create {
font-size: 2.5vh;
height: 100vh;
overflow: auto;
position: relative;
}
.form {
background-color: rgba(0, 0, 0, 0.7);
box-shadow: 0 0 20px black;
margin: auto;
max-width: 60rem;
> :nth-child(1), > :nth-child(3) {
padding: 4vh 0;
}
}
.errors {
color: red;
font-size: 1.5em;
line-height: 1;
margin-top: 2rem;
animation: neon 0.6s infinite;
}
@keyframes neon {
0% { text-decoration: none; opacity: 0.6; }
50% { text-decoration: underline; opacity: 1; }
to { opacity: 0.6; }
}
.create fieldset,
.create [type="submit"]
{
background: inherit;
border: none;
padding: 2vh 2vw;
position: relative;
}
.create h2 {
font-family: Caladea, 'Times New Roman', Times, serif;
font-size: 3vh;
}
.create {
h1, h2 {
font-weight: normal;
text-align: center;
}
}
.create form {
font-size: 2.5vh;
text-align: center;
}
.create fieldset {
text-align: center;
text-shadow:
1px 0 1px black,
0 1px 1px black,
-1px 0 1px black,
0 -1px 1px black,
;
}
.create label {
color: #aaa;
}
.fieldsets {
padding: 0 4vw;
transition: max-height 0.5s;
fieldset {
top: 0;
}
}
.packsWrapper legend {
margin: 0 auto;
}
.create button {
background: #151515;
border: 1px solid #000000;
color: #d68030;
font-family: Strike;
font-size: 5vh;
height: 18vh;
margin: 0 auto;
padding: 6vh;
text-transform: uppercase;
width: 100%;
&:not(:disabled) {
animation: glow-pulse 5s infinite;
}
}
.create button .text {
border: 4px solid #b97106;
padding: 17px;
text-align: center;
&:before {
content: '\00a0\00a0\00a0 ';
opacity: 0;
transition: 0.2s opacity;
}
&:after {
content: ' \00a0\00a0';
opacity: 0;
transition: 0.2s opacity;
}
&:hover {
border-color: #d6803077;
&:before {
content: '> ';
display: inline;
opacity: 1;
}
&:after {
content: ' <';
display: inline;
opacity: 1;
}
}
}
.create button:hover {
&:before {
filter: blur(0.05em) brightness(0.5);
}
}
.create .packs .error {
color: #ff0000;
filter: blur(0);
flex-basis: 100%;
font-size: 2vh;
margin: 0 0 3vh;
animation: bling 0.5s infinite;
}
@keyframes bling {
from { color: #ff0000; }
50% { color: #ff9900; }
to { color: #ff0000; }
}
.create {
[disabled] {
cursor: not-allowed;
}
}
@keyframes glow-pulse {
from { color: #d68030; text-shadow: 0 0 3vh transparent; }
49% { color: #d68030; text-shadow: 0 0 3vh transparent; }
50% { color: #d68030; text-shadow: 0 0 3vh transparent; }
85.1% { color: #d3d630; text-shadow: 0 0 1.5vh #d68030; }
94.1% { color: #d68030; text-shadow: 0 0 12vh #d3d63022; }
95.1% { color: #d68030; text-shadow: 0 0 15vh #d3d63022; }
to { color: #d68030; text-shadow: 0 0 3vh transparent; }
}
.description {
text-decoration: underline;
text-underline-offset: 0.125em;
}
.packsWrapper {
margin-bottom: 3vh;
padding: 2vh;
.description {
color: #ffff77;
font-size: 200%;
line-height: 1;
margin: 0 0 2vh;
}
}
.packs {
display: flex;
flex-wrap: wrap;
justify-content: center;
margin-bottom: 1em;
}
.limitsWrapper {
margin: 0;
> label {
display: inline-block;
margin-left: 1em;
padding: 1.5vh 1vh;
}
}
.limits {
padding-left: 2vw;
}
.limitsWrapper > div {
margin-bottom: 1em;
}
.limitsWrapper label {
align-items: center;
color: #ffffcc;
display: inline-flex;
flex-wrap: wrap;
filter: blur(0px);
font-family: Verdana, Geneva, Tahoma, sans-serif;
justify-content: center;
line-height: normal;
margin: 1rem 0 0.25rem 0;
}
.limitsWrapper select {
color: #ffffcc;
font-size: 125%;
line-height: 1.25;
margin: 0 1rem 0 0.5rem;
text-align: center;
text-align-last: center;
text-shadow: 0 0 1vw black;
width: 3em;
}
.limitsWrapper label:focus-within {
color: #ffff44;
select {
color: #ffff00;
}
}
.limitsWrapper .description {
color: #ffff77;
font-size: 200%;
line-height: 1;
margin: 0 0 2vh;
}

View File

@ -0,0 +1,36 @@
import PropTypes from 'prop-types';
import {useState} from 'react';
import styles from './pack-choice.module.css';
export default function PackChoice(props) {
const {pack} = props;
const [checked, setChecked] = useState(pack.default);
const id = `pack-choice-${pack.id}`;
return (
<span className={[styles.pack, styles.checkbox].join(' ')}>
<input
aria-label={pack.label}
checked={checked}
id={id}
name="packs"
onChange={(event) => setChecked(event.target.checked)}
title={pack.label}
type="checkbox"
value={pack.id}
/>
<label htmlFor={id} title={pack.label}>
<div className={styles.labelText}>{pack.label}</div>
</label>
</span>
);
}
PackChoice.propTypes = {
pack: PropTypes.shape({
default: PropTypes.bool,
id: PropTypes.number,
label: PropTypes.string,
}).isRequired,
};

View File

@ -0,0 +1,71 @@
.checkbox input[type="checkbox"] {
display: none;
}
.checkbox label {
display: inline-block;
font-family: Verdana, Geneva, Tahoma, sans-serif;
line-height: normal;
padding-left: 1.75em;
position: relative;
white-space: inherit;
}
.checkbox label::before,
.checkbox label::after {
display: inline-block;
position: absolute;
}
.checkbox input[type="checkbox"]:focus + label::before {
outline: rgb(59, 153, 252) auto 5px;
}
.checkbox label::before{
border: 1px solid;
content: "";
height: 1.25em;
left: 0;
top: 0;
width: 1.25em;
}
.checkbox input[type="checkbox"] + label::after {
content: none;
}
.checkbox input[type="checkbox"]:checked + label {
color: #ffffcc;
}
.checkbox input[type="checkbox"]:checked + label::after {
animation: heartbeat 1s infinite;
color: #ff0000;
content: "💔";
left: calc(0.625em + 1px);
top: 1rem;
transform-origin: 0% 0%;
}
@keyframes heartbeat {
0% {
transform: rotate(0) scale(0.9) translate(-50%, -50%);
}
12% {
transform: rotate(-7deg) scale(0.7) translate(-50%, -50%);
}
25% {
transform: rotate(0) scale(0.8) translate(-50%, -50%);
}
37%, 50% {
transform: rotate(2deg) scale(0.7) translate(-50%, -50%);
}
100% {
transform: rotate(0) scale(0.9) translate(-50%, -50%);
}
}
.pack {
display: inline-block;
padding: 0.4em 1vw;
}
.pack label:hover {
color: #aaa;
}

View File

@ -0,0 +1,158 @@
import {
Form,
json,
redirect,
useActionData,
useLoaderData,
useNavigation,
useSearchParams,
} from '@remix-run/react';
import FluidText from '#fluid-text';
import {Game} from '#state/game';
import {juggleSession, loadSession} from '#state/session';
import PackChoice from './pack-choice';
import styles from './index.module.css';
import * as config from './config';
export const action = async ({request}) => {
const {createGame} = await import('#state/game.server');
const formData = await request.formData();
const errors = Object.entries(config)
.reduce((r, [k, v]) => ({
...r,
...!v.includes(formData.get(k)) && {[k]: `Invalid selection for ${k}`}
}), Game.check(formData));
if (Object.keys(errors).length > 0) {
return json({errors});
}
const session = await loadSession(request);
if (!session.id) {
throw new Response('', {status: 400});
}
return redirect(`/play/${await createGame(session, formData)}`);
};
export const meta = () => {
return [{title: 'Create | Do Terrible'}];
};
function isDefaultPack(name) {
return [
'cha0s from the sky',
'CAH Base Set',
'CAH: Main Deck',
'CAH: Human Pack',
'CAH: Blue Box Expansion',
'CAH: Green Box Expansion',
'CAH: Red Box Expansion',
'CAH: Box Expansion',
'CAH: 2000s Nostalgia Pack',
'CAH: College Pack',
'CAH: First Expansion',
'CAH: Second Expansion',
'CAH: Third Expansion',
'CAH: Fourth Expansion',
'CAH: Fifth Expansion',
'CAH: Sixth Expansion',
].includes(name);
}
export async function loader({request}) {
return json({
packs: Game.packs.map(({name, id}) => ({default: isDefaultPack(name), label: name, id})),
session: await juggleSession(request),
});
}
export default function CreateSpace() {
const actionData = useActionData();
const {packs, session} = useLoaderData();
const navigation = useNavigation();
const [searchParams] = useSearchParams();
const errors = Object.entries(actionData?.errors || {})
const sortedPacks = packs.sort((l, r) => {
if (l.default === r.default) {
return l.label < r.label ? -1 : 1;
}
return l.default ? -1 : 1;
});
const renderedPackChoices = sortedPacks.map(
(pack) => <PackChoice pack={pack} key={pack.id} />,
);
const isCreating = navigation.formAction === '/create';
return (
<div className={styles.createWrapper}>
<div className={styles.create}>
<Form method="post">
<div className={styles.form}>
<div>
<button disabled={!session.id || isCreating} type="submit">
<FluidText>
<div className={styles.text}>
{
session.id
? (isCreating ? 'Starting the game...' : 'Start the game')
: 'Enable cookies'
}
</div>
</FluidText>
</button>
{0 !== session.id && (
<p>...or tweak some settings first</p>
)}
{(errors.length > 0 || searchParams.has('none')) && (
<div className={styles.errors}>
{errors.map(([key, error]) => <div key={key}>{error}</div>)}
{searchParams.has('none') && (
<div key="none">No active games may be joined right now. Create one!</div>
)}
</div>
)}
</div>
{0 !== session.id && (
<div className={styles.fieldsets}>
<fieldset className={styles.limitsWrapper}>
<div className={styles.description}>Details</div>
<div className={styles.limits}>
<input name="maxPlayers" type="hidden" value="9" />
<label>
<span>score to win</span>
<select defaultValue={10} name="scoreToWin">
{config.scoreToWin.map((value) => (
<option key={value} value={value}>{value}</option>
))}
</select>
</label>
<label>
<span>seconds per round</span>
<select defaultValue={120} name="secondsPerRound">
{config.secondsPerRound.map((value) => (
<option key={value} value={value}>{value}</option>
))}
</select>
</label>
<label>
<span>bots</span>
<select defaultValue={0} name="bots">
{config.bots.map((value) => (
<option key={value} value={value}>{value}</option>
))}
</select>
</label>
</div>
</fieldset>
<fieldset className={styles.packsWrapper}>
<div className={styles.description}>Packs</div>
<div className={styles.packs}>{renderedPackChoices}</div>
</fieldset>
</div>
)}
</div>
</Form>
</div>
</div>
);
}

View File

@ -0,0 +1,49 @@
import {Link} from '@remix-run/react';
import PropTypes from 'prop-types';
import {State} from '#state/globals';
import styles from './index.module.css';
export default function Game(props) {
const {game} = props;
return (
<Link
className={styles.game}
to={`/play/${game.key}`}
>
<h2>{game.key}</h2>
<div className={styles.stats}>
<span className={styles['player-count']}>
<span className={styles.number}>{game.playerCount}</span>
{' '}
{(() => {
switch (game.state) {
case State.STARTING: return 'just starting';
case State.ANSWERING:
case State.AWARDING:
case State.AWARDED:
return 'playing';
case State.FINISHED: return 'finished';
case State.PAUSED: return 'paused';
default: return '';
}
})()}
</span>
<span className={styles.spacer}>{' - '}</span>
<span className={styles.completed}>
progress: <span className={styles.number}>{game.completed * 100}</span>%
</span>
</div>
</Link>
);
}
Game.propTypes = {
game: PropTypes.shape({
completed: PropTypes.number,
key: PropTypes.string,
playerCount: PropTypes.number,
state: PropTypes.number,
}).isRequired,
};

View File

@ -0,0 +1,39 @@
.game {
color: #999;
display: block;
margin: 1em;
max-width: 20em;
background-color: rgba(12, 12, 12, 0.8);
border: 1px solid #444;
padding: 1em;
text-decoration: none;
&:hover {
background-color: rgba(24, 24, 24, 0.9);
}
}
.game .stats {
display: flex;
justify-content: space-between;
font-size: 0.8em;
margin-bottom: 1em;
}
.game .stats .ish {
font-size: 0.7em;
padding: 0;
}
.game h2 {
font-family: Consolas, monospace;
margin: 0.5em 0 1em;
}
.number {
color: #d68030;
font-family: Caladea, 'Times New Roman', Times, serif;
}
.spacer {
margin: 0 0.5rem;
}

View File

@ -0,0 +1,16 @@
import PropTypes from 'prop-types';
import Game from './game';
import styles from './index.module.css';
export default function Games({games}) {
return (
<div className={styles.games}>
{games.map((game) => <Game key={game.key} game={game} />)}
</div>
);
}
Games.propTypes = {
games: PropTypes.arrayOf(Game.propTypes.game),
};

View File

@ -0,0 +1,5 @@
.games {
display: flex;
flex-wrap: wrap;
justify-content: space-evenly;
}

View File

@ -0,0 +1,80 @@
import {
Form,
json,
redirect,
useLoaderData,
useSearchParams,
} from '@remix-run/react';
import styles from './index.module.css';
import Games from './games';
export const meta = () => {
return [{title: 'Join | Do Terrible'}];
};
export async function action({request}) {
const formData = await request.formData();
const gameKey = formData.get('gameKey').toUpperCase();
if (!gameKey.match(/^[A-Z]{4}$/)) {
throw new Response('', {status: 400});
}
const {loadGame} = await import('#state/game.server');
const game = await loadGame(gameKey);
if (!game) {
throw redirect(`/join?nope=${gameKey}`);
}
return redirect(`/play/${gameKey}`);
}
export async function loader() {
const {joinList} = await import('#state/game.server');
const games = await joinList();
if (0 === games.length) {
throw redirect('/create?none');
}
return json({games: await joinList()});
}
export default function Join() {
const {games} = useLoaderData();
const [searchParams] = useSearchParams();
return (
<div className={styles.joinWrapper}>
<div className={styles.join}>
<h1>Join a game</h1>
{searchParams.has('full') && (
<div className={styles.error}>
That game is full, try joining another one!
</div>
)}
{searchParams.has('nope') && (
<div className={styles.error}>
<code>{searchParams.get('nope')}</code> is not a valid game key
</div>
)}
<Form method="post">
<label>
<div>Have a code? Stick it in</div>
{' '}
<input
aria-label="Game code"
minLength="4"
maxLength="4"
name="gameKey"
pattern="^[a-zA-Z]{4}$"
placeholder="CODE"
spellCheck={false}
size="4"
title="A four-letter game code."
type="text"
/>
</label>
<button type="submit">Join</button>
</Form>
<h2 className={styles.jump}>Or, jump into a game</h2>
<Games games={games} />
</div>
</div>
);
}

View File

@ -0,0 +1,76 @@
.joinWrapper {
margin: auto;
padding-top: 3em;
text-align: center;
}
@media(min-width: 64em) {
.join {
font-size: 1.25em;
}
}
.join h1, .join h2 {
color: #d68030;
font-family: Strike;
font-weight: normal;
}
.join p {
font-size: 1.3em;
margin-bottom: 2em;
}
.join button {
border: none;
font-size: 1em;
line-height: 2em;
text-decoration: underline;
}
.join label {
display: flex;
flex-direction: column;
line-height: normal;
}
.join form {
display: flex;
justify-content: center;
input {
background-color: #222;
color: #fff;
font-size: 5vh;
margin: auto;
padding: 1vh 1vw;
text-transform: uppercase;
width: 19.5vh;
}
button {
background-color: #242424;
border: 1px solid black;
color: #d68030;
font-family: Smack;
font-size: 4vh;
font-weight: normal;
margin-left: 2vw;
padding: 0.25vh 2vw;
text-decoration: none;
}
}
.error {
color: red;
margin-bottom: 2rem;
animation: neon 0.6s infinite;
}
@keyframes neon {
0% { text-decoration: none; opacity: 0.6; }
50% { text-decoration: underline; opacity: 1; }
to { opacity: 0.6; }
}
.jump {
margin-bottom: 0;
}

BIN
app/routes/_intro/intro.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

View File

@ -0,0 +1,17 @@
.introWrapper {
background-color: #111111;
display: inline-block;
min-height: 100vh;
width: 100%;
}
.intro {
background-attachment: fixed;
background-color: #171717;
background-image: url('./intro.jpg');
background-position-x: center;
background-position-y: center;
background-size: cover;
background-repeat: repeat;
min-height: 100vh;
}

View File

@ -0,0 +1,14 @@
import {Outlet} from 'react-router-dom';
import '../../fonts/index.css';
import styles from './intro.module.css';
export default function Intro() {
return (
<div className={styles.introWrapper}>
<div className={styles.intro}>
<Outlet />
</div>
</div>
);
}

View File

@ -0,0 +1,28 @@
import {loadSession} from '#state/session';
import {actionSource} from '#utils/action-source';
export async function loader({params, request}) {
const {loadGame, startTimeout} = await import('#state/game.server');
const {gameKey} = params;
const session = await loadSession(request);
if (!session) {
throw new Response('', {status: 400});
}
const game = await loadGame(gameKey);
if (!game || !game.players[session.id]) {
throw new Response('', {status: 400});
}
const player = game.players[session.id];
const {emitters} = player;
// Close long polls since this request was made with JS.
player.closeLongPolls();
return actionSource(request, (emitter) => {
emitters.push(emitter);
return () => {
emitters.splice(emitters.indexOf(emitter), 1);
if (0 === emitters.length) {
player.timeout = startTimeout(gameKey, session);
}
};
});
}

View File

@ -0,0 +1,142 @@
import {renderToString} from 'react-dom/server';
import {Presence, State} from '#state/globals';
import {loadSession} from '#state/session';
import {longPoll} from '#utils/long-poll';
import PlayersChatMessage from '../play.$gameKey/players/chat/message';
import styles from '../play.$gameKey/players/chat/message/index.module.css';
import stylesheet from '../play.$gameKey/players/chat/message/index.module.css?raw';
const compiled = Object.entries(styles)
.sort(([l], [r]) => r.length - l.length)
.reduce((compiled, [raw, encoded]) => compiled.replaceAll(raw, encoded), stylesheet);
export async function loader({params, request}) {
const {gameKey} = params;
const session = await loadSession(request);
if (!session) {
throw new Response('', {status: 400});
}
const {loadGame} = await import('#state/game.server');
const game = await loadGame(gameKey);
if (!game || !game.players[session.id]) {
throw new Response('', {status: 400});
}
return longPoll(request, (send, close) => {
let messageOwner = false;
let i = -1;
function renderMessage(message) {
if (!game.players[message.owner]) {
return '';
}
let string = renderToString(
<div className={`o${message.owner}`}>
<PlayersChatMessage
key={message.key}
isShort={messageOwner === message.owner}
message={message}
name={''}
/>
</div>,
);
string += `
<style>
.o${message.owner} .${styles.owner}:before {
content: '${game.players[message.owner].name.replaceAll("'", "\\'")}';
}
</style>
`;
messageOwner = message.owner;
return string;
}
send(
`
<!DOCTYPE html><title>.</title>
<style>
html,body{
height: 100%;
}
body{
margin:0;
}
::-webkit-scrollbar {
width: 12px;
}
::-webkit-scrollbar-track {
background: #333;
}
::-webkit-scrollbar-thumb {
background-color: #777;
border-radius: 20px;
border: 3px solid #333;
}
.messages{
display:flex;
flex-direction:column-reverse;
height: 100%;
overflow-y:auto;
scrollbar-width: thin;
scrollbar-color: #777 #333;
}
.${styles.message}{font-size:0.75em;}
${compiled}
</style>
<div class="messages">
${game.messages.map(renderMessage).toReversed().join('')}
`
// minimize
.split('\n').map((s) => s.trim()).join('')
);
let activePlayers = Object.values(game.players)
.filter(({presence}) => presence === Presence.ACTIVE)
.length;
function onJoined() {
activePlayers += 1;
if (game.state === State.STARTING && 3 === activePlayers) {
close();
}
}
function onMessage({payload}) {
// no-js autoscroll
send(`<div style="order: ${i--}">${renderMessage(payload)}</div>`);
}
function onPresence({payload}) {
if (Presence.ACTIVE === payload.presence) {
activePlayers += 1;
if (game.state === State.STARTING && 3 === activePlayers) {
close();
}
}
else {
activePlayers -= 1;
if (game.state === State.STARTING && 2 === activePlayers) {
close();
}
}
}
function onRename({payload: {name, id}}) {
send(`
<style>
.o${id} .${styles.owner}:before {
content: '${name.replaceAll("'", "\\'")}';
}
</style>
`);
}
game.addActionListener('rename', onRename);
game.addActionListener('destroy', close);
game.addActionListener('joined', onJoined);
game.addActionListener('presence', onPresence);
game.addActionListener('message', onMessage);
game.addActionListener('state', close);
return () => {
game.removeActionListener('state', close);
game.removeActionListener('destroy', close);
game.removeActionListener('joined', onJoined);
game.removeActionListener('presence', onPresence);
game.removeActionListener('message', onMessage);
game.removeActionListener('rename', onRename);
};
});
}

View File

@ -0,0 +1,19 @@
import styles from './index.module.css';
export default function ChatButton() {
return (
<svg
className={styles.icon}
viewBox="-21 -47 682.66669 682"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m552.011719-1.332031h-464.023438c-48.515625 0-87.988281 39.464843-87.988281 87.988281v283.972656c0 48.414063 39.300781 87.816406 87.675781 87.988282v128.863281l185.191407-128.863281h279.144531c48.515625 0 87.988281-39.472657 87.988281-87.988282v-283.972656c0-48.523438-39.472656-87.988281-87.988281-87.988281zm50.488281 371.960937c0 27.835938-22.648438 50.488282-50.488281 50.488282h-290.910157l-135.925781 94.585937v-94.585937h-37.1875c-27.839843 0-50.488281-22.652344-50.488281-50.488282v-283.972656c0-27.84375 22.648438-50.488281 50.488281-50.488281h464.023438c27.839843 0 50.488281 22.644531 50.488281 50.488281zm0 0"
style={{opacity: 0.5}}
/>
<path className={styles.lines} d="m171.292969 131.171875h297.414062v37.5h-297.414062zm0 0" />
<path className={styles.lines} d="m171.292969 211.171875h297.414062v37.5h-297.414062zm0 0" />
<path className={styles.lines} d="m171.292969 291.171875h297.414062v37.5h-297.414062zm0 0" />
</svg>
);
}

View File

@ -0,0 +1,95 @@
import {useBlocker, useFetcher} from '@remix-run/react';
import PropTypes from 'prop-types';
import {useEffect} from 'react';
import {useGame, useIsHydrated} from '#hooks';
import ChatButton from './chat-button';
import MuteButton from './mute-button';
import styles from './index.module.css';
function Controls({
mutedTuple,
chatOpenTuple,
unreadTuple,
}) {
const fetcher = useFetcher({key: 'bar'});
const game = useGame();
const isHydrated = useIsHydrated();
// UX: allow "back" to close the chat.
useBlocker(() => {
if (!game.destroyed && chatOpenTuple[0]) {
chatOpenTuple[1](false);
return true;
}
});
useEffect(() => {
function onBeforeUnload(event) {
if (chatOpenTuple[0]) {
chatOpenTuple[1](false);
event.preventDefault();
}
}
window.addEventListener('beforeunload', onBeforeUnload);
return () => {
window.removeEventListener('beforeunload', onBeforeUnload);
};
});
return (
<div className={styles.controls}>
<fetcher.Form
onSubmit={(event) => {
event.preventDefault();
}}
>
<button
aria-label={mutedTuple[0] ? 'Unmute the game' : 'Mute the game'}
className={[
styles.mute,
(
!isHydrated
|| 0 === (window.speechSynthesis?.getVoices().length || 0)
) && styles.hidden,
mutedTuple[0] && styles.muted,
].filter(Boolean).join(' ')}
onClick={() => {
mutedTuple[1](!mutedTuple[0]);
}}
type="button"
>
<MuteButton />
</button>
<button
className={[styles.chat, unreadTuple[0] > 0 && styles.unread].filter(Boolean).join(' ')}
name={chatOpenTuple[0] ? '' : 'chat'}
onClick={() => {
chatOpenTuple[1](!chatOpenTuple[0]);
unreadTuple[1](0);
}}
type="submit"
>
<ChatButton />
<span
className={
[styles.unread, 0 === unreadTuple[0] && styles.hidden].filter(Boolean).join(' ')
}
>
<span className={styles.rendered}>{unreadTuple[0]}</span>
<span className={styles.streaming}></span>
</span>
</button>
</fetcher.Form>
</div>
);
}
Controls.displayName = 'Controls';
Controls.propTypes = {
mutedTuple: PropTypes.array,
chatOpenTuple: PropTypes.array,
unreadTuple: PropTypes.array,
}
export default Controls;

View File

@ -0,0 +1,47 @@
import PropTypes from 'prop-types';
import {useGame} from '#hooks';
import Controls from './controls';
import styles from './index.module.css';
import Timeout from './timeout';
export function unreadUpdateStyle(unread) {
return `
<style>
.${styles.unread}.${styles.hidden} {display: inline !important}
.${styles.unread} .${styles.rendered} {display: none}
.${styles.streaming}:before {content: '${unread}'}
.${styles.chat} svg {
animation: ${styles['chat-wiggle']} 7s infinite;
}
.${styles.chat} svg .${styles.lines} {
animation: ${styles.fluoresce} 7s infinite;
}
</style>
`;
}
function Bar(props) {
const game = useGame();
return (
<div className={styles['bar-wrapper']}>
<div
className={[
styles.bar,
props.chatOpenTuple[0] && styles.floating,
].filter(Boolean).join(' ')}
>
<Timeout timeout={game.timeout} />
<Controls {...props} />
</div>
</div>
);
}
Bar.propTypes = {
chatOpenTuple: PropTypes.array,
};
Bar.displayName = 'Bar';
export default Bar;

View File

@ -0,0 +1,144 @@
.bar-wrapper {
height: 6rem;
width: 100%;
}
.bar {
align-items: center;
background-color: #111;
display: flex;
height: 100%;
left: 0;
justify-content: space-between;
text-align: center;
top: 0;
}
button.chat, button.mute {
&:focus {
box-shadow: none;
}
}
button.chat {
position: relative;
@media(min-width: 80em) {
display: none;
}
svg .lines {
opacity: 0.5;
}
&.unread svg {
animation: chat-wiggle 7s infinite;
& .lines {
animation: fluoresce 7s infinite;
}
}
.unread {
font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;
position: absolute;
color: white;
border-radius: 0.5rem;
background-color: #880000;
font-size: 100%;
padding: 0.25rem 0.25rem;
right: 1rem;
top: 2.5rem;
}
}
button.mute {
transition: opacity 0.5s;
svg {
opacity: 0.7;
}
&.muted {
svg {
opacity: 0.3;
}
}
}
@keyframes chat-wiggle {
from { transform: rotate(25deg) scale(1); }
2% { transform: rotate(-12deg) scale(1.15); }
4% { transform: rotate(12deg); }
4.2% { transform: rotate(0deg); }
4.4% { transform: rotate(6deg); }
4.6% { transform: rotate(-3deg) scale(1); }
4.7% { transform: rotate(0deg) scale(1); }
4.75% { transform: rotate(1.5deg) scale(1); }
4.8% { transform: rotate(0deg) scale(1); }
}
@keyframes fluoresce {
from { opacity: 0.5; }
4.8% { opacity: 1.0; }
to { opacity: 0.5; }
}
.mute .line {
transition: opacity 0.4s;
}
.mute.muted .line {
opacity: 1;
}
.mute:not(.muted) .line {
opacity: 0;
}
.mute path, .mute polygon {
fill: #999999;
}
.mute .wave {
transition: fill 0.4s;
}
.mute rect, .mute:not(.muted) .wave {
fill: #ffffff;
}
.hidden {
display: none;
}
.controls {
line-height: 0;
form {
display: inline-block;
}
button {
background-color: transparent;
border: none;
.icon {
align-items: center;
background-color: #111;
display: flex;
fill: #fff;
justify-content: center;
margin: 1rem;
height: 3rem;
width: 3rem;
}
}
}
.iframe {
border: 0;
height: 100%;
overflow: hidden;
position: relative;
width: 10em;
}
.rendered, .streaming {
--nothing: 0;
}

View File

@ -0,0 +1,46 @@
import styles from './index.module.css';
export default function MuteButton() {
return (
<svg
className={styles.icon}
version="1.1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 500.184 500.184"
>
<path
d="M114.215,178.215H34.462c-10.831,0-20.677,8.861-20.677,20.677v99.446c0,10.831,8.862,20.677,20.677,20.677h79.754
c11.815,0,20.677-9.846,20.677-20.677v-99.446C134.892,188.061,126.031,178.215,114.215,178.215z M115.2,297.354
c0,0,0,0.985-0.985,0.985H34.462c0,0-0.985,0-0.985-0.985v-99.446c0,0,0-0.985,0.985-0.985h79.754c0,0,0.985,0,0.985,0.985
V297.354z"
/>
<polygon
points="114.215,183.138 128,196.923 278.646,47.261 278.646,452.923 128,302.277 114.215,316.062 298.338,500.184
298.338,0"
/>
<path
className={styles.wave}
d="M288.492,176.246v19.692c31.508,0,56.123,23.631,56.123,53.169s-25.6,53.169-56.123,53.169v19.692
c42.339,0,75.815-32.492,75.815-72.861S329.846,176.246,288.492,176.246z"
/>
<path
className={styles.wave}
d="M355.446,139.815l-8.862,17.723c35.446,17.723,57.108,52.185,57.108,90.585c0,36.431-18.708,68.923-51.2,87.631
l9.846,16.739c37.415-21.661,60.062-61.046,61.046-104.369C423.384,202.831,397.784,160.492,355.446,139.815z"
/>
<path
className={styles.wave}
d="M397.785,93.538l-9.846,16.739c49.231,29.538,78.769,79.754,78.769,135.877c0,56.123-29.538,107.323-79.754,136.862
l9.846,16.739c56.123-32.492,89.6-90.585,89.6-152.615C486.4,184.123,452.923,127.015,397.785,93.538z"
/>
<rect
className={styles.line}
x="-65.352"
y="242.48"
transform="matrix(0.7029 -0.7112 0.7112 0.7029 -109.9687 241.3525)"
width="598.608"
height="19.691"
/>
</svg>
);
}

View File

@ -0,0 +1,56 @@
import PropTypes from 'prop-types';
import {
useEffect,
useState,
} from 'react';
import styles from './index.module.css';
import Number from './number';
function Timeout({timeout}) {
const [counter, setCounter] = useState(timeout);
useEffect(() => {
setCounter(timeout);
}, [timeout]);
useEffect(() => {
let last = Date.now();
const ms = 77;
const handle = setInterval(() => {
const now = Date.now();
const elapsed = (now - last) / 1000;
last = now;
setCounter((counter) => counter - elapsed);
}, ms);
return () => {
clearInterval(handle);
};
}, []);
let formatted;
if (counter > 10000) {
formatted = '';
}
else if (counter <= 0) {
formatted = '0';
}
else if (counter >= 10) {
formatted = `${Math.floor(counter)}`;
}
else {
formatted = counter.toFixed(2);
}
return (
<div className={styles.timeout}>
<Number value={formatted} />
</div>
);
}
Timeout.defaultProps = {};
Timeout.propTypes = {
timeout: PropTypes.number.isRequired,
};
Timeout.displayName = 'Timeout';
export default Timeout;

View File

@ -0,0 +1,6 @@
.timeout {
color: #fff;
font-family: Caladea, 'Times New Roman', Times, serif;
font-size: 3rem;
margin: 0 0 0 1.5rem;
}

View File

@ -0,0 +1,21 @@
import styles from './number.module.css';
export default function Initializer() {
return (
<script
// so dramatic...
dangerouslySetInnerHTML={{
__html: `
{
const styleSheet = document.createElement('style');
styleSheet.innerText = [
'.${styles.rendered} {display: inline !important}',
'.${styles.streamed} {display: none !important}',
].join('');
document.head.appendChild(styleSheet);
}
`,
}}
/>
);
}

View File

@ -0,0 +1,50 @@
import PropTypes from 'prop-types';
import styles from './number.module.css';
export function numberUpdateStyle(c, m) {
return `
<style>
.${styles.rendered} {display:none}
.${styles.characteristic} .${styles.streamed}:before {
content: '${Math.floor(c)}';
}
${
m
? `
.${styles.mantissa} .${styles.streamed}:before {
content: '.${m.slice(0, 2)}';
}
`
: ''
}
</style>
`;
}
export default function Number(props) {
const {value} = props;
const [c, m] = value.split('.');
return (
<div className={styles.number}>
<span className={styles.characteristic}>
<span className={styles.rendered}>{c}</span>
<span className={styles.streamed}></span>
</span>
<span className={styles.mantissa}>
<span className={styles.rendered}>
{
m && (
<>.{m.slice(0, 2)}</>
)
}
</span>
<span className={styles.streamed}></span>
</span>
</div>
);
}
Number.propTypes = {
value: PropTypes.string.isRequired,
};

View File

@ -0,0 +1,7 @@
.characteristic, .rendered, .streamed {
--nothing: 0;
}
.mantissa {
font-size: 0.4em;
}

View File

@ -0,0 +1,49 @@
.play {
display: flex;
overflow: hidden;
position: relative;
}
.black-and-white {
display: flex;
flex-direction: column;
width: 100vw;
@media(min-width: 80em) {
width: 60vw;
}
height: 100vh;
}
.black-wrapper {
display: flex;
flex-direction: column;
height: 50%;
}
.black {
background-color: #151515;
color: #b1b1b1;
display: flex;
flex-basis: calc(100% - 6rem);
overflow: hidden;
padding: 4vh 4vw;
position: relative;
}
.white-wrapper {
height: 50%;
overflow: auto;
}
.white {
background-color: #dfdfdf;
color: #151515;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.no-cookie {
padding: 4vh 8vw;
}

View File

@ -0,0 +1,53 @@
import {useState} from 'react';
import {useFetcher, useParams} from '@remix-run/react';
import {useGame, useIsHydrated, useSession} from '#hooks';
import styles from './index.module.css';
export default function Form() {
const {gameKey} = useParams();
const game = useGame();
const isHydrated = useIsHydrated();
const session = useSession();
const [text, setText] = useState('');
const fetcher = useFetcher();
return (
<fetcher.Form
action={`/play/${gameKey}`}
className={styles.form}
method="post"
onSubmit={(event) => {
const formData = new FormData(event.target);
const key = formData.get('key');
game.messages.push({
key,
owner: session.id,
text: formData.get('text'),
timestamp: Date.now(),
});
setText('');
}}
>
<input
aria-label="Write a chat message"
autoComplete="off"
// Doesn't work in iframe..?
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
name="message"
type="text"
maxLength="1024"
onChange={(event) => {
setText(event.target.value);
}}
tabIndex={0}
value={text}
/>
{!isHydrated && (
<input name="chat" type="hidden" />
)}
<input name="key" type="hidden" value={Math.random()} />
<input className={styles.hidden} name="action" type="submit" value="message" />
</fetcher.Form>
);
}

View File

@ -0,0 +1,62 @@
import {useParams} from '@remix-run/react';
import ScrollableFeed from 'react-scrollable-feed';
import {useGame, useIsHydrated} from '#hooks';
import styles from './index.module.css';
import Form from './form';
import PlayersChatMessage from './message';
export default function PlayersChatSpace() {
const game = useGame();
const isHydrated = useIsHydrated();
const {gameKey} = useParams();
let messageOwner = false;
return (
<div className={styles.chat}>
<ScrollableFeed>
<div className={styles.stretch} />
{
isHydrated
? (
game.messages
.filter((message) => game.players[message.owner])
.sort((l, r) => l.timestamp < r.timestamp ? -1 : 1)
.map((message) => {
const $message = (
<PlayersChatMessage
key={message.key}
isShort={messageOwner === message.owner}
message={message}
name={game.players[message.owner].name}
/>
);
messageOwner = message.owner;
return $message;
})
)
: (
<iframe
className={styles['messages-iframe']}
src={`/play/${gameKey}/messages`}
title="Chat messages"
/>
)
}
</ScrollableFeed>
{
isHydrated
? (
<Form />
)
: (
<iframe
className={styles['form-iframe']}
src={`/play/${gameKey}/chat-form`}
title="Chat form"
/>
)
}
</div>
);
}

View File

@ -0,0 +1,49 @@
.chat {
display: flex;
flex-direction: column;
font-size: 0.75rem;
height: 100%;
margin-top: auto;
overflow: hidden;
> div {
display: flex;
flex-direction: column;
}
}
.form {
background-color: #222;
}
.form input[type="text"] {
background-color: #333;
border: none;
border-image-width: 0;
border-radius: 1rem;
color: #aaa;
font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;
font-size: 1.5rem;
margin: 1rem;
padding: 0.75rem;
resize: none;
width: calc(100% - 2rem);
}
.hidden {
display: none;
}
.form-iframe {
border: none;
height: calc(7em + 3px);
}
.messages-iframe {
border: none;
height: calc(100% - 0.5rem);
width: 100%;
}
.stretch {
flex-grow: 1;
}

View File

@ -0,0 +1,43 @@
import PropTypes from 'prop-types';
import ReactMarkdown from 'react-markdown';
import styles from './index.module.css';
export default function PlayersChatMessageSpace(props) {
const {message: {text, timestamp}, isShort, name} = props;
const dtf = new Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
});
const date = new Date(timestamp);
return (
<div
className={[styles.message, isShort && styles.short].filter(Boolean).join(' ')}
>
{
!isShort && (
<header>
<div className={styles.owner}>{name}</div>
<div className={styles.time}>{dtf.format(date)}</div>
</header>
)
}
<div className={styles.text}>
<div className={styles.markdown}>
<ReactMarkdown>{text}</ReactMarkdown>
</div>
{isShort && <div className={styles.time}>{dtf.format(date)}</div>}
</div>
</div>
);
}
PlayersChatMessageSpace.propTypes = {
isShort: PropTypes.bool.isRequired,
message: PropTypes.shape({
text: PropTypes.string,
timestamp: PropTypes.number,
owner: PropTypes.number,
}).isRequired,
name: PropTypes.string,
};

View File

@ -0,0 +1,84 @@
.message {
color: #aaa;
padding: 0.125rem 1.75rem;
}
.message, .message * {
font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;
}
.message:first-child {
margin-top: 0;
}
.message.short {
margin-top: 0;
margin-bottom: 0;
}
.message.short .time {
display: inline;
font-size: 75%;
visibility: hidden;
}
.message.short:hover .time {
visibility: visible;
}
.message.short header .owner {
display: none;
}
.message:hover {
background-color: #1c1c1c;
}
.message header {
display: flex;
width: 100%;
}
.message .owner {
color: #d68030aa;
font-family: Caladea, 'Times New Roman', Times, serif;
font-weight: bold;
margin-bottom: 0.125em;
font-size: 200%;
}
.message .time {
color: #666;
font-size: 150%;
margin-left: 0.5em;
}
.message header .time {
line-height: 1.75;
}
.message .text {
font-size: 200%;
}
.message .text .markdown * {
margin: 0;
}
.message .text .markdown {
h1, h2, h3, h4, h5, h6 {
line-height: 1em;
}
}
.message .text .markdown {
display: inline;
}
.message .text .markdown :first-child {
margin-top: 0;
}
.message .text .markdown :last-child {
display: inline;
}

View File

@ -0,0 +1,29 @@
import PropTypes from 'prop-types';
import PlayersChatSpace from './chat';
import List from './list';
import styles from './index.module.css';
function PlayersSpace({isOpen}) {
return (
<div
className={[
styles['players-space-container'],
isOpen && styles.opened,
].filter(Boolean).join(' ')}
>
<div className={styles['players-space']}>
<div className={styles['players-list']}>
<List />
</div>
<PlayersChatSpace />
</div>
</div>
);
}
PlayersSpace.propTypes = {
isOpen: PropTypes.bool.isRequired,
};
export default PlayersSpace;

View File

@ -0,0 +1,52 @@
.players-space-container {
background-color: #222222;
font-family: Caladea, 'Times New Roman', Times, serif;
font-size: 3vw;
@media(min-width: 50em) {
font-size: 2vw;
}
height: 100%;
max-width: 100%;
opacity: 0.95;
position: absolute;
left: -100vw;
transition: left 0.1s;
top: calc(6rem - 1px);
width: 100%;
z-index: 10;
}
.players-space-container.opened {
left: 0;
}
@media(min-width: 80em) {
.players-space-container {
font-size: 1vw;
left: auto;
right: 0;
top: 0;
width: 40vw;
&.opened {
left: auto;
}
}
}
.players-space {
display: flex;
flex-direction: column;
height: calc(100vh - 6rem);
@media(min-width: 80em) {
height: 100vh;
}
width: 100%;
}
@media(min-width: 64em) {
.players-space {
float: left;
position: static;
}
}

View File

@ -0,0 +1,219 @@
import {
Form,
useFetcher,
useSearchParams,
} from "@remix-run/react";
import {useEffect, useState} from 'react';
import {Presence} from '#state/globals';
import {useGame, useIsHydrated, useSession} from '#hooks';
import styles from './index.module.css';
export function playerJoinedUpdateStyle(index, {name, score}) {
return `
<style>
.${styles.player}:nth-child(${index + 1}) {
display: inline-block !important;
order: 10;
}
.${styles.player}:nth-child(${index + 1})
.${styles.name} {
opacity: 1;
text-decoration: none;
}
.${styles.player}:nth-child(${index + 1})
.${styles.name}
.${styles.streaming}:before {
content: '${name.replaceAll("'", "\\'")}';
}
.${styles.player}:nth-child(${index + 1})
.${styles.score}
.${styles.number}:before {
content: '${score}';
}
</style>
`;
}
export function playerNameUpdateStyle(index, name) {
return `
<style>
.${styles.player}:nth-child(${index + 1})
.${styles.name}
.${styles.rendered} {
display:none;
}
.${styles.player}:nth-child(${index + 1})
.${styles.name}
.${styles.streaming}:before {
content: '${name.replaceAll("'", "\\'")}';
}
</style>
`;
}
export function playerPresenceUpdateStyle(presence, index) {
return `
<style>
.${styles.player}:nth-child(${index + 1})
.${styles.name} {
opacity: ${Presence.ACTIVE === presence ? '1' : '0.3'};
text-decoration: ${Presence.ACTIVE === presence ? 'none' : 'line-through'};
}
</style>
`;
}
export function playerRemoveUpdateStyle(index) {
return `
<style>
.${styles.player}:nth-child(${index + 1}) {
display: none !important;
}
</style>
`;
}
export const sortPlayers = (game) => (l, r) => {
if (+l[0] === game.czar) {
return -1;
}
if (+r[0] === game.czar) {
return 1;
}
if (
Presence.INACTIVE === l[1].presence
&& Presence.ACTIVE === r[1].presence
) {
return 1;
}
if (
Presence.INACTIVE === r[1].presence
&& Presence.ACTIVE === l[1].presence
) {
return -1;
}
return r[1].score - l[1].score;
};
export default function List() {
const game = useGame();
const session = useSession();
const isHydrated = useIsHydrated();
const [searchParams] = useSearchParams();
const [isEditing, setIsEditing] = useState(searchParams.has('edit-name'));
const fetcher = useFetcher();
useEffect(() => {
if ('submitting' === fetcher.state) {
setIsEditing(false);
}
}, [fetcher.state]);
const players = Object.entries(game.players);
return (
<div className={styles['players-list']}>
{
players
.sort(sortPlayers(game))
.concat(
isHydrated
? []
: (
Array.from({length: 9 - players.length})
.map((e, i) => [-10 - i, {name: '', score: ''}])
)
)
.map(([id, player]) => {
const isCzar = game.czar === +id;
const isOwner = game.owner === +id;
const isSelf = session.id === +id;
return (
<div
key={+id}
className={[
styles.player,
Presence.INACTIVE === player.presence && styles.blurred,
'' === player.name && styles.hidden,
isCzar && styles.czar,
isOwner && styles.owner,
isSelf && styles.self,
].filter(Boolean).join(' ')}
>
{
isSelf
? (
isEditing
? (
<fetcher.Form method="put">
<input
aria-label="Edit your name"
// It's fine; being dumped here should be expected.
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
type="text"
maxLength="24"
name="name"
onBlur={() => {
setIsEditing(false);
}}
onKeyDown={({key}) => {
if ('Escape' === key) {
setIsEditing(false);
}
}}
placeholder={player.name}
ref={(input) => input && input.focus()}
/>
{
isHydrated
? (
<button
className={styles.hydrated}
name="action"
type="submit"
value="rename"
>
Confirm
</button>
)
: (
<>
<button name="action" type="submit" value="rename">Confirm</button>
<button name="cancel" type="submit">Cancel</button>
</>
)
}
</fetcher.Form>
)
: (
<Form
onSubmit={(event) => {
setIsEditing(true);
event.preventDefault();
}}
>
<input name="chat" type="hidden" />
<input name="edit-name" type="hidden" />
<button className={styles.name} type="submit">
{/* Optimism. */}
{fetcher.formData?.get('name') || player.name}
</button>
</Form>
)
)
: (
<span className={styles.name}>
<span className={styles.rendered}>{player.name}</span>
<span className={styles.streaming}></span>
</span>
)
}
<span className={styles.score}>[{
<span className={styles.number}>{player.score}</span>
}]</span>
</div>
);
})
}
</div>
);
}

View File

@ -0,0 +1,157 @@
.players-list {
background-color: #262626;
box-shadow: 0 4px 4px -6px black;
display: flex;
flex-wrap: wrap;
height: 100%;
overflow-y: auto;
padding: 0.5rem 0.5rem 0 0.5rem;
}
.player {
background-color: #171717;
border-radius: 5px;
color: hsl(0, 0%, 67%);
display: inline-block;
font-family: var(--default-font);
font-size: 150%;
margin: 0 0.5rem 0.5rem 0;
padding: 0.5em;
white-space: nowrap;
}
.player form {
display: inline-block;
}
.player button {
background-color: inherit;
border: none;
color: inherit;
font-size: inherit;
&[name="cancel"] {
font-size: 0.7em;
margin: 0 6px 0 -6px;
opacity: 0.4;
&:hover {
opacity: 0.6;
}
}
&[name="action"] {
color: #d68030;
font-family: Strike;
margin-left: -6px;
&:hover {
color: #d69d67;
}
}
&.hydrated {
display: none;
}
}
.player.czar {
background-color: #b1b1b1;
color: #1c1c1c;
display: block;
font-size: 200%;
margin: 0 0 0.5rem 0;
top: 1em;
text-align: center;
width: 100%;
}
.player.czar .spinny {
animation: spinny 5s infinite;
display: inline-block;
}
@keyframes spinny {
from { transform: rotate(0deg); }
25% { transform: rotate(0deg); }
50% { transform: rotate(180deg); }
75% { transform: rotate(180deg); }
to { transform: rotate(0deg); }
}
.player.owner::before {
content: '👑 ';
}
.player.czar .score {
color: #888;
font-size: 80%;
.number {
text-shadow:
-1px 0px 0px #444,
1px 0px 0px #444,
0px -1px 0px #444,
0px 1px 0px #444
;
}
}
.player .name {
margin-right: 6px;
}
.player.czar .name {
&::after {
color: #000;
content: ' (terrible)';
font-size: 80%;
}
}
.player.self .name {
cursor: pointer;
}
.player.self .score {
&::before {
color: #fff;
content: '(you) ';
font-size: 80%;
margin-left: -6px;
}
}
.player.self.czar .name {
&::after {
color: #000;
}
}
.player.blurred .name {
opacity: 0.3;
text-decoration: line-through;
}
.player input {
background: #333;
color: #ffffff;
margin-right: 6px;
padding-block: 1px;
padding-inline: 6px;
font-family: var(--default-font);
font-size: 100%;
}
.player .score {
color: #555;
font-size: 100%;
.number {
color: #d68030;
font-style: italic;
font-family: Caladea, 'Times New Roman', Times, serif;
}
}
.rendered, .streaming {
--nothing: 0;
}
.hidden {
display: none;
}

View File

@ -0,0 +1,230 @@
import {
json,
redirect,
useLoaderData,
useNavigate,
useParams,
useSearchParams,
} from '@remix-run/react';
import {useEffect, useState} from 'react';
import FluidText from '#fluid-text';
import {GameContext, SelectionContext, SessionContext} from '#hooks/context';
import {Game} from '#state/game';
import {State} from '#state/globals';
import {juggleSession, loadSession} from '#state/session';
import '../../fonts/index.css';
import Bar from './bar';
import styles from './index.module.css';
import Players from './players';
export const meta = ({params: {gameKey}}) => {
return [{title: `${gameKey} | Doing Terrible`}];
};
// :)
export function shouldRevalidate() {
return false;
}
export async function action({params, request}) {
const {loadGame} = await import('#state/game.server');
const formData = await request.formData();
const {gameKey} = params;
if (formData.has('cancel')) {
return redirect(`/play/${gameKey}?chat`);
}
const game = await loadGame(gameKey);
if (!game) {
throw new Response('', {status: 400});
}
const session = await loadSession(request);
if (!session.id || !game.players[session.id]) {
throw new Response('', {status: 400});
}
game.handleAction(formData, session);
// no-js chat message
if (formData.has('chat')) {
return redirect(`/play/${gameKey}/chat-form`);
}
else {
return redirect(`/play/${gameKey}`);
}
}
export async function loader({params, request}) {
const {gameKey} = params;
const {loadGame} = await import('#state/game.server');
const game = await loadGame(gameKey);
if (!game) {
throw redirect('/');
}
const session = await juggleSession(request);
if (
9 === Object.keys(game.players).length
&& !game.players[session.id]
) {
throw redirect('/join?full');
}
return json({
game: game.loaderData(session),
session,
});
}
// import.meta.glob
import * as starting from './status/starting';
import * as answering from './status/answering';
import * as awarding from './status/awarding';
import * as awarded from './status/awarded';
import * as finished from './status/finished';
import * as paused from './status/paused';
const Components = {
[State.STARTING]: starting,
[State.ANSWERING]: answering,
[State.AWARDING]: awarding,
[State.AWARDED]: awarded,
[State.FINISHED]: finished,
[State.PAUSED]: paused,
};
const NeedsCookie = {
Black: () => <FluidText><div>Need cookies enabled to play!</div></FluidText>,
White: () => <FluidText><div className={styles['no-cookie']}>Sorry!</div></FluidText>,
};
function Play() {
const [searchParams] = useSearchParams();
const mutedTuple = useState(true);
const [isMuted] = mutedTuple;
const unreadTuple = useState(0);
const navigate = useNavigate();
const {gameKey} = useParams();
const {
game,
session,
} = useLoaderData();
const chatOpenTuple = useState(searchParams.has('chat'));
const selectionTuple = useState(
game.players[session.id]?.answer || searchParams.getAll('selection').map((i) => +i) || []
);
const setSelection = selectionTuple[1];
const [, forceRender] = useState();
const [message, setMessage] = useState('');
useEffect(() => {
switch (game.state) {
case State.ANSWERING:
case State.AWARDING:
setMessage(game.blackCard.replace(/_+/g, 'blank'));
break;
case State.AWARDED:
case State.FINISHED: {
const text = game.blackCard;
let utterance = '';
const blanksCount = text.split('_').length - 1;
if (0 === blanksCount) {
utterance += `${text} ${game.answers[game.winner[0]][0]}`;
}
else {
utterance += game.answers[game.winner[0]]
.reduce((r, text) => r.replace(/_+/, text), text);
}
utterance += '.\n';
utterance += `gg ${game.players[game.winner[1]].name}`;
setMessage(utterance);
break;
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [game.state, setMessage]);
useEffect(() => {
if (isMuted) {
window.speechSynthesis?.cancel();
}
else {
window.speechSynthesis?.speak(new SpeechSynthesisUtterance(message));
}
}, [isMuted, message]);
// Mutation.
useEffect(() => {
function onResize() {
const em = parseFloat(getComputedStyle(document.body).fontSize);
game.showUnread = !chatOpenTuple[0] && window.innerWidth < 80 * em;
}
onResize();
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [game, chatOpenTuple]);
useEffect(() => {
function handler(event) {
for (const action of JSON.parse(event.data)) {
const {state: lastState} = game;
Game.mutateJson(game, action);
switch (action.type) {
case 'destroy': {
navigate('/');
return;
}
case 'message':
if (game.showUnread) {
unreadTuple[1](unreadTuple[0] + 1);
}
break;
case 'state':
if (![game.state, lastState].includes(State.PAUSED)) {
setSelection([]);
}
break;
}
}
forceRender(Math.random());
}
let eventSource = new EventSource(`/play/${gameKey}/actions`);
eventSource.addEventListener('action', handler);
eventSource.addEventListener('error', () => {
window.location.reload();
});
return () => {
eventSource.removeEventListener('action', handler);
eventSource.close();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameKey, navigate]);
const Component = session.id ? Components[game.state] : NeedsCookie;
return (
<SessionContext.Provider value={session}>
<GameContext.Provider value={game}>
<SelectionContext.Provider value={selectionTuple}>
<div className={styles.play}>
<Players isOpen={chatOpenTuple[0]} />
<div className={styles['black-and-white']}>
<div className={styles['black-wrapper']}>
<Bar
mutedTuple={mutedTuple}
chatOpenTuple={chatOpenTuple}
gameKey={gameKey}
unreadTuple={unreadTuple}
/>
<div className={styles.black}><Component.Black /></div>
</div>
<div className={styles['white-wrapper']}>
<div className={styles.white}><Component.White /></div>
</div>
</div>
</div>
</SelectionContext.Provider>
</GameContext.Provider>
</SessionContext.Provider>
);
}
Play.defaultProps = {};
Play.propTypes = {};
Play.displayName = 'Play';
export default Play;

View File

@ -0,0 +1,19 @@
import BlackCardText from '#black-card-text';
import {useSelection} from '#hooks';
import {useGame, useSession} from '#hooks';
function Answering() {
const [selection] = useSelection();
const game = useGame();
const session = useSession();
return <BlackCardText answers={selection.map((answer) => game.players[session.id].cards[answer])} />;
}
Answering.defaultProps = {};
Answering.propTypes = {};
Answering.displayName = 'Answering';
export default Answering;

View File

@ -0,0 +1,2 @@
export {default as Black} from './black';
export {default as White} from './white';

View File

@ -0,0 +1,39 @@
.info {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
}
.answers {
height: 85%;
justify-content: space-around;
text-align: center;
}
.padded {
height: 100%;
padding: 4vh 4vw;
}
.title {
color: #555;
font-family: Smack;
font-size: 2.25vw;
height: 15%;
padding: 0.25em;
text-align: center;
&.bigger {
height: 50%;
}
}
.name {
color: #d68030;
font-family: Strike;
}
.normal {
color: #888;
font-family: Shark;
}

View File

@ -0,0 +1,59 @@
import Answers from '#answers';
import FluidText from '#fluid-text';
import {useGame, useSession} from '#hooks';
import styles from './index.module.css';
function Answering() {
const game = useGame();
const session = useSession();
if (game.czar === session.id) {
return (
<div className={styles.info}>
<div className={styles.padded}>
<div className={styles.answers}>
<FluidText>
<span className={styles.name}>You</span>
{' '}
<span className={styles.normal}>are the</span>
{' '}
terrible
</FluidText>
</div>
</div>
<div className={styles.title}><FluidText>After the others submit, you will choose the victor.</FluidText></div>
</div>
);
}
if (game.players[session.id].answer) {
return (
<div className={styles.info}>
<div className={[styles.title, styles.bigger].join(' ')}><FluidText>After the others submit, a victor will be chosen.</FluidText></div>
</div>
);
}
const count = Math.max(1, game.blackCard.split('_').length - 1);
return (
<div className={styles.info}>
<div className={styles.answers}>
<Answers
choices={game.players[session.id].cards}
count={count}
/>
</div>
<div className={styles.title}>
<FluidText>
<div>
<span className={styles.name}>{game.players[game.czar].name}</span>
{' '}
<span className={styles.normal}>will decide your fate</span>
</div>
</FluidText>
</div>
</div>
);
}
Answering.displayName = 'Answering';
export default Answering;

View File

@ -0,0 +1,15 @@
import BlackCardText from '#black-card-text';
import {useGame} from '#hooks';
function Awarded() {
const game = useGame();
return <BlackCardText answers={game.answers[game.winner[0]]} />;
}
Awarded.defaultProps = {};
Awarded.propTypes = {};
Awarded.displayName = 'Awarded';
export default Awarded;

View File

@ -0,0 +1,2 @@
export {default as Black} from './black';
export {default as White} from './white';

View File

@ -0,0 +1,14 @@
.awarded {
font-size: 5vw;
height: 100%;
padding: 5vh 10vw;
text-align: center;
width: 100%;
}
.winner {
color: #d68030;
display: inline-block;
font-family: Strike;
}

View File

@ -0,0 +1,26 @@
import {useGame} from '#hooks';
import FluidText from '#fluid-text';
import styles from './index.module.css';
function Awarded() {
const game = useGame();
return (
<div className={styles.awarded}>
<FluidText>
<span className={styles.winner}>{game.players[game.winner[1]].name}</span>
{' '}
won!
</FluidText>
</div>
);
}
Awarded.defaultProps = {};
Awarded.propTypes = {};
Awarded.displayName = 'Awarded';
export default Awarded;

View File

@ -0,0 +1,16 @@
import BlackCardText from '#black-card-text';
import {useGame, useSelection} from '#hooks';
function Awarding() {
const [selection] = useSelection();
const game = useGame();
return <BlackCardText answers={game.answers[selection[0]]} />;
}
Awarding.defaultProps = {};
Awarding.propTypes = {};
Awarding.displayName = 'Awarding';
export default Awarding;

View File

@ -0,0 +1,2 @@
export {default as Black} from './black';
export {default as White} from './white';

View File

@ -0,0 +1,18 @@
.title {
color: #777;
font-size: 3.5vw;
height: 15%;
padding: 0 8vw;
text-align: center;
.name {
color: #d68030;
font-family: Strike;
}
}
.answers {
align-content: flex-start;
display: flex;
flex-wrap: wrap;
height: 85%;
}

View File

@ -0,0 +1,67 @@
import Answers from '#answers';
import FluidText from '#fluid-text';
import {useGame, useSession} from '#hooks';
import styles from './index.module.css';
function Awarding() {
const game = useGame();
const session = useSession();
const czarName = game.players[game.czar].name;
const isCzar = game.czar === session.id;
let key = 0;
const choices = game.answers
.map((answer) => (
<>
{answer.reduce(
(r, text) => [
...r,
...(r.length > 0 ? [<span className={styles.separator} key={key++}> / </span>] : []),
<span key={key++}>{text}</span>,
],
[],
)}
</>
));
return (
<>
<div className={styles.answers}>
<Answers
choices={choices}
count={1}
className={isCzar ? '' : styles.noselect}
disabled={!isCzar}
/>
</div>
<div className={styles.title}>
<FluidText>
{
isCzar
? (
<div>
Choose
{' '}
<span className={styles.normal}>the</span>
{' '}
<span className={styles.name}>victor</span>
</div>
)
: (
<div>
<span className={styles.name}>{czarName}</span>
{' '}
<span className={styles.normal}>is choosing the</span>
{' '}
victor
</div>
)
}
</FluidText>
</div>
</>
);
}
Awarding.displayName = 'Awarding';
export default Awarding;

View File

@ -0,0 +1,25 @@
import FluidText from '#fluid-text';
import {useGame} from '#hooks';
import styles from './index.module.css';
function Finished() {
const game = useGame();
return (
<div className={styles.winnerWrapper}>
<FluidText>
<span className={styles.winner}>{game.players[game.winner[1]].name}</span>
{' '}
won!
</FluidText>
</div>
);
}
Finished.defaultProps = {};
Finished.propTypes = {};
Finished.displayName = 'Finished';
export default Finished;

View File

@ -0,0 +1,2 @@
export {default as Black} from './black';
export {default as White} from './white';

View File

@ -0,0 +1,49 @@
.form {
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
}
.start {
width: 80%;
margin: auto;
}
.text {
padding: 4vh 4vw;
}
.winnerWrapper {
height: 100%;
padding: 0 8vw;
text-align: center;
width: 100%;
}
.winner {
color: #d68030;
font-family: Strike;
}
.message {
height: 100%;
text-align: center;
}
.first {
height: 60%;
padding: 2vh 2vw;
}
.owner {
color: #d68030;
font-family: Strike;
}
.second {
height: 30%;
padding: 2vh 8vw;
text-underline-offset: 0.75em;
}

View File

@ -0,0 +1,61 @@
import {Link, useFetcher} from '@remix-run/react';
import FluidText from '#fluid-text';
import {useGame, useIsHydrated, useSession} from '#hooks';
import styles from './index.module.css';
function Finished() {
const fetcher = useFetcher({key: 'finished'});
const game = useGame();
const isHydrated = useIsHydrated();
const session = useSession();
return game.owner === session.id
? (
<fetcher.Form
className={styles.form}
method="put"
>
{!isHydrated && (
<input name="redirect" type="hidden" />
)}
<button
aria-label="Restart the game"
className={styles.start}
name="action"
type="submit"
value="start"
>
{/* hi */}
<FluidText><span className={styles.text}>Restart</span></FluidText>
</button>
</fetcher.Form>
)
: (
<div className={styles.message}>
<div className={styles.first}>
<FluidText>
Harass
{' '}
<span className={styles.owner}>{game.players[game.owner].name}</span>
{' '}
to restart the game
</FluidText>
</div>
<span className={styles.or}>or</span>
<div className={styles.second}>
<FluidText>
<Link className={styles['create-your-own']} to="/create">create a game</Link>
</FluidText>
</div>
</div>
);
}
Finished.defaultProps = {};
Finished.propTypes = {};
Finished.displayName = 'Finished';
export default Finished;

View File

@ -0,0 +1,54 @@
import {useFetcher} from '@remix-run/react';
import FluidText from '#fluid-text';
import {useGame, useSession} from '#hooks';
import styles from './index.module.css';
function Paused() {
const fetcher = useFetcher({key: 'bots'});
const game = useGame();
const session = useSession();
return (
<div className={styles.paused}>
<div
className={
[styles.message, game.owner === session.id && styles.half]
.filter(Boolean).join(' ')
}
>
<FluidText>
Paused!
</FluidText>
</div>
{game.owner === session.id && (
<div className={[styles.message, styles.half].join(' ')}>
<div className={styles.label}>
<FluidText>
You may add some bots to keep the game going!
</FluidText>
</div>
<div className={styles.form}>
<fetcher.Form method="put">
<button
name="action"
type="submit"
value="bots"
>
add some bots
</button>
</fetcher.Form>
</div>
</div>
)}
</div>
);
}
Paused.defaultProps = {};
Paused.propTypes = {};
Paused.displayName = 'Paused';
export default Paused;

View File

@ -0,0 +1,2 @@
export {default as Black} from './black';
export {default as White} from './white';

View File

@ -0,0 +1,39 @@
.paused {
text-align: center;
height: 100%;
width: 100%;
}
.paused form {
line-height: normal;
}
.message {
height: 100%;
padding: 2vh 8vw;
width: 100%;
}
.half {
height: 50%;
}
.label {
height: 40%;
}
.form {
display: flex;
flex-direction: column;
justify-content: center;
height: 50%;
margin: 2vh 0;
button {
background: #222;
border: 1px solid #777;
color: #fa873a;
padding: 0.5em;
font-size: 2em;
}
}

View File

@ -0,0 +1,19 @@
import FluidText from '#fluid-text';
import styles from './index.module.css';
function Paused() {
return (
<div className={styles.message}>
<FluidText>There are not enough active players to continue the game!</FluidText>
</div>
);
}
Paused.defaultProps = {};
Paused.propTypes = {};
Paused.displayName = 'Paused';
export default Paused;

View File

@ -0,0 +1,74 @@
import {useFetcher, useParams} from '@remix-run/react';
import FluidText from '#fluid-text';
import {Presence} from '#state/globals';
import {useGame, useIsHydrated, useSession} from '#hooks';
import styles from './black.module.css';
function Starting() {
const {gameKey} = useParams();
const fetcher = useFetcher({key: 'starting'});
const game = useGame();
const isHydrated = useIsHydrated();
const session = useSession();
const enoughPlayersToStart = 2 < Object.entries(game.players)
.filter(([, {presence}]) => Presence.INACTIVE !== presence)
.length;
const isOwner = session.id === game.owner;
const ownerName = game.players[game.owner].name;
return (
<div className={styles.starting}>
{
enoughPlayersToStart
? (
isOwner
? (
<div className={styles['lets-go']}>
<div className={styles.excuse}>LET&apos;S GO</div>
<fetcher.Form
className={styles.start}
method="put"
>
{!isHydrated && (
<input name="redirect" type="hidden" />
)}
<input name="game" type="hidden" value={gameKey} />
<button
aria-label="Start the game"
name="action"
type="submit"
value="start"
>
<div className={styles.text}>Start</div>
</button>
</fetcher.Form>
</div>
)
: (
<FluidText className={styles.waiting}>
<div>
Waiting for
{' '}
<span className={styles.owner}>{ownerName}</span>
{' '}
to start!
</div>
</FluidText>
)
)
: (
<div className={styles.excuse}>Go get more players</div>
)
}
</div>
);
}
Starting.defaultProps = {};
Starting.propTypes = {};
Starting.displayName = 'Starting';
export default Starting;

View File

@ -0,0 +1,88 @@
.owner {
color: #d68030;
font-family: Strike;
}
.excuse {
color: #d68030;
display: inline-block;
padding: 3vh 3vw;
font-family: Strike;
font-size: 8vw;
margin: 0;
text-align: center;
}
.starting {
display: flex;
width: 100%;
}
.lets-go {
align-items: center;
display: flex;
flex-direction: column;
font-size: 12vw;
height: 100%;
width: 100%;
@media(min-width: 64em) {
flex-direction: row;
font-size: 6.25vw;
}
}
.excuse, .start {
height: 100%;
width: 100%;
flex-direction: column;
justify-content: center;
}
.excuse {
display: flex;
> div {
display: inline-table;
padding: 3vh 3vw;
width: 90%;
}
}
.start {
display: flex;
flex-direction: row;
@media(min-width: 64em) {
height: 100%;
width: 50%;
}
}
.start button {
background-color: #222;
border: 1px solid black;
border-radius: 5px;
color: #f64030;
font-size: inherit;
text-transform: uppercase;
width: 100%;
&:hover {
background-color: #272727;
color: #fa7a6e;
}
.text {
animation: hint 1s infinite;
font-family: Strike;
padding: 2vw;
}
}
@keyframes hint {
from { opacity: 0.5; }
50% { opacity: 1; }
to { opacity: 0.5; }
}
.waiting {
font-size: 8vh;
}

View File

@ -0,0 +1,2 @@
export {default as Black} from './black';
export {default as White} from './white';

View File

@ -0,0 +1,27 @@
import {Link, useParams} from '@remix-run/react';
import FluidText from '#fluid-text';
import styles from './white.module.css';
function Starting() {
const {gameKey} = useParams();
return (
<div className={styles.info}>
<div className={styles.description}>Share the code</div>
<Link
className={styles.destination}
to={`/play/${gameKey}`}
>
<span className={styles['game-key']}>{gameKey}</span>
</Link>
</div>
);
}
Starting.defaultProps = {};
Starting.propTypes = {};
Starting.displayName = 'Starting';
export default Starting;

View File

@ -0,0 +1,54 @@
.description {
font-family: Smack;
font-size: 8vw;
@media(min-width: 64em) {
font-size: 80px;
}
height: 20%;
}
.destination {
display: flex;
height: 80%;
flex-direction: column;
flex-wrap: wrap;
justify-content: flex-end;
text-decoration: none;
}
.game-key {
color: #d68030;
display: inline-table;
letter-spacing: 1vw;
font-family: Georgia, 'Times New Roman', Times, serif;
font-size: 22vw;
@media(min-width: 80em) {
font-size: 15vw;
}
text-shadow:
-1px 0 1px #111,
1px 0 1px #111,
0 1px 1px #111,
0 -1px 1px #111,
1px 1px 3px #000
;
}
.info {
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
padding: 4vh 4vw;
text-align: center;
width: 100%;
.message {
color: #666;
height: 30%;
}
.title {
height: 30%;
}
}

View File

@ -0,0 +1,3 @@
import Form from '../play.$gameKey/players/chat/form';
export default Form;

142
app/state/cards.js Normal file
View File

@ -0,0 +1,142 @@
function filter(type, text) {
if (text.match(/insert name/i)) {
return false;
}
return true;
}
function sanitize(type, text) {
// Trailing periods.
if ('white' === type) {
text = text.replace(/(.*)([^.]+)\.+$/, '$1$2');
}
// Teh dumb.
text = text.replace('better then', 'better than');
text = text.replace('worse then', 'worse than');
text = text.replace('One in then', 'One in ten');
text = text.replace('_ then _', '_ than _');
text = text.replace('pount', 'pound');
return text;
}
function isCompactJson(json) {
return !Array.isArray(json);
}
function getCardText(card) {
return 'string' === typeof card ? card : card.text;
}
function readCompactJsonPacks(json) {
const packs = [];
for (let j = 0; j < json.packs.length; ++j) {
const pack = json.packs[j];
let tokens = undefined;
const black = pack.black
.filter((k) => filter('black', getCardText(json.black[k])))
.map((k) => sanitize('black', getCardText(json.black[k])));
black.forEach((text, i) => {
const matches = text.match(/\[([^\]]+)\]/g);
if (matches) {
if (!tokens) {
tokens = {};
}
if (!tokens[i]) {
tokens[i] = {};
}
tokens[i].black = matches.map((match) => match.slice(1, -1));
}
});
const white = pack.white
.filter((k) => filter('white', getCardText(json.white[k])))
.map((k) => sanitize('white', getCardText(json.white[k])));
white.forEach((text, i) => {
const matches = text.match(/\[([^\]]+)\]/g);
if (matches) {
if (!tokens) {
tokens = {};
}
if (!tokens[i]) {
tokens[i] = {};
}
tokens[i].white = matches.map((match) => match.slice(1, -1));
}
});
packs.push({
...pack,
black,
white,
tokens,
});
}
return packs;
}
function normalizeToCompactJson(json) {
const normalized = {
black: [],
white: [],
packs: [],
};
for (let j = 0; j < json.length; ++j) {
const pack = json[j];
normalized.packs.push({
...pack,
black: pack.black.map((card) => normalized.black.push(getCardText(card)) - 1),
white: pack.white.map((card) => normalized.white.push(getCardText(card)) - 1),
});
}
return normalized;
}
function readFullJsonPacks(json) {
return readCompactJsonPacks(normalizeToCompactJson(json));
}
export function readJsonPacks(json) {
return isCompactJson(json)
? readCompactJsonPacks(json)
: readFullJsonPacks(json);
}
export function shuffle(cards) {
let currentIndex = cards.length, randomIndex;
while (currentIndex > 0) {
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
[cards[currentIndex], cards[randomIndex]] = [cards[randomIndex], cards[currentIndex]];
}
return cards;
}
// Algorithm L(-ish :))
export function sample(packs, k) {
const n = packs.reduce((n, {cards: {length}}) => n + length, 0);
const reservoir = Array(k);
// Encode the "stream" into a jump table.
const jump = [];
let i;
for (i = 0; i < packs.length; ++i) {
jump[i] = 0 === i ? 0 : jump[i - 1] + packs[i - 1].cards.length;
}
let c = 0, d = 0;
for (i = 0; i < k; ++i) {
reservoir[i] = {pack: packs[d].id, card: c};
if (++c === packs[d].cards.length) {
c = 0;
d += 1;
}
}
let W = Math.exp(Math.log(Math.random()) / k);
let j = d;
while (i < n) {
i += Math.floor(Math.log(Math.random()) / Math.log(1 - W)) + 1;
while (j < jump.length - 1 && i >= jump[j + 1]) {
j += 1;
}
if (i < n) {
reservoir[Math.floor(Math.random() * k)] = {pack: packs[j].id, card: i - jump[j]};
W = W * Math.exp(Math.log(Math.random()) / k);
}
}
return reservoir;
}

24
app/state/cards.test.js Normal file
View File

@ -0,0 +1,24 @@
import {expect} from 'chai';
import {
sample,
} from './cards.js';
const D = 20;
const N = 4000;
const packs = Array.from({length: D})
.map((e, id) => ({
id,
cards: Array.from({length: N}, (e, i) => `${id} - ${i}`),
}));
it('takes valid samples', () => {
const reservoir = sample(packs, N);
expect(reservoir.length)
.to.equal(N);
reservoir.forEach(({pack, card}) => {
expect(packs[pack].cards[card])
.to.equal(`${pack} - ${card}`);
})
});

View File

@ -0,0 +1,20 @@
import {createCookie} from '@remix-run/node';
import {singleton} from '#utils/singleton';
const jar = singleton('jar', {});
export async function mutate(request, key, mutator) {
return serialize(key, await mutator(await parse(request, key)))
}
export async function parse(request, key, defaultValue) {
if (!jar[key]) {
jar[key] = createCookie(key);
}
return (await jar[key].parse(request.headers.get('Cookie'))) || defaultValue;
}
export async function serialize(key, data) {
return jar[key].serialize(data);
}

744
app/state/game.js Normal file
View File

@ -0,0 +1,744 @@
import {Delay, Presence, State} from '#state/globals';
import {sample, shuffle} from './cards.js';
import namer from './namer.js';
class Player {
constructor() {
this.reset();
}
closeLongPolls() {
const {longPolls} = this;
this.longPolls = [];
longPolls.forEach((longPoll) => {
longPoll();
});
}
emit(events) {
for (const emitter of this.emitters) {
emitter.emit(events);
}
}
reset() {
this.answer = [];
this.cards = [];
this.emitters = [];
this.longPolls = [];
this.name = '';
this.score = 0;
this.presence = Presence.ACTIVE;
this.timeout = undefined;
}
set timeout(timeout) {
if (this._timeout) {
clearTimeout(this._timeout);
}
this._timeout = timeout;
}
toJSON() {
return {
answer: this.answer.length > 0,
name: this.name,
presence: this.presence,
score: this.score,
};
}
}
export class Game {
static packs = [];
static tokens = {};
constructor() {
this.reset();
}
acceptAward(answerer) {
const answerers = Object.entries(this.answers);
this.winner = [answerer, +answerers[answerer][0]];
this.players[this.winner[1]].score++;
const actions = [
{type: 'winner', payload: this.winner},
{
type: 'score',
payload: {
id: +this.winner[1],
score: this.players[this.winner[1]].score,
},
},
];
this.lastStateChange = Date.now();
if (this.players[this.winner[1]].score === this.scoreToWin) {
this.state = State.FINISHED;
actions.push({type: 'timeout', payload: Delay.DESTRUCT});
}
else {
this.state = State.AWARDED;
actions.push({type: 'timeout', payload: Delay.AWARDED});
}
actions.push({type: 'state', payload: this.state});
return actions;
}
addPlayer(id, name) {
this.players[id] = new Player();
this.players[id].name = name;
return this.players[id];
}
addActionListener(type, listener) {
if (!this.actionListeners[type]) {
this.actionListeners[type] = [];
}
this.actionListeners[type].push(listener);
}
allocateBlackCards() {
const {packs} = this.constructor;
const worstCase = 1 + this.maxPlayers * (this.scoreToWin - 1);
const pool = sample(this.packs.map((id) => ({id, cards: packs[id].black})), worstCase);
for (let i = 0; i < pool.length; ++i) {
this.blackCards[i] = pool[i].pack * 65536 + pool[i].card;
}
shuffle(this.blackCards);
}
allocateBlackReplacements() {
const {packs, tokens} = this.constructor;
for (let i = 0; i < this.blackCards.length; ++i) {
const encoded = this.blackCards[i];
const [pack, card] = [Math.floor(encoded / 65536), encoded % 65536];
if (!packs[pack].tokens?.[card]?.black) {
continue;
}
this.blackCardReplacements[encoded] = [];
for (let j = 0; j < packs[pack].tokens[card].black.length; ++j) {
const token = packs[pack].tokens[card].black[j];
if (tokens[token]) {
const sample = Math.floor(Math.random() * tokens[token].length);
this.blackCardReplacements[encoded].push([token, sample]);
}
}
}
}
allocateCards() {
this.allocateBlackCards();
this.allocateBlackReplacements();
this.allocateWhiteCards();
this.allocateWhiteReplacements();
}
allocateWhiteCards() {
const {packs} = this.constructor;
// Derive the worst case number of white cards from the actual number of answers required from
// the allocated black cards.
const totalAnswers = this.blackCards
// Very last round doesn't matter.
.slice(0, -1)
// Map to card text and accumulate the number of blanks.
.map((encoded) => packs[Math.floor(encoded / 65536)].black[encoded % 65536])
.reduce((total, text) => total + (text.match(/_/g)?.length || 1), 0)
const total = this.packs.reduce((total, id) => total + packs[id].white.length, 0);
const worstCase = this.maxPlayers * 9 + this.maxPlayers * 9 * totalAnswers;
let pool;
// Less than worst case? Allocate them all and keep a discard pile.
if (total <= worstCase) {
this.discard = [];
pool = Array(total);
let i = 0;
for (let j = 0; j < this.packs.length; ++j) {
const {white} = packs[this.packs[j]];
for (let k = 0; k < white.length; ++k) {
pool[i++] = {pack: this.packs[j], card: k};
}
}
}
// Sample a random distribution of the worst-case amount. No discard needed.
else {
pool = sample(this.packs.map((id) => ({id, cards: packs[id].white})), worstCase);
}
for (let i = 0; i < pool.length; ++i) {
this.whiteCards[i] = pool[i].pack * 65536 + pool[i].card;
}
shuffle(this.whiteCards);
}
allocateWhiteReplacements() {
const {packs, tokens} = this.constructor;
for (let i = 0; i < this.whiteCards.length; ++i) {
const encoded = this.whiteCards[i];
const [pack, card] = [Math.floor(encoded / 65536), encoded % 65536];
if (!packs[pack].tokens?.[card]?.white) {
continue;
}
this.whiteCardReplacements[encoded] = [];
for (let j = 0; j < packs[pack].tokens[card].white.length; ++j) {
const token = packs[pack].tokens[card].white[j];
if (tokens[token]) {
const sample = Math.floor(Math.random() * tokens[token].length);
this.whiteCardReplacements[encoded].push([token, sample]);
}
}
}
}
static check(formData) {
const {packs} = this;
const errors = {};
const maxPlayers = +formData.get('maxPlayers');
const scoreToWin = +formData.get('scoreToWin');
// Check that we have enough cards for the worst case.
const packIds = formData.getAll('packs').map((pack) => +pack);
const blackWorstCase = 1 + maxPlayers * (scoreToWin - 1);
if (blackWorstCase > packIds.reduce((total, id) => total + packs[id].black.length, 0)) {
errors.black = 'Not enough black cards, select more packs!';
}
const whiteWorstCase = maxPlayers * 9;
if (whiteWorstCase > packIds.reduce((total, id) => total + packs[id].white.length, 0)) {
errors.white = 'Not enough white cards, select more packs!';
}
return errors;
}
checkAnswers() {
const actions = [];
const players = Object.entries(this.players)
.filter(([, {presence}]) => presence === Presence.ACTIVE)
.filter(([id]) => +id !== this.czar);
if (!players.every(([, {answer}]) => answer.length > 0)) {
return actions;
}
this.answers = players.reduce(
(answers, [id, player]) => ({
...answers,
[id]: player.answer
.map((card) => [card, this.renderCard('white', player.cards[card])]),
}),
{},
);
players.forEach(([id, player]) => {
player.answer = [];
actions.push({type: 'answer', payload: [+id, false]});
});
actions.push({
type: 'answers',
payload: Object.values(this.answers)
.map((answer) => answer.map(([, rendered]) => rendered)),
});
this.lastStateChange = Date.now();
if (this.czar < 0 || Presence.INACTIVE === this.players[this.czar].presence) {
actions.push(...this.forceAward());
}
else {
this.state = State.AWARDING;
actions.push(
{type: 'state', payload: State.AWARDING},
{type: 'timeout', payload: this.secondsPerRound},
);
}
return actions;
}
dealWhiteCard() {
if (0 === this.whiteCards.length) {
this.whiteCards = this.discard;
this.discard = [];
shuffle(this.whiteCards);
}
return this.whiteCards.pop();
}
discardAnswers() {
const discarding = [];
Object.entries(this.answers)
.forEach(([id, answer]) => {
answer.forEach(([card]) => {
if (this.discard) {
discarding.push(this.players[id].cards[card]);
}
const whiteCard = this.dealWhiteCard();
this.players[id].cards[card] = whiteCard;
this.players[id].emit([{
type: 'card',
payload: [
+id,
+card,
this.renderCard('white', whiteCard),
],
}]);
});
});
if (this.discard) {
this.discard.push(...discarding);
}
this.answers = {};
return [{type: 'answers', payload: {}}];
}
emit(actions) {
for (const id in this.players) {
this.players[id].emit(actions);
}
for (const action of actions) {
if (this.actionListeners[action.type]) {
const listeners = [...this.actionListeners[action.type]];
for (const listener of listeners) {
listener(action);
}
}
}
}
forceAnswers(players) {
const count = Math.max(1, this.blackCard.split('_').length - 1);
return players
.map(([id, player]) => {
player.answer = [];
for (let i = 0; i < count; ++i) {
player.answer.push(i);
}
return {type: 'answer', payload: [+id, true]};
});
}
forceAward() {
return this.acceptAward(Math.floor(Math.random() * Object.keys(this.answers).length));
}
handleAction(formData, session) {
switch (formData.get('action')) {
case 'answer': {
switch (this.state) {
case State.ANSWERING:
this.players[session.id].answer = formData.getAll('selection');
this.emit([
{type: 'answer', payload: [session.id, true]},
...this.checkAnswers(),
]);
break;
case State.AWARDING:
this.emit(this.acceptAward(formData.get('selection')));
break;
}
break;
}
case 'bots': {
if (State.PAUSED !== this.state) {
throw new Response('', {status: 400});
}
const activePlayers = Object.entries(this.players)
.filter(([, {presence}]) => presence === Presence.ACTIVE);
const needed = 3 - activePlayers.length;
if (needed <= 0) {
throw new Response('', {status: 400});
}
this.lastStateChange = Date.now();
this.state = State.ANSWERING;
const actions = [
{type: 'state', payload: this.state},
{type: 'timeout', payload: this.secondsPerRound},
];
const bots = [];
let id = Math.min(0, activePlayers.reduce((lowest, [id]) => Math.min(lowest, id), 0));
for (let i = 1; i <= needed; ++i) {
const bot = this.addPlayer(--id, namer());
for (let i = 0; i < 9; ++i) {
bot.cards.push(this.dealWhiteCard());
}
bots.push([id, bot]);
actions.push(
{type: 'joined', payload: {id, player: bot}},
);
}
actions.push(
...this.forceAnswers(bots),
...this.checkAnswers(),
);
this.emit(actions);
break;
}
case 'message': {
const message = formData.get('message');
if ('' === message.trim() || message.length > 1024) {
throw new Response('', {status: 400});
}
const payload = {
key: parseFloat(formData.get('key')),
owner: session.id,
text: message,
timestamp: Date.now(),
};
this.messages.push(payload);
while (this.messages.length > 100) {
this.messages.shift();
}
this.emit([{type: 'message', payload}]);
break;
}
case 'rename': {
const name = formData.get('name');
if (0 === name.length) {
return;
}
if (name.length > 24) {
throw new Response('', {status: 400});
}
this.players[session.id].name = name;
this.emit([{type: 'rename', payload: {id: session.id, name}}]);
break;
}
case 'start': {
if (![State.FINISHED, State.STARTING].includes(this.state)) {
throw new Response('', {status: 400});
}
if (session.id !== this.owner) {
throw new Response('', {status: 401});
}
this.allocateCards();
this.answers = {};
this.blackCard = this.renderCard('black', this.blackCards[0]);
this.czar = +Object.keys(this.players)[1];
this.lastStateChange = Date.now();
this.state = State.ANSWERING;
const actions = [];
const bots = [];
Object.entries(this.players)
.forEach(([id, player]) => {
player.score = 0;
actions.push({type: 'score', payload: {id: +id, score: 0}});
player.cards = [];
for (let i = 0; i < 9; ++i) {
player.cards.push(this.dealWhiteCard());
}
player.emit(player.cards.map((card, i) => ({
type: 'card',
payload: [
+id,
+i,
this.renderCard('white', card),
],
})));
if (+id < 0) {
bots.push([id, player]);
}
});
this.emit([
...actions,
...this.forceAnswers(bots),
{type: 'answers', payload: {}},
{type: 'black-card', payload: this.blackCard},
{type: 'czar', payload: this.czar},
{type: 'state', payload: this.state},
{type: 'timeout', payload: this.secondsPerRound},
]);
break;
}
}
}
loaderData(session) {
const json = this.toJSON();
if (!session.id) {
return json;
}
this.emit(
this.sideEffectsForSession(session)
.map((action) => {
this.constructor.mutateJson(json, action);
return action;
}),
);
const player = this.players[session.id];
json.players[session.id] = {
...player.toJSON(),
answer: player.answer.length > 0 && State.ANSWERING === this.state ? player.answer : false,
cards: player.cards.map((card) => this.renderCard('white', card)),
};
return json;
}
static mutateJson(game, action) {
const {type, payload} = action;
switch (type) {
case 'answer':
game.players[payload[0]].answer = payload[1];
break;
case 'answers':
game.answers = payload;
break;
case 'black-card':
game.blackCard = payload;
break;
case 'card':
game.players[payload[0]].cards[payload[1]] = payload[2];
break;
case 'czar':
game.czar = payload;
break;
case 'destroy':
game.destroyed = true;
break;
case 'joined': {
const {id, player} = payload;
game.players[id] = player;
break;
}
case 'message': {
const index = game.messages.findLastIndex(({key}) => key == payload.key);
if (-1 === index) {
game.messages.push(payload);
}
else {
game.messages[index] = payload;
}
break;
}
case 'owner':
game.owner = payload;
break;
case 'presence':
game.players[payload.id].presence = payload.presence;
break;
case 'remove':
delete game.players[payload];
break;
case 'rename': {
const {id, name} = payload;
game.players[id].name = name;
break;
}
case 'score':
game.players[payload.id].score = payload.score;
break;
case 'state':
game.state = payload;
break;
case 'timeout':
// Little nudge for cache breaking.
game.timeout = payload + (Math.random() * 0.001);
break;
case 'winner':
game.winner = payload;
break;
}
}
removeActionListener(type, listener) {
if (!this.actionListeners[type]) {
return;
}
const listeners = this.actionListeners[type];
listeners.splice(listeners.indexOf(listener), 1);
}
removePlayer(id) {
this.emit([
{type: 'remove', payload: id},
]);
(this.discard ? this.discard : this.whiteCards).push(...this.players[id].cards);
this.players[id].reset();
delete this.players[id];
}
renderCard(type, encoded) {
const {packs, tokens} = this.constructor;
let text = packs[Math.floor(encoded / 65536)][type][encoded % 65536];
this[`${type}CardReplacements`][encoded]?.forEach(([token, replacement]) => {
text = text.replace(`[${token}]`, tokens[token][replacement]);
});
return text;
}
reset() {
this.actionListeners = {};
this.answers = {};
this.blackCard = '';
this.blackCards = [];
this.blackCardReplacements = [];
this.czar = undefined;
this.packs = [];
this.lastStateChange = 0;
this.maxPlayers = 0;
this.messages = [];
this.owner = undefined;
for (const id in this.players) {
this.players[id].reset();
}
this.players = {};
this.scoreToWin = 0;
this.secondsPerRound = 0;
this.state = State.STARTING;
this.whiteCards = [];
this.whiteCardReplacements = [];
this.winner = undefined;
return this;
}
setPlayerInactive(id, remove) {
const player = this.players[id];
player.presence = Presence.INACTIVE;
const actions = [
{type: 'presence', payload: {id, presence: player.presence}},
];
if (this.owner === id) {
const entry = Object.entries(this.players)
.find(([other]) => other !== id && other > 0);
if (entry) {
this.owner = +entry[0];
actions.push({type: 'owner', payload: this.owner});
}
}
switch (this.state) {
case State.AWARDING:
if (this.czar === id) {
actions.push(...this.forceAward());
}
break;
case State.ANSWERING: {
const activePlayers = Object.values(this.players)
.filter(({presence}) => presence === Presence.ACTIVE);
if (activePlayers.length >= 3) {
actions.push(...this.checkAnswers());
}
else {
this.state = State.PAUSED;
this.lastStateChange = Date.now();
this.emit([
{type: 'state', payload: this.state},
{type: 'timeout', payload: Delay.DESTRUCT},
]);
}
break;
}
}
this.emit(actions);
player.timeout = setTimeout(remove, Delay.REMOVED * 1000);
}
sideEffectsForSession(session) {
const actions = [];
let player = this.players[session.id];
let joined = false;
if (!player) {
player = this.addPlayer(session.id, namer());
if (![State.FINISHED, State.STARTING].includes(this.state)) {
for (let i = 0; i < 9; ++i) {
player.cards.push(this.dealWhiteCard());
}
}
joined = true;
}
if (joined) {
this.emit([{type: 'joined', payload: {id: session.id, player}}]);
}
if (player.presence != Presence.ACTIVE) {
player.presence = Presence.ACTIVE;
actions.push({type: 'presence', payload: {id: session.id, presence: player.presence}});
}
player.timeout = undefined;
if (!this.players[this.owner] || this.players[this.owner].presence === Presence.INACTIVE) {
this.owner = session.id;
actions.push({type: 'owner', payload: this.owner});
}
if (State.PAUSED === this.state) {
const activePlayers = Object.entries(this.players)
.filter(([, {presence}]) => presence === Presence.ACTIVE);
if (activePlayers.length >= 3) {
if (!this.players[this.czar] || this.players[this.czar].presence !== Presence.ACTIVE) {
const ids = activePlayers
.map(([id]) => id)
.map((id) => +id);
this.czar = ids[Math.floor(Math.random() * ids.length)];
actions.push({type: 'czar', payload: this.czar});
}
this.lastStateChange = Date.now();
this.state = State.ANSWERING;
actions.push(
{type: 'state', payload: this.state},
{type: 'timeout', payload: this.secondsPerRound},
);
}
}
return actions;
}
tick() {
const sinceLast = (Date.now() - this.lastStateChange) / 1000;
switch (this.state) {
case State.FINISHED:
case State.PAUSED:
case State.STARTING:
if (sinceLast >= Delay.DESTRUCT) {
this.emit([{type: 'destroy'}]);
this.reset();
return false;
}
break;
case State.ANSWERING:
if (sinceLast >= this.secondsPerRound) {
const actions = this.forceAnswers(
Object.entries(this.players)
.filter(([id, {answer}]) => (
+id !== this.czar
&& answer.length === 0
)),
);
actions.push(...this.checkAnswers());
this.emit(actions);
}
break;
case State.AWARDING:
if (sinceLast >= this.secondsPerRound) {
this.emit(this.forceAward());
}
break;
case State.AWARDED:
if (sinceLast >= Delay.AWARDED) {
this.lastStateChange = Date.now();
this.state = State.ANSWERING;
this.blackCards.shift();
this.blackCard = this.renderCard('black', this.blackCards[0]);
const actions = [
{type: 'black-card', payload: this.blackCard},
{type: 'state', payload: this.state},
];
actions.push(...this.discardAnswers());
const activePlayers = Object.entries(this.players)
.filter(([, {presence}]) => presence === Presence.ACTIVE);
if (activePlayers.length >= 3) {
const ids = activePlayers.map(([id]) => id).map((id) => +id);
this.czar = ids[(ids.indexOf(this.czar) + 1) % ids.length];
actions.push(...this.forceAnswers(Object.entries(this.players).filter(([id]) => (
+id < 0
&& this.czar !== +id
))));
actions.push(
{type: 'czar', payload: this.czar},
...this.checkAnswers(),
{type: 'timeout', payload: this.secondsPerRound},
);
}
else {
this.state = State.PAUSED;
actions.push(
{type: 'state', payload: this.state},
{type: 'timeout', payload: Delay.DESTRUCT},
);
}
this.emit(actions);
}
break;
}
return true;
}
timeoutToJSON() {
let timeout;
switch (this.state) {
case State.FINISHED:
case State.PAUSED:
case State.STARTING:
timeout = Delay.DESTRUCT - ((Date.now() - this.lastStateChange) / 1000);
break;
case State.ANSWERING:
case State.AWARDING:
timeout = this.secondsPerRound - ((Date.now() - this.lastStateChange) / 1000);
break;
case State.AWARDED:
timeout = Delay.AWARDED - ((Date.now() - this.lastStateChange) / 1000);
break;
}
return timeout;
}
// toWire
toJSON() {
return {
answers: Object.values(this.answers)
.map((answer) => answer.map(([, rendered]) => rendered)),
blackCard: this.blackCard,
czar: this.czar,
messages: this.messages,
owner: this.owner,
players: Object.entries(this.players)
.map(([id, player]) => [id, player.toJSON()])
.reduce((players, [id, player]) => ({...players, [id]: player}), {}),
state: this.state,
timeout: this.timeoutToJSON(),
winner: this.winner,
};
}
}

255
app/state/game.server.js Normal file
View File

@ -0,0 +1,255 @@
import {PassThrough} from 'node:stream';
import {Delay, Presence, State} from '#state/globals';
import {loadSession} from '#state/session';
import {singleton} from '#utils/singleton';
import {TopLevelLongPoll} from '#utils/top-level-long-poll';
import {unreadUpdateStyle} from '../routes/play.$gameKey/bar';
import {numberUpdateStyle} from '../routes/play.$gameKey/bar/timeout/number';
import {
playerJoinedUpdateStyle,
playerNameUpdateStyle,
playerPresenceUpdateStyle,
playerRemoveUpdateStyle,
sortPlayers,
} from '../routes/play.$gameKey/players/list';
import {Game} from './game.js';
import namer from './namer.js';
const alphabet = 'ABCDEFHIJKLMNPQRSTUVWXYZ';
const free = singleton('free', [
'BOOB', 'JUNK', 'BUTT', 'POOP', 'FART', 'CHIT', 'MEOW', 'WOOF', 'BORK', 'DERP', 'YAWN', 'ZOOM',
'GEEK', 'BEEP', 'HONK', 'GROK', 'NERD', 'PLOP', 'YIKE', 'JAZZ', 'WINK', 'GLOP', 'HISS', 'ZEST',
'JIVE', 'GAGA', 'FLUB', 'JEEP', 'OOPS', 'VIBE', 'ZING', 'FLIP', 'ZEAL', 'VAMP', 'YOGA', 'GOOP',
'FIZZ', 'ZANY', 'DORK', 'FLAP', 'HUNK', 'YOWL', 'WISP', 'YAWP', 'DING', 'DONG', 'WANG', 'BURP',
].reverse().map((gameKey) => [gameKey, new Game()]));
const games = singleton('games', new Map());
function createKey() {
let gameKey;
do {
gameKey = '';
for (let i = 0; i < 4; ++i) {
gameKey += alphabet.charAt(Math.floor(Math.random() * alphabet.length));
}
} while (games.has(gameKey));
return gameKey;
}
export async function createGame(session, formData) {
const [gameKey, game] = free.length > 0 ? free.pop() : [createKey(), new Game()];
games.set(gameKey, game);
game.lastStateChange = Date.now();
game.maxPlayers = +formData.get('maxPlayers');
game.owner = session.id;
game.scoreToWin = +formData.get('scoreToWin');
game.secondsPerRound = +formData.get('secondsPerRound');
game.packs = formData.getAll('packs').map((pack) => +pack);
const bots = +formData.get('bots');
for (let i = 1; i <= bots; ++i) {
game.addPlayer(-i, namer());
}
return gameKey;
}
export function createGameServerLoop() {
return setInterval(() => {
const pruning = [];
for (const [gameKey, game] of games.entries()) {
if (!game.tick()) {
pruning.push(gameKey);
}
}
for (let i = 0; i < pruning.length; ++i) {
const gameKey = pruning[i];
free.push([gameKey, games.get(gameKey)]);
games.delete(gameKey);
}
}, 100);
}
export async function joinList() {
const list = [];
for (const [gameKey, game] of games.entries()) {
const players = Object.entries(game.players);
if (9 === players.length) {
continue;
}
list.push({
completed: (
players.length > 0
? (
players
.map(([, {score}]) => score)
.toSorted((l, r) => l - r)
.pop()
/ game.scoreToWin
)
: 0
),
key: gameKey,
playerCount: (
players
.filter(([, {presence}]) => Presence.ACTIVE === presence)
.length
),
state: game.state,
});
if (100 === list.length) {
break;
}
}
return list;
}
export async function loadGame(gameKey) {
return games.get(gameKey);
}
export async function requestBody(request) {
const session = await loadSession(request);
if (!session) {
return new PassThrough();
}
const {pathname, searchParams} = new URL(request.url);
const matches = pathname.match(/^\/play\/([A-Z]+)$/);
return matches
? new TopLevelLongPoll(async (send) => {
const gameKey = matches[1];
const game = await loadGame(gameKey);
if (request.signal.aborted) return;
const player = game.players[session.id];
let resolve;
const promise = new Promise((resolve_) => {
resolve = resolve_;
});
let closed = false;
let handle;
function close() {
if (closed) {
return;
}
closed = true;
if (handle) {
clearTimeout(handle);
}
game.removeActionListener('destroy', refresh);
game.removeActionListener('presence', presence);
game.removeActionListener('joined', joined);
game.removeActionListener('message', message);
game.removeActionListener('remove', remove);
game.removeActionListener('rename', rename);
game.removeActionListener('state', refresh);
request.signal.removeEventListener('abort', close);
const index = player.longPolls.indexOf(close);
if (-1 !== index) {
player.longPolls.splice(index, 1);
if (0 === player.longPolls.length) {
player.timeout = startTimeout(gameKey, session);
}
}
resolve();
}
const refresh = () => {
send(`<meta http-equiv="refresh" content="0;URL=/play/${gameKey}" />`);
close();
}
async function pumpTimeout() {
let timeout = game.timeoutToJSON();
const fractional = timeout < 10;
let c = timeout;
let m;
if (fractional) {
[c, m] = `${Math.max(0, timeout)}`.split('.');
}
send(numberUpdateStyle(c, m));
handle = setTimeout(pumpTimeout, fractional ? 77 : 1_000);
}
let playerCount = 0;
const playersRemoved = [];
const playerMap = {};
const players = Object.entries(game.players);
players
.sort(sortPlayers(game))
.forEach(([id]) => {
playerMap[id] = playerCount++;
});
function rename({payload}) {
send(playerNameUpdateStyle(playerMap[payload.id], payload.name));
}
let activePlayers = players
.filter(([, {presence}]) => presence === Presence.ACTIVE)
.length;
function presence({payload}) {
if (Presence.ACTIVE === payload.presence) {
activePlayers += 1;
if (game.state === State.STARTING && 3 === activePlayers) {
refresh();
return;
}
}
else {
activePlayers -= 1;
if (game.state === State.STARTING && 2 === activePlayers) {
refresh();
return;
}
}
send(playerPresenceUpdateStyle(payload.presence, playerMap[payload.id]));
}
function joined({payload}) {
activePlayers += 1;
if (game.state === State.STARTING && 3 === activePlayers) {
refresh();
return;
}
if (!playerMap[payload.id]) {
playerMap[payload.id] = 9 === playerCount ? playersRemoved.shift() : playerCount++;
}
send(playerJoinedUpdateStyle(playerMap[payload.id], payload.player));
}
function remove({payload}) {
playersRemoved.push(payload);
send(playerRemoveUpdateStyle(playerMap[payload]));
}
let unread = 0;
function message({payload}) {
if (searchParams.has('chat') || payload.owner === session.id) {
return;
}
send(unreadUpdateStyle(++unread));
}
handle = pumpTimeout();
request.signal.addEventListener('abort', close);
player.longPolls.push(close);
game.addActionListener('destroy', refresh);
game.addActionListener('presence', presence);
game.addActionListener('joined', joined);
game.addActionListener('message', message);
game.addActionListener('remove', remove);
game.addActionListener('rename', rename);
game.addActionListener('state', refresh);
return promise;
})
: new PassThrough();
}
export function startTimeout(gameKey, session) {
return setTimeout(async () => {
const game = await loadGame(gameKey);
if (!game || !game.players[session.id]) {
return;
}
game.setPlayerInactive(session.id, async () => {
const game = await loadGame(gameKey);
if (!game || !game.players[session.id]) {
return;
}
game.removePlayer(session.id);
});
}, Delay.INACTIVE * 1000);
}

Some files were not shown because too many files have changed in this diff Show More