chore: initial
This commit is contained in:
commit
fbeee2bb86
68
.eslintrc.cjs
Normal file
68
.eslintrc.cjs
Normal 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
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
/.cache
|
||||||
|
/build
|
||||||
|
.env
|
17
.vscode/launch.json
vendored
Normal file
17
.vscode/launch.json
vendored
Normal 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
78
README.md
Normal 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
116
app/answers/index.jsx
Normal 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;
|
171
app/answers/index.module.css
Normal file
171
app/answers/index.module.css
Normal 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%;
|
||||||
|
}
|
86
app/black-card-text/index.jsx
Normal file
86
app/black-card-text/index.jsx
Normal 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;
|
27
app/black-card-text/index.module.css
Normal file
27
app/black-card-text/index.module.css
Normal 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
18
app/entry.client.jsx
Normal 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
155
app/entry.server.jsx
Normal 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
60
app/fluid-text/index.jsx
Normal 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;
|
||||||
|
|
13
app/fluid-text/index.module.css
Normal file
13
app/fluid-text/index.module.css
Normal 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;
|
||||||
|
}
|
18
app/fluid-text/initializer.jsx
Normal file
18
app/fluid-text/initializer.jsx
Normal 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
BIN
app/fonts/caladea.woff
Normal file
Binary file not shown.
BIN
app/fonts/flashrogers.ttf
Normal file
BIN
app/fonts/flashrogers.ttf
Normal file
Binary file not shown.
BIN
app/fonts/flashrogerschrome.ttf
Normal file
BIN
app/fonts/flashrogerschrome.ttf
Normal file
Binary file not shown.
BIN
app/fonts/flashrogershalf.ttf
Normal file
BIN
app/fonts/flashrogershalf.ttf
Normal file
Binary file not shown.
BIN
app/fonts/flashrogerspunch.ttf
Normal file
BIN
app/fonts/flashrogerspunch.ttf
Normal file
Binary file not shown.
19
app/fonts/index.css
Normal file
19
app/fonts/index.css
Normal 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
BIN
app/fonts/shark.ttf
Normal file
Binary file not shown.
BIN
app/fonts/smack.ttf
Normal file
BIN
app/fonts/smack.ttf
Normal file
Binary file not shown.
5
app/hooks/context.js
Normal file
5
app/hooks/context.js
Normal 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
4
app/hooks/index.js
Normal 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
7
app/hooks/use-game.js
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import {useContext} from 'react';
|
||||||
|
|
||||||
|
import {GameContext} from './context';
|
||||||
|
|
||||||
|
export default function useGame() {
|
||||||
|
return useContext(GameContext);
|
||||||
|
}
|
7
app/hooks/use-is-hydrated.js
Normal file
7
app/hooks/use-is-hydrated.js
Normal 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;
|
||||||
|
}
|
7
app/hooks/use-selection.js
Normal file
7
app/hooks/use-selection.js
Normal 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
7
app/hooks/use-session.js
Normal 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
106
app/root.css
Normal 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
42
app/root.jsx
Normal 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
4
app/root.module.css
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
.terrible {
|
||||||
|
background-color: #222222;
|
||||||
|
color: #AAAAAA;
|
||||||
|
}
|
131
app/routes/_intro._index/landing.module.css
Normal file
131
app/routes/_intro._index/landing.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
32
app/routes/_intro._index/route.jsx
Normal file
32
app/routes/_intro._index/route.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
3
app/routes/_intro.create/config.js
Normal file
3
app/routes/_intro.create/config.js
Normal 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'];
|
257
app/routes/_intro.create/index.module.css
Normal file
257
app/routes/_intro.create/index.module.css
Normal 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;
|
||||||
|
}
|
36
app/routes/_intro.create/pack-choice.jsx
Normal file
36
app/routes/_intro.create/pack-choice.jsx
Normal 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,
|
||||||
|
};
|
71
app/routes/_intro.create/pack-choice.module.css
Normal file
71
app/routes/_intro.create/pack-choice.module.css
Normal 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;
|
||||||
|
}
|
158
app/routes/_intro.create/route.jsx
Normal file
158
app/routes/_intro.create/route.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
49
app/routes/_intro.join/games/game/index.jsx
Normal file
49
app/routes/_intro.join/games/game/index.jsx
Normal 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,
|
||||||
|
};
|
39
app/routes/_intro.join/games/game/index.module.css
Normal file
39
app/routes/_intro.join/games/game/index.module.css
Normal 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;
|
||||||
|
}
|
16
app/routes/_intro.join/games/index.jsx
Normal file
16
app/routes/_intro.join/games/index.jsx
Normal 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),
|
||||||
|
};
|
5
app/routes/_intro.join/games/index.module.css
Normal file
5
app/routes/_intro.join/games/index.module.css
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
.games {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
}
|
80
app/routes/_intro.join/index.jsx
Normal file
80
app/routes/_intro.join/index.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
76
app/routes/_intro.join/index.module.css
Normal file
76
app/routes/_intro.join/index.module.css
Normal 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
BIN
app/routes/_intro/intro.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 230 KiB |
17
app/routes/_intro/intro.module.css
Normal file
17
app/routes/_intro/intro.module.css
Normal 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;
|
||||||
|
}
|
14
app/routes/_intro/route.jsx
Normal file
14
app/routes/_intro/route.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
28
app/routes/play.$gameKey.actions/route.jsx
Normal file
28
app/routes/play.$gameKey.actions/route.jsx
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
142
app/routes/play.$gameKey.messages/route.jsx
Normal file
142
app/routes/play.$gameKey.messages/route.jsx
Normal 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);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
19
app/routes/play.$gameKey/bar/chat-button.jsx
Normal file
19
app/routes/play.$gameKey/bar/chat-button.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
95
app/routes/play.$gameKey/bar/controls.jsx
Normal file
95
app/routes/play.$gameKey/bar/controls.jsx
Normal 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;
|
47
app/routes/play.$gameKey/bar/index.jsx
Normal file
47
app/routes/play.$gameKey/bar/index.jsx
Normal 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;
|
144
app/routes/play.$gameKey/bar/index.module.css
Normal file
144
app/routes/play.$gameKey/bar/index.module.css
Normal 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;
|
||||||
|
}
|
46
app/routes/play.$gameKey/bar/mute-button.jsx
Normal file
46
app/routes/play.$gameKey/bar/mute-button.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
56
app/routes/play.$gameKey/bar/timeout/index.jsx
Normal file
56
app/routes/play.$gameKey/bar/timeout/index.jsx
Normal 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;
|
6
app/routes/play.$gameKey/bar/timeout/index.module.css
Normal file
6
app/routes/play.$gameKey/bar/timeout/index.module.css
Normal 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;
|
||||||
|
}
|
21
app/routes/play.$gameKey/bar/timeout/number-initializer.jsx
Normal file
21
app/routes/play.$gameKey/bar/timeout/number-initializer.jsx
Normal 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);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
50
app/routes/play.$gameKey/bar/timeout/number.jsx
Normal file
50
app/routes/play.$gameKey/bar/timeout/number.jsx
Normal 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,
|
||||||
|
};
|
7
app/routes/play.$gameKey/bar/timeout/number.module.css
Normal file
7
app/routes/play.$gameKey/bar/timeout/number.module.css
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
.characteristic, .rendered, .streamed {
|
||||||
|
--nothing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mantissa {
|
||||||
|
font-size: 0.4em;
|
||||||
|
}
|
49
app/routes/play.$gameKey/index.module.css
Normal file
49
app/routes/play.$gameKey/index.module.css
Normal 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;
|
||||||
|
}
|
53
app/routes/play.$gameKey/players/chat/form.jsx
Normal file
53
app/routes/play.$gameKey/players/chat/form.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
62
app/routes/play.$gameKey/players/chat/index.jsx
Normal file
62
app/routes/play.$gameKey/players/chat/index.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
49
app/routes/play.$gameKey/players/chat/index.module.css
Normal file
49
app/routes/play.$gameKey/players/chat/index.module.css
Normal 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;
|
||||||
|
}
|
43
app/routes/play.$gameKey/players/chat/message/index.jsx
Normal file
43
app/routes/play.$gameKey/players/chat/message/index.jsx
Normal 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,
|
||||||
|
};
|
|
@ -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;
|
||||||
|
}
|
29
app/routes/play.$gameKey/players/index.jsx
Normal file
29
app/routes/play.$gameKey/players/index.jsx
Normal 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;
|
52
app/routes/play.$gameKey/players/index.module.css
Normal file
52
app/routes/play.$gameKey/players/index.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
219
app/routes/play.$gameKey/players/list/index.jsx
Normal file
219
app/routes/play.$gameKey/players/list/index.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
157
app/routes/play.$gameKey/players/list/index.module.css
Normal file
157
app/routes/play.$gameKey/players/list/index.module.css
Normal 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;
|
||||||
|
}
|
230
app/routes/play.$gameKey/route.jsx
Normal file
230
app/routes/play.$gameKey/route.jsx
Normal 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;
|
19
app/routes/play.$gameKey/status/answering/black.jsx
Normal file
19
app/routes/play.$gameKey/status/answering/black.jsx
Normal 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;
|
2
app/routes/play.$gameKey/status/answering/index.js
Normal file
2
app/routes/play.$gameKey/status/answering/index.js
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export {default as Black} from './black';
|
||||||
|
export {default as White} from './white';
|
39
app/routes/play.$gameKey/status/answering/index.module.css
Normal file
39
app/routes/play.$gameKey/status/answering/index.module.css
Normal 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;
|
||||||
|
}
|
59
app/routes/play.$gameKey/status/answering/white.jsx
Normal file
59
app/routes/play.$gameKey/status/answering/white.jsx
Normal 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;
|
15
app/routes/play.$gameKey/status/awarded/black.jsx
Normal file
15
app/routes/play.$gameKey/status/awarded/black.jsx
Normal 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;
|
2
app/routes/play.$gameKey/status/awarded/index.js
Normal file
2
app/routes/play.$gameKey/status/awarded/index.js
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export {default as Black} from './black';
|
||||||
|
export {default as White} from './white';
|
14
app/routes/play.$gameKey/status/awarded/index.module.css
Normal file
14
app/routes/play.$gameKey/status/awarded/index.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
26
app/routes/play.$gameKey/status/awarded/white.jsx
Normal file
26
app/routes/play.$gameKey/status/awarded/white.jsx
Normal 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;
|
16
app/routes/play.$gameKey/status/awarding/black.jsx
Normal file
16
app/routes/play.$gameKey/status/awarding/black.jsx
Normal 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;
|
2
app/routes/play.$gameKey/status/awarding/index.js
Normal file
2
app/routes/play.$gameKey/status/awarding/index.js
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export {default as Black} from './black';
|
||||||
|
export {default as White} from './white';
|
18
app/routes/play.$gameKey/status/awarding/index.module.css
Normal file
18
app/routes/play.$gameKey/status/awarding/index.module.css
Normal 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%;
|
||||||
|
}
|
67
app/routes/play.$gameKey/status/awarding/white.jsx
Normal file
67
app/routes/play.$gameKey/status/awarding/white.jsx
Normal 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;
|
25
app/routes/play.$gameKey/status/finished/black.jsx
Normal file
25
app/routes/play.$gameKey/status/finished/black.jsx
Normal 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;
|
2
app/routes/play.$gameKey/status/finished/index.js
Normal file
2
app/routes/play.$gameKey/status/finished/index.js
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export {default as Black} from './black';
|
||||||
|
export {default as White} from './white';
|
49
app/routes/play.$gameKey/status/finished/index.module.css
Normal file
49
app/routes/play.$gameKey/status/finished/index.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
61
app/routes/play.$gameKey/status/finished/white.jsx
Normal file
61
app/routes/play.$gameKey/status/finished/white.jsx
Normal 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;
|
54
app/routes/play.$gameKey/status/paused/black.jsx
Normal file
54
app/routes/play.$gameKey/status/paused/black.jsx
Normal 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;
|
2
app/routes/play.$gameKey/status/paused/index.js
Normal file
2
app/routes/play.$gameKey/status/paused/index.js
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export {default as Black} from './black';
|
||||||
|
export {default as White} from './white';
|
39
app/routes/play.$gameKey/status/paused/index.module.css
Normal file
39
app/routes/play.$gameKey/status/paused/index.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
19
app/routes/play.$gameKey/status/paused/white.jsx
Normal file
19
app/routes/play.$gameKey/status/paused/white.jsx
Normal 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;
|
74
app/routes/play.$gameKey/status/starting/black.jsx
Normal file
74
app/routes/play.$gameKey/status/starting/black.jsx
Normal 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'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;
|
88
app/routes/play.$gameKey/status/starting/black.module.css
Normal file
88
app/routes/play.$gameKey/status/starting/black.module.css
Normal 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;
|
||||||
|
}
|
2
app/routes/play.$gameKey/status/starting/index.js
Normal file
2
app/routes/play.$gameKey/status/starting/index.js
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export {default as Black} from './black';
|
||||||
|
export {default as White} from './white';
|
27
app/routes/play.$gameKey/status/starting/white.jsx
Normal file
27
app/routes/play.$gameKey/status/starting/white.jsx
Normal 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;
|
54
app/routes/play.$gameKey/status/starting/white.module.css
Normal file
54
app/routes/play.$gameKey/status/starting/white.module.css
Normal 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%;
|
||||||
|
}
|
||||||
|
}
|
3
app/routes/play.$gameKey_.chat-form/route.jsx
Normal file
3
app/routes/play.$gameKey_.chat-form/route.jsx
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import Form from '../play.$gameKey/players/chat/form';
|
||||||
|
|
||||||
|
export default Form;
|
142
app/state/cards.js
Normal file
142
app/state/cards.js
Normal 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
24
app/state/cards.test.js
Normal 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}`);
|
||||||
|
})
|
||||||
|
});
|
20
app/state/cookie.server.js
Normal file
20
app/state/cookie.server.js
Normal 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
744
app/state/game.js
Normal 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
255
app/state/game.server.js
Normal 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
Loading…
Reference in New Issue
Block a user