Compare commits
101 Commits
59f6fa80e0
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e9b145066 | |||
| 7d45a36f93 | |||
| fb3f567a5b | |||
| 41bd1fce38 | |||
| ed42c831de | |||
| ae6a79aadd | |||
| 46002403c8 | |||
| b854fec9e5 | |||
| bedafb0b7c | |||
| 9e3697ffef | |||
| b3e040f03f | |||
| 46e7b60ade | |||
| ce29ab72ae | |||
| d203fe4141 | |||
| 67fdf66cd4 | |||
| 9919b97931 | |||
| fd342e7d47 | |||
| b433a26fc6 | |||
| 0ea16ead64 | |||
| 11f21221ee | |||
| 01a12ec58a | |||
| 782dd738cc | |||
| 5e33e33cce | |||
| 90be478e9a | |||
| f38a5a69df | |||
| 0d6d3d6d32 | |||
| d69336027a | |||
| 686529507e | |||
| aeb7d9174b | |||
| e5f432dc98 | |||
| 0f015841ff | |||
| 6c45e7b114 | |||
| a117f6703f | |||
| 4bcf071668 | |||
| 6c64886f2a | |||
| 9b918b1c6a | |||
| 3347452ec4 | |||
| a2e8887a0b | |||
| cc53470ddf | |||
| 7d8ac0db76 | |||
| 35a5af154f | |||
| 265aad4522 | |||
| 287c19fc0d | |||
| 3f1635880a | |||
| 601e3660d3 | |||
| f5de37523a | |||
| e30adf8d82 | |||
| 00779b6515 | |||
| e58b702bc4 | |||
| ac48d43827 | |||
| 0484ca6fc1 | |||
| ef5a5e059a | |||
| c755b83d3d | |||
| 1c915d1713 | |||
| 4419dd7acc | |||
| 01d1571b0e | |||
| bfb4fd3a63 | |||
| e12def2e10 | |||
| eed6db3373 | |||
| a8a6d02cc7 | |||
| 32c516bf37 | |||
| 5e8978c550 | |||
| 2ff5d781fd | |||
| a7e339a8ce | |||
| 96df75972a | |||
| fb204e8869 | |||
| eb064273ed | |||
| 9d4b17b762 | |||
| a90a914d2f | |||
| e9b8258fd9 | |||
| 839d596b55 | |||
| 3891e8b85b | |||
| 2ce46088d5 | |||
| 9277089e04 | |||
| 0263ee9a4f | |||
| 5fd0df8135 | |||
| 5527506cb9 | |||
| 0dc0a9ce62 | |||
| 5f05eb4adf | |||
| 0124b69440 | |||
| e4f6e1899d | |||
| e6cee7c2fa | |||
| 7e5bc73b0d | |||
| 9d573b4b7d | |||
| 552c544014 | |||
| 50c94b8e1e | |||
| 24dd8b1e99 | |||
| e3710f7152 | |||
| cac5ebff3a | |||
| 5480e2921b | |||
| 27d47764f8 | |||
| 6edbaa7a68 | |||
| 9bc6cfbf7c | |||
| 600cf1c290 | |||
| 7a5ac26f45 | |||
| 44e7586baf | |||
| 4efc60861b | |||
| 58038a6e86 | |||
| 5fc503778d | |||
| 369fbec9e5 | |||
| ac944e4c87 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
assets/** filter=lfs diff=lfs merge=lfs -text
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,3 +1,9 @@
|
||||
.output
|
||||
.vinxi
|
||||
*.db
|
||||
|
||||
.DS_STORE
|
||||
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
|
||||
1
.vscode/settings.json
vendored
Normal file
1
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
11
Makefile
11
Makefile
@@ -2,7 +2,14 @@ SHELL := /bin/bash
|
||||
|
||||
build:
|
||||
pnpm install
|
||||
pnpm run build
|
||||
pnpm run -F client build
|
||||
pnpm run -F server dbdeploy
|
||||
pnpm run -F server dbtypes
|
||||
|
||||
start:
|
||||
pnpm run start
|
||||
PORT=$(PORT) pnpm start
|
||||
|
||||
note:
|
||||
./notes/newfile
|
||||
# touch ./notes/$$file.md
|
||||
# code -r ./notes/$$file.md
|
||||
33
README.md
33
README.md
@@ -1,32 +1 @@
|
||||
# SolidStart
|
||||
|
||||
Everything you need to build a Solid project, powered by [`solid-start`](https://start.solidjs.com);
|
||||
|
||||
## Creating a project
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npm init solid@latest
|
||||
|
||||
# create a new project in my-app
|
||||
npm init solid@latest my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
Solid apps are built with _presets_, which optimise your project for deployment to different environments.
|
||||
|
||||
By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add it to the `devDependencies` in `package.json` and specify in your `app.config.js`.
|
||||
|
||||
## This project was created with the [Solid CLI](https://github.com/solidjs-community/solid-cli)
|
||||
games!!!
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { defineConfig } from "@solidjs/start/config";
|
||||
|
||||
export default defineConfig({});
|
||||
BIN
assets/sources/Card_back_01.svg
LFS
Normal file
BIN
assets/sources/Card_back_01.svg
LFS
Normal file
Binary file not shown.
BIN
assets/sources/Vector-Cards-Version-3.2/AUTHORS.txt
LFS
Normal file
BIN
assets/sources/Vector-Cards-Version-3.2/AUTHORS.txt
LFS
Normal file
Binary file not shown.
BIN
assets/sources/Vector-Cards-Version-3.2/CHANGELOG_(PROJECT_HISTORY).txt
LFS
Normal file
BIN
assets/sources/Vector-Cards-Version-3.2/CHANGELOG_(PROJECT_HISTORY).txt
LFS
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
assets/sources/Vector-Cards-Version-3.2/LGPL 3.0 - LICENSING INFORMATION.txt
LFS
Normal file
BIN
assets/sources/Vector-Cards-Version-3.2/LGPL 3.0 - LICENSING INFORMATION.txt
LFS
Normal file
Binary file not shown.
BIN
assets/sources/Vector-Cards-Version-3.2/QUESTIONS & ANSWERS (Please Read).html
LFS
Normal file
BIN
assets/sources/Vector-Cards-Version-3.2/QUESTIONS & ANSWERS (Please Read).html
LFS
Normal file
Binary file not shown.
BIN
assets/sources/cards.png
LFS
Normal file
BIN
assets/sources/cards.png
LFS
Normal file
Binary file not shown.
7
deploy
Executable file
7
deploy
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
branch=$(git branch --show-current)
|
||||
|
||||
git switch prod
|
||||
git merge $branch
|
||||
git push
|
||||
git switch $branch
|
||||
7
notes/newfile
Executable file
7
notes/newfile
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
ts=$(date +"%Y-%m-%d-%H%M%S")
|
||||
file=./notes/$ts.md
|
||||
touch $file
|
||||
echo -e "# $ts\n" > $file
|
||||
echo "$file:end"
|
||||
code --goto "$file:2"
|
||||
22
package.json
22
package.json
@@ -1,17 +1,21 @@
|
||||
{
|
||||
"name": "games",
|
||||
"type": "module",
|
||||
"version": "0.0.10",
|
||||
"scripts": {
|
||||
"dev": "vinxi dev",
|
||||
"build": "vinxi build",
|
||||
"start": "vinxi start"
|
||||
"dev": "pnpm --parallel dev",
|
||||
"build": "pnpm run -F client build",
|
||||
"start": "pnpm run -F server start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@solidjs/start": "^1.1.0",
|
||||
"solid-js": "^1.9.5",
|
||||
"vinxi": "^0.5.7"
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"object-hash": "^3.0.0"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"devDependencies": {
|
||||
"@types/object-hash": "^3.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
9
pkg/client/index.html
Normal file
9
pkg/client/index.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<script type="module" src="/src/app.tsx"></script>
|
||||
</head>
|
||||
<body />
|
||||
</html>
|
||||
32
pkg/client/package.json
Normal file
32
pkg/client/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@games/client",
|
||||
"type": "module",
|
||||
"version": "0.0.4",
|
||||
"scripts": {
|
||||
"dev": "vite --port 3000",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/eden": "^1.3.2",
|
||||
"@solid-primitives/memo": "^1.4.3",
|
||||
"@solid-primitives/scheduled": "^1.5.2",
|
||||
"@solid-primitives/storage": "^4.3.3",
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"color2k": "^2.0.3",
|
||||
"js-cookie": "^3.0.5",
|
||||
"kefir": "^3.8.8",
|
||||
"kefir-bus": "^2.3.1",
|
||||
"object-hash": "^3.0.0",
|
||||
"solid-js": "^1.9.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/solar": "^1.2.4",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/kefir": "^3.8.11",
|
||||
"@unocss/preset-icons": "^66.4.2",
|
||||
"@unocss/preset-wind4": "^66.4.2",
|
||||
"unocss": "^66.4.2",
|
||||
"vite": "^4.4.9",
|
||||
"vite-plugin-solid": "^2.11.8"
|
||||
}
|
||||
}
|
||||
BIN
pkg/client/public/favicon.ico
Normal file
BIN
pkg/client/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
1
pkg/client/public/views/back.svg
Symbolic link
1
pkg/client/public/views/back.svg
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../assets/sources/Card_back_01.svg
|
||||
1
pkg/client/public/views/cards
Symbolic link
1
pkg/client/public/views/cards
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../assets/sources/Vector-Cards-Version-3.2/FACES (BORDERED)/STANDARD BORDERED/Single Cards (One Per FIle)
|
||||
14
pkg/client/src/api.ts
Normal file
14
pkg/client/src/api.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { treaty } from "@elysiajs/eden";
|
||||
import { fromEvents } from "kefir";
|
||||
import { type Api } from "@games/server/src/api";
|
||||
|
||||
const { api } = treaty<Api>(
|
||||
import.meta.env.DEV ? "http://localhost:5001" : window.location.origin,
|
||||
{
|
||||
fetch: { credentials: "include" },
|
||||
}
|
||||
);
|
||||
export default api;
|
||||
|
||||
export const fromWebsocket = <T>(ws: any) =>
|
||||
fromEvents(ws, "message").map((evt) => (evt as unknown as { data: T }).data);
|
||||
59
pkg/client/src/app.tsx
Normal file
59
pkg/client/src/app.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { makePersisted } from "@solid-primitives/storage";
|
||||
import { Route, Router } from "@solidjs/router";
|
||||
import pkg from "^/package.json";
|
||||
import { createSignal, lazy, Suspense } from "solid-js";
|
||||
import { render } from "solid-js/web";
|
||||
import "virtual:uno.css";
|
||||
import "./style.css";
|
||||
import { name, setName } from "./profile";
|
||||
|
||||
const Profile = () => {
|
||||
let dialogRef!: HTMLDialogElement;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={() => dialogRef.showModal()}
|
||||
class="i-solar-user-circle-bold button s-10 m-2 cursor-pointer fixed tr"
|
||||
/>
|
||||
|
||||
<dialog ref={dialogRef} closedby="any">
|
||||
<div class="fixed tr bg-emerald-100 m-2 p-4 rounded-xl border-2 shadow-md shadow-black">
|
||||
Name:{" "}
|
||||
<input
|
||||
value={name()}
|
||||
onChange={(e) => {
|
||||
dialogRef.close();
|
||||
setName(e.target.value);
|
||||
}}
|
||||
class="bg-emerald-200 border-1.5 rounded-full px-4"
|
||||
/>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<Router
|
||||
root={(props) => (
|
||||
<>
|
||||
<Suspense>{props.children}</Suspense>
|
||||
<Profile />
|
||||
<span class="fixed br m-2 font-mono text-xs">
|
||||
{"v" + pkg.version}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<Route path="/" component={lazy(() => import("./routes/index"))} />
|
||||
<Route
|
||||
path="/t/:tableKey"
|
||||
component={lazy(() => import("./routes/[table]"))}
|
||||
/>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
render(App, document.getElementsByTagName("body")[0]);
|
||||
54
pkg/client/src/components/Card.tsx
Normal file
54
pkg/client/src/components/Card.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Component, Suspense } from "solid-js";
|
||||
|
||||
import type { Card } from "@games/shared/cards";
|
||||
import { Clickable, Scalable, Stylable } from "./toolbox";
|
||||
|
||||
const cardToSvgFilename = (card: Card) => {
|
||||
if (card.kind == "joker") {
|
||||
return `JOKER-${card.color == "black" ? "2" : "3"}`;
|
||||
}
|
||||
|
||||
const value =
|
||||
{ ace: 1, jack: 11, queen: 12, king: 13 }[
|
||||
card.rank as "ace" | "jack" | "queen" | "king" // fuck you typescript
|
||||
] ?? (card.rank as number);
|
||||
return `${card.suit.toUpperCase()}-${value}${
|
||||
value >= 11 ? "-" + (card.rank as string).toUpperCase() : ""
|
||||
}`;
|
||||
};
|
||||
|
||||
export const CARD_RATIO = 1.456730769;
|
||||
export const BASE_CARD_WIDTH = 100;
|
||||
|
||||
export default ((props) => {
|
||||
const width = () => BASE_CARD_WIDTH * (props.scale ?? 1);
|
||||
const height = () => width() * CARD_RATIO;
|
||||
return (
|
||||
<Suspense>
|
||||
<img
|
||||
onClick={props.onClick}
|
||||
draggable={false}
|
||||
class={props.class}
|
||||
style={props.style}
|
||||
width={`${width()}px`}
|
||||
height={`${height()}px`}
|
||||
src={
|
||||
props.face == "down"
|
||||
? "/views/back.svg"
|
||||
: `/views/cards/${cardToSvgFilename(props.card)}.svg`
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}) satisfies Component<
|
||||
(
|
||||
| {
|
||||
card: Card;
|
||||
face?: "up";
|
||||
}
|
||||
| { card?: Card; face: "down" }
|
||||
) &
|
||||
Stylable &
|
||||
Clickable &
|
||||
Scalable
|
||||
>;
|
||||
30
pkg/client/src/components/FannedHand.tsx
Normal file
30
pkg/client/src/components/FannedHand.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Hand } from "@games/shared/cards";
|
||||
import { For } from "solid-js";
|
||||
import Card from "./Card";
|
||||
import { Stylable } from "./toolbox";
|
||||
|
||||
export default (props: { handCount: number } & Stylable) => {
|
||||
return (
|
||||
<For each={Array(props.handCount)}>
|
||||
{(_, i) => {
|
||||
const midOffset = i() + 0.5 - props.handCount / 2;
|
||||
return (
|
||||
<Card
|
||||
face="down"
|
||||
scale={0.4}
|
||||
style={{
|
||||
"margin-left": "-12px",
|
||||
"margin-right": "-12px",
|
||||
transform: `translate(0px, ${Math.pow(
|
||||
Math.abs(midOffset),
|
||||
2
|
||||
)}px) rotate(${midOffset * 0.12}rad)`,
|
||||
"min-width": "40px",
|
||||
"box-shadow": "-4px 4px 6px rgba(0, 0, 0, 0.6)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
);
|
||||
};
|
||||
17
pkg/client/src/components/Hand.css
Normal file
17
pkg/client/src/components/Hand.css
Normal file
@@ -0,0 +1,17 @@
|
||||
.hand {
|
||||
height: 160px;
|
||||
background: radial-gradient(var(--sweet-green), var(--dark-green));
|
||||
min-width: 100px;
|
||||
width: fit-content;
|
||||
max-width: 90%;
|
||||
border: 2px dashed var(--light);
|
||||
border-radius: 12px;
|
||||
|
||||
margin-bottom: 50px;
|
||||
padding: 10px;
|
||||
|
||||
overflow: scroll;
|
||||
scrollbar-width: none;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
26
pkg/client/src/components/Hand.tsx
Normal file
26
pkg/client/src/components/Hand.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Component, For } from "solid-js";
|
||||
import type { Card as TCard, Hand as THand } from "@games/shared/cards";
|
||||
import Card from "./Card";
|
||||
import { Stylable } from "./toolbox";
|
||||
|
||||
import "./Hand.css";
|
||||
|
||||
export default ((props) => {
|
||||
return (
|
||||
<div class={"hand " + props.class} style={props.style}>
|
||||
<For each={props.hand}>
|
||||
{(card, i) => (
|
||||
<Card
|
||||
card={card}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => props.onClickCard?.(card, i())}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}) satisfies Component<
|
||||
{ hand: THand; onClickCard?: (card: TCard, i: number) => any } & Stylable
|
||||
>;
|
||||
63
pkg/client/src/components/Pile.tsx
Normal file
63
pkg/client/src/components/Pile.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Component, createMemo, For, JSX, Show } from "solid-js";
|
||||
import Card, { BASE_CARD_WIDTH, CARD_RATIO } from "./Card";
|
||||
import { desaturate } from "color2k";
|
||||
|
||||
import { Clickable, hashColor, Scalable, Stylable } from "./toolbox";
|
||||
|
||||
const cardOffset = 0.35; // Small offset for the stack effect
|
||||
|
||||
export default ((props) => {
|
||||
const cards = createMemo(() => {
|
||||
const numCards = Math.max(0, props.count - 1); // Subtract 1 for the top card
|
||||
return Array.from({ length: numCards }, (_, i) => i).toReversed();
|
||||
});
|
||||
const width = () => BASE_CARD_WIDTH * (props.scale ?? 1);
|
||||
const height = () => width() * CARD_RATIO;
|
||||
const offset = () => cardOffset * (props.scale ?? 1);
|
||||
return (
|
||||
<Show when={props.count > 0}>
|
||||
<div
|
||||
style={{
|
||||
...props.style,
|
||||
}}
|
||||
class={props.class}
|
||||
>
|
||||
<svg
|
||||
class="absolute z-[-1]"
|
||||
width={width() + cards().length * offset()}
|
||||
height={height() + cards().length * offset()}
|
||||
viewBox={`0 0 ${width() + cards().length * offset()} ${
|
||||
height() + cards().length * offset()
|
||||
}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<For each={cards()}>
|
||||
{(i) => {
|
||||
const xOffset = (i * offset()) / 2;
|
||||
const yOffset = i * offset();
|
||||
const color = desaturate(hashColor(i), 0.9);
|
||||
return (
|
||||
<rect
|
||||
x={xOffset}
|
||||
y={yOffset}
|
||||
width={width()}
|
||||
height={height()}
|
||||
rx="5" // Rounded corners
|
||||
ry="5"
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</svg>
|
||||
<Card onClick={props.onClick} face="down" scale={props.scale} />
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}) satisfies Component<
|
||||
{
|
||||
count: number;
|
||||
} & Stylable &
|
||||
Clickable &
|
||||
Scalable
|
||||
>;
|
||||
28
pkg/client/src/components/Player.tsx
Normal file
28
pkg/client/src/components/Player.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { onMount, useContext } from "solid-js";
|
||||
import { playerColor } from "~/profile";
|
||||
import { TableContext } from "./Table";
|
||||
import { Stylable } from "./toolbox";
|
||||
|
||||
export default (props: { playerKey: string } & Stylable) => {
|
||||
const table = useContext(TableContext);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(e) => table?.setPlayers(props.playerKey, { ref: e })}
|
||||
style={{
|
||||
...props.style,
|
||||
"background-color": playerColor(props.playerKey),
|
||||
...(table?.view() == null && table?.players[props.playerKey].ready
|
||||
? {
|
||||
border: "10px solid green",
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
class={`${props.class} w-20 h-20 rounded-full flex justify-center items-center`}
|
||||
>
|
||||
<p class="font-[1em] text-align-center">
|
||||
{table?.players[props.playerKey].name}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
199
pkg/client/src/components/Table.tsx
Normal file
199
pkg/client/src/components/Table.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import type { TWsIn, TWsOut } from "@games/server/src/table";
|
||||
import games from "@games/shared/games/index";
|
||||
import { pool, Property, Stream } from "kefir";
|
||||
import {
|
||||
Accessor,
|
||||
createContext,
|
||||
createEffect,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Setter,
|
||||
Show,
|
||||
} from "solid-js";
|
||||
import { createStore, SetStoreFunction, Store } from "solid-js/store";
|
||||
import { Dynamic } from "solid-js/web";
|
||||
import api, { fromWebsocket } from "~/api";
|
||||
import { createObservable, createSynced, cx, extractProperty } from "~/fn";
|
||||
import { me, name } from "~/profile";
|
||||
import GAMES from "./games";
|
||||
import Player from "./Player";
|
||||
|
||||
type PlayerStore = Store<{
|
||||
[key: string]: {
|
||||
name: string;
|
||||
ready: boolean;
|
||||
ref?: HTMLDivElement;
|
||||
};
|
||||
}>;
|
||||
export const TableContext = createContext<{
|
||||
wsEvents: Stream<TWsOut, any>;
|
||||
sendWs: (msg: TWsIn) => void;
|
||||
|
||||
tableRef: HTMLDivElement;
|
||||
|
||||
gameConfig: Accessor<any>;
|
||||
setGameConfig: Setter<any>;
|
||||
players: PlayerStore;
|
||||
setPlayers: SetStoreFunction<PlayerStore>;
|
||||
|
||||
view: Accessor<any>;
|
||||
}>();
|
||||
|
||||
export default (props: { tableKey: string }) => {
|
||||
// #region Websocket declaration
|
||||
let ws: ReturnType<ReturnType<typeof api.ws>["subscribe"]> | undefined =
|
||||
undefined;
|
||||
const wsEvents = pool<TWsOut, any>();
|
||||
const sendWs = (msg: TWsIn) => ws?.send(msg);
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region inbound table properties
|
||||
const [players, setPlayers] = createStore<PlayerStore>({});
|
||||
|
||||
wsEvents
|
||||
.thru(extractProperty("playersPresent"))
|
||||
.onValue((P) =>
|
||||
setPlayers(
|
||||
Object.fromEntries(
|
||||
P.map((p) => [
|
||||
p,
|
||||
p in players ? players[p] : { name: "", ready: false },
|
||||
])
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
wsEvents.thru(extractProperty("playerNames")).onValue((P) =>
|
||||
Object.entries(P)
|
||||
.filter(([player]) => player in players)
|
||||
.map(([player, name]) => setPlayers(player, "name", name))
|
||||
);
|
||||
|
||||
wsEvents.thru(extractProperty("playersReady")).onValue((P) =>
|
||||
Object.entries(P)
|
||||
.filter(([player]) => player in players)
|
||||
.map(([player, ready]) => setPlayers(player, "ready", ready))
|
||||
);
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region inbound game properties
|
||||
const [gameConfig, setGameConfig] = createSynced({
|
||||
ws: wsEvents.thru(extractProperty("gameConfig")) as Property<
|
||||
{ game: string; players: string[] },
|
||||
any
|
||||
>,
|
||||
sendWs: (gameConfig) => sendWs({ gameConfig }),
|
||||
});
|
||||
const view = wsEvents.thru(extractProperty("view")).thru(createObservable);
|
||||
// #endregion
|
||||
|
||||
const [ready, setReady] = createSignal(false);
|
||||
|
||||
onMount(() => {
|
||||
ws = api.ws(props).subscribe();
|
||||
ws.on("open", () => {
|
||||
wsEvents.plug(fromWebsocket<TWsOut>(ws));
|
||||
|
||||
// TODO: these need to be in a tracking scope to be disposed
|
||||
createEffect(() => sendWs({ ready: ready() }));
|
||||
createEffect(() => sendWs({ name: name() }));
|
||||
});
|
||||
onCleanup(() => ws?.close());
|
||||
});
|
||||
|
||||
const GamePicker = () => {
|
||||
return (
|
||||
<div class="absolute tc mt-8 flex gap-4">
|
||||
<select value={gameConfig()?.game}>
|
||||
<For each={Object.entries(games)}>
|
||||
{([gameId]) => <option value={gameId}>{gameId}</option>}
|
||||
</For>
|
||||
</select>
|
||||
<button onClick={() => setReady((prev) => !prev)} class="button p-1 ">
|
||||
{ready() ? "Not Ready" : "Ready"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
let tableRef!: HTMLDivElement;
|
||||
return (
|
||||
<TableContext.Provider
|
||||
value={{
|
||||
wsEvents,
|
||||
sendWs,
|
||||
|
||||
tableRef,
|
||||
|
||||
players,
|
||||
setPlayers,
|
||||
gameConfig,
|
||||
setGameConfig,
|
||||
view,
|
||||
}}
|
||||
>
|
||||
{/* Player avatars around the table */}
|
||||
<div class="flex justify-around p-t-14">
|
||||
<For each={gameConfig()?.players.filter((p) => p != me())}>
|
||||
{(player, i) => {
|
||||
const verticalOffset = () => {
|
||||
const N = gameConfig()!.players.length - 1;
|
||||
const x = Math.abs((2 * i() + 1) / (N * 2) - 0.5);
|
||||
const y = Math.sqrt(1 - x * x);
|
||||
return 1 - y;
|
||||
};
|
||||
return (
|
||||
<Player
|
||||
playerKey={player}
|
||||
style={{
|
||||
transform: `translate(0, ${verticalOffset() * 150}vh)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* The table body itself */}
|
||||
<div
|
||||
ref={tableRef}
|
||||
class={cx(
|
||||
"fixed",
|
||||
|
||||
"bg-radial",
|
||||
"from-orange-950",
|
||||
"to-stone-950",
|
||||
|
||||
"border-4",
|
||||
"border-neutral-950",
|
||||
"shadow-lg",
|
||||
|
||||
"top-40",
|
||||
"bottom-20",
|
||||
"left-[2%]",
|
||||
"right-[2%]"
|
||||
)}
|
||||
style={{
|
||||
"border-radius": "50%",
|
||||
}}
|
||||
>
|
||||
<Show when={view() == null}>
|
||||
<GamePicker />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* The game being played */}
|
||||
<Dynamic
|
||||
component={
|
||||
gameConfig()?.game ?? "" in GAMES
|
||||
? GAMES[gameConfig()!.game as keyof typeof GAMES]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</TableContext.Provider>
|
||||
);
|
||||
};
|
||||
5
pkg/client/src/components/games/index.ts
Normal file
5
pkg/client/src/components/games/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import simple from "./simple";
|
||||
|
||||
export default {
|
||||
simple,
|
||||
};
|
||||
149
pkg/client/src/components/games/simple.tsx
Normal file
149
pkg/client/src/components/games/simple.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import type {
|
||||
SimpleAction,
|
||||
SimplePlayerView,
|
||||
} from "@games/shared/games/simple";
|
||||
import { Accessor, createEffect, For, Show, useContext } from "solid-js";
|
||||
import { Portal } from "solid-js/web";
|
||||
import { me } from "~/profile";
|
||||
import { createObservable, extractProperty } from "../../fn";
|
||||
import FannedHand from "../FannedHand";
|
||||
import Hand from "../Hand";
|
||||
import Pile from "../Pile";
|
||||
import { TableContext } from "../Table";
|
||||
|
||||
export default () => {
|
||||
const table = useContext(TableContext)!;
|
||||
const view = table.view as Accessor<SimplePlayerView>;
|
||||
|
||||
const Configuration = () => (
|
||||
<Show when={view() == null}>
|
||||
<Portal mount={table.tableRef}>
|
||||
<div class="absolute center grid grid-cols-2 gap-col-2 text-xl">
|
||||
<label for="allow discards" style={{ "text-align": "right" }}>
|
||||
Allow discards
|
||||
</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="allow discards"
|
||||
style={{ width: "50px" }}
|
||||
checked={table.gameConfig()?.["can discard"] ?? false}
|
||||
onChange={(evt) =>
|
||||
table.setGameConfig({
|
||||
...table.gameConfig(),
|
||||
"can discard": evt.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<label for="to win" style={{ "text-align": "right" }}>
|
||||
Cards to win
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="to win"
|
||||
style={{
|
||||
"text-align": "center",
|
||||
width: "50px",
|
||||
color: "var(--yellow)",
|
||||
}}
|
||||
value={table.gameConfig()["cards to win"]}
|
||||
onChange={(evt) =>
|
||||
table.setGameConfig({
|
||||
...table.gameConfig(),
|
||||
"cards to win": Number.parseInt(evt.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
);
|
||||
|
||||
const submitAction = (action: SimpleAction) => table.sendWs({ action });
|
||||
const ActiveGame = () => (
|
||||
<Show when={view() != null}>
|
||||
{/* Main pile in the middle of the table */}
|
||||
<Pile
|
||||
count={view().deckCount}
|
||||
scale={0.8}
|
||||
class="cursor-pointer fixed center"
|
||||
onClick={() => submitAction({ type: "draw" })}
|
||||
/>
|
||||
|
||||
{/* Your own hand */}
|
||||
<Hand
|
||||
class="fixed bc"
|
||||
hand={view().myHand}
|
||||
onClickCard={(card) => submitAction({ type: "discard", card })}
|
||||
/>
|
||||
|
||||
{/* Other players' hands */}
|
||||
<For
|
||||
each={Object.entries(view().playerHandCounts).filter(
|
||||
([key, _]) => key in table.players
|
||||
)}
|
||||
>
|
||||
{([playerKey, handCount], i) => (
|
||||
<Portal
|
||||
mount={table.players[playerKey].ref}
|
||||
ref={(ref) => {
|
||||
const midOffset =
|
||||
i() + 0.5 - Object.values(view().playerHandCounts).length / 2;
|
||||
|
||||
ref.style = `position: absolute; display: flex; justify-content: center; top: 65%;`;
|
||||
}}
|
||||
>
|
||||
<FannedHand handCount={handCount} />
|
||||
</Portal>
|
||||
)}
|
||||
</For>
|
||||
|
||||
{/* Turn indicator */}
|
||||
<div
|
||||
class="absolute tc text-align-center"
|
||||
style={{
|
||||
"background-color":
|
||||
view().playerTurn == me() ? "var(--yellow)" : "transparent",
|
||||
color: view().playerTurn == me() ? "var(--dark)" : "var(--light)",
|
||||
}}
|
||||
>
|
||||
It's{" "}
|
||||
<span class="font-bold">
|
||||
{view().playerTurn == me()
|
||||
? "your"
|
||||
: table.players[view().playerTurn].name + "'s"}
|
||||
</span>{" "}
|
||||
turn
|
||||
</div>
|
||||
|
||||
{/* Quit button */}
|
||||
<button
|
||||
class="button fixed tl m-4 p-1"
|
||||
onClick={() => {
|
||||
table.sendWs({ quit: true });
|
||||
}}
|
||||
>
|
||||
Quit
|
||||
</button>
|
||||
</Show>
|
||||
);
|
||||
|
||||
const results = table.wsEvents
|
||||
.thru(extractProperty("results"))
|
||||
.thru(createObservable);
|
||||
const Results = () => (
|
||||
<Show when={results() != null}>
|
||||
<span class="bg-[var(--light)] text-[var(--dark)] rounded-[24px] border-2 border-[var(--dark)] absolute center p-4 shadow-lg text-[4em] text-center">
|
||||
{table.players[results()!].name} won!
|
||||
</span>
|
||||
</Show>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Configuration />
|
||||
<ActiveGame />
|
||||
<Results />
|
||||
</>
|
||||
);
|
||||
};
|
||||
23
pkg/client/src/components/toolbox.tsx
Normal file
23
pkg/client/src/components/toolbox.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import hash, { NotUndefined } from "object-hash";
|
||||
import { JSX } from "solid-js";
|
||||
|
||||
export type Stylable = {
|
||||
class?: string;
|
||||
style?: JSX.CSSProperties;
|
||||
};
|
||||
|
||||
export type Clickable = {
|
||||
onClick?:
|
||||
| JSX.EventHandlerUnion<
|
||||
HTMLDivElement,
|
||||
MouseEvent,
|
||||
JSX.EventHandler<HTMLDivElement, MouseEvent>
|
||||
>
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export type Scalable = {
|
||||
scale?: number;
|
||||
};
|
||||
|
||||
export const hashColor = (obj: NotUndefined) => `#${hash(obj).substring(0, 6)}`;
|
||||
70
pkg/client/src/fn.ts
Normal file
70
pkg/client/src/fn.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { createLatest } from "@solid-primitives/memo";
|
||||
import { Observable, Property, Stream } from "kefir";
|
||||
import { Accessor, createEffect, createSignal } from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import type { ExtractPropertyType, UnionKeys } from "@games/shared/types";
|
||||
|
||||
declare global {
|
||||
interface Array<T> {
|
||||
thru<S>(fn: (arr: T[]) => S): S;
|
||||
}
|
||||
}
|
||||
Array.prototype.thru = function <T, S>(this: T[], fn: (arr: T[]) => S) {
|
||||
return fn(this);
|
||||
};
|
||||
export const clone = <T>(o: T): T => JSON.parse(JSON.stringify(o));
|
||||
|
||||
export type ApiType<T extends () => Promise<{ data: any }>> = Awaited<
|
||||
ReturnType<T>
|
||||
>["data"];
|
||||
|
||||
export type WSEvent<
|
||||
T extends { subscribe: (handler: (...args: any[]) => any) => any }
|
||||
> = Parameters<Parameters<T["subscribe"]>[0]>[0];
|
||||
|
||||
export const createObservable = <T>(obs: Observable<T, any>) => {
|
||||
const [signal, setSignal] = createSignal<T>();
|
||||
obs.onValue((val) => setSignal(() => val));
|
||||
|
||||
return signal;
|
||||
};
|
||||
|
||||
export const createObservableWithInit = <T>(
|
||||
obs: Observable<T, any>,
|
||||
init: T
|
||||
) => {
|
||||
const [signal, setSignal] = createSignal<T>(init);
|
||||
obs.onValue((val) => setSignal(() => val));
|
||||
|
||||
return signal;
|
||||
};
|
||||
|
||||
export const cx = (...classes: string[]) => classes.join(" ");
|
||||
|
||||
export const createObservableStore =
|
||||
<T extends object = {}>(init: T) =>
|
||||
(obs: Observable<T, any>) => {
|
||||
const [store, setStore] = createStore<T>(init);
|
||||
obs.onValue((val) => setStore(val));
|
||||
return store;
|
||||
};
|
||||
|
||||
export const extractProperty =
|
||||
<T extends object, P extends UnionKeys<T>>(property: P) =>
|
||||
(obs: Observable<T, any>): Property<ExtractPropertyType<T, P>, any> =>
|
||||
obs
|
||||
.filter((o) => property in o)
|
||||
.map(
|
||||
(o) => (o as { [K in P]: any })[property] as ExtractPropertyType<T, P>
|
||||
)
|
||||
.toProperty();
|
||||
|
||||
export const createSynced = <T>(p: {
|
||||
ws: Stream<T, any>;
|
||||
sendWs: (o: T) => void;
|
||||
}) => {
|
||||
const [local, setLocal] = createSignal<T>();
|
||||
const remote = createObservable(p.ws.toProperty());
|
||||
createEffect(() => local() !== undefined && p.sendWs(local()!));
|
||||
return [createLatest([local, remote]), setLocal] as const;
|
||||
};
|
||||
14
pkg/client/src/profile.ts
Normal file
14
pkg/client/src/profile.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { makePersisted } from "@solid-primitives/storage";
|
||||
import hash from "object-hash";
|
||||
import { createResource, createSignal } from "solid-js";
|
||||
import api from "./api";
|
||||
|
||||
export const mePromise = api.whoami.post().then((r) => r.data);
|
||||
export const [me] = createResource(() => mePromise);
|
||||
|
||||
export const playerColor = (humanKey: string) =>
|
||||
"#" + hash(humanKey).substring(0, 6);
|
||||
|
||||
export const [name, setName] = makePersisted(createSignal("__name__"), {
|
||||
name: "name",
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Board, Card, Hand, Pile, Stack, Suit } from "./cards";
|
||||
import { Board, Card, Hand, Pile, Stack, Suit } from "./types/cards";
|
||||
import { clone } from "./fn";
|
||||
|
||||
const AGG: Suit = "spades";
|
||||
15
pkg/client/src/routes/[table].tsx
Normal file
15
pkg/client/src/routes/[table].tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { A, useParams } from "@solidjs/router";
|
||||
|
||||
import Table from "~/components/Table";
|
||||
import { Show } from "solid-js";
|
||||
import { me } from "~/profile";
|
||||
|
||||
export default () => {
|
||||
const { tableKey } = useParams();
|
||||
|
||||
return (
|
||||
<Show when={me() != null}>
|
||||
<Table tableKey={tableKey} />
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
46
pkg/client/src/routes/index.tsx
Normal file
46
pkg/client/src/routes/index.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { A } from "@solidjs/router";
|
||||
|
||||
export default () => {
|
||||
const randomTablePath = `/t/abcd`;
|
||||
return (
|
||||
<>
|
||||
<div class="flex flex-col absolute center">
|
||||
<h1>Welcome to games.drm.dev!</h1>
|
||||
<p>
|
||||
This website is a real-time multiplayer platform for playing card
|
||||
games online.
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
Games happen at <strong>tables</strong>. A table is any url of the
|
||||
form{" "}
|
||||
<span class="font-mono text-[var(--light-purple)]">
|
||||
games.drm.dev/t/
|
||||
<span class="text-[var(--yellow)]">*</span>
|
||||
</span>
|
||||
</p>
|
||||
<br />
|
||||
<p>Go to the same one as your friend and you will find them there!</p>
|
||||
<br />
|
||||
<p>
|
||||
If you have a table key in mind (the part after /t/), then plug it in
|
||||
to your URL bar! Or, here's a couple links to random tables:
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
With no one in it:{" "}
|
||||
<A href={randomTablePath}>
|
||||
https://www.games.drm.dev{randomTablePath}
|
||||
</A>
|
||||
</p>
|
||||
</div>
|
||||
<a href="https://brainmade.org" target="_blank">
|
||||
<img
|
||||
src="https://brainmade.org/white-logo.svg"
|
||||
class="fixed bl m-2"
|
||||
width="80"
|
||||
/>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
};
|
||||
47
pkg/client/src/style.css
Normal file
47
pkg/client/src/style.css
Normal file
@@ -0,0 +1,47 @@
|
||||
body {
|
||||
--purple: rgb(138, 156, 255);
|
||||
--light-purple: rgb(200, 150, 223);
|
||||
--light: seashell;
|
||||
--dark: #0a180e;
|
||||
--green: rgb(24, 82, 65);
|
||||
--dark-green: rgb(1, 42, 16);
|
||||
--sweet-green: rgb(23, 138, 125);
|
||||
--yellow: rgb(252, 220, 103);
|
||||
|
||||
height: 100vh;
|
||||
color: var(--light);
|
||||
background: radial-gradient(rgb(24, 82, 65), rgb(1, 42, 16));
|
||||
font-family: "Trebuchet MS";
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
font-family: Garamond;
|
||||
}
|
||||
p {
|
||||
font-size: 1em;
|
||||
font-family: Garamond;
|
||||
}
|
||||
a {
|
||||
font-family: monospace;
|
||||
color: var(--purple);
|
||||
}
|
||||
a:visited {
|
||||
color: var(--light-purple);
|
||||
}
|
||||
|
||||
.button {
|
||||
cursor: pointer;
|
||||
background-color: var(--light);
|
||||
color: var(--dark);
|
||||
box-shadow: 0px 5px 10px var(--dark);
|
||||
transition: background-color 0.15s, color 0.15s, transform 0.15s;
|
||||
}
|
||||
.button:hover {
|
||||
background-color: var(--sweet-green);
|
||||
color: var(--light);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
strong {
|
||||
color: var(--yellow);
|
||||
}
|
||||
22
pkg/client/tsconfig.json
Normal file
22
pkg/client/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"^/*": ["./*"],
|
||||
"@games/*": ["./pkg/*"],
|
||||
"$/*": ["./pkg/client/*"],
|
||||
"~/*": ["./pkg/client/src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user