Compare commits

...

101 Commits

Author SHA1 Message Date
5e9b145066 version bump 2025-09-08 22:54:43 -04:00
7d45a36f93 fix div bug 2025-09-08 22:54:10 -04:00
fb3f567a5b bugs bashed 2025-09-08 22:45:42 -04:00
41bd1fce38 add brainmade logo 2025-09-08 21:29:19 -04:00
ed42c831de [wip] gameplay works but config not respected 2025-09-07 23:31:19 -04:00
ae6a79aadd [wip] configs are synced but gameplay is broken 2025-09-07 22:56:56 -04:00
46002403c8 really solid 2025-09-06 23:22:58 -04:00
b854fec9e5 [wip] extractProperty proper typing with union types 2025-09-06 17:30:47 -04:00
bedafb0b7c portal to decorate players by ref instead of ID 2025-09-06 16:54:03 -04:00
9e3697ffef better plumbing for games frontend 2025-09-04 23:48:49 -04:00
b3e040f03f errors 2025-09-03 22:50:32 -04:00
46e7b60ade deploy 2025-09-03 21:16:19 -04:00
ce29ab72ae scaling 2025-09-03 21:14:54 -04:00
d203fe4141 better fanned hands 2025-09-02 23:21:48 -04:00
67fdf66cd4 depth for card piles 2025-09-02 22:38:04 -04:00
9919b97931 hotfix: version bump 2025-09-01 22:55:01 -04:00
fd342e7d47 no db 2025-09-01 22:53:57 -04:00
b433a26fc6 result states 2025-08-31 22:23:30 -04:00
0ea16ead64 hotfix: font size in em 2025-08-31 10:06:46 -04:00
11f21221ee we did it 2025-08-30 22:30:31 -04:00
01a12ec58a [wip] so close; check the ws messages 2025-08-30 18:24:08 -04:00
782dd738cc [wip] im tired boss 2025-08-30 15:49:55 -04:00
5e33e33cce [wip] kefir cleanup 2025-08-29 23:50:23 -04:00
90be478e9a css cleanup 2025-08-29 21:57:03 -04:00
f38a5a69df package -> pkg 2025-08-29 20:54:18 -04:00
0d6d3d6d32 starting to abstract the game 2025-08-28 22:20:24 -04:00
d69336027a holding cards, kinda 2025-08-27 23:04:49 -04:00
686529507e basic hand viewing 2025-08-26 21:42:44 -04:00
aeb7d9174b quitting working but suspiciously 2025-08-26 19:13:09 -04:00
e5f432dc98 housekeeping 2025-08-26 18:37:27 -04:00
0f015841ff when in doubt make it a property I guess 2025-08-25 22:37:42 -04:00
6c45e7b114 bug fixes 2025-08-25 18:36:59 -04:00
a117f6703f lots of necessary plumbing 2025-08-24 22:04:29 -04:00
4bcf071668 fix multi tab presence 2025-08-24 15:09:58 -04:00
6c64886f2a super minor 2025-08-24 14:03:55 -04:00
9b918b1c6a hotfix 2025-08-24 13:59:16 -04:00
3347452ec4 end to end! 2025-08-23 20:33:52 -04:00
a2e8887a0b lots more kefir tinkering 2025-08-23 17:34:50 -04:00
cc53470ddf deep in kefir lore 2025-08-22 00:19:40 -04:00
7d8ac0db76 around the table 2025-08-21 00:27:58 -04:00
35a5af154f cooking with kefir 2025-08-20 21:56:23 -04:00
265aad4522 checkpoint 2025-08-19 22:19:24 -04:00
287c19fc0d cooking with websockets 2025-08-18 23:31:28 -04:00
3f1635880a tokens fr 2025-08-18 17:47:39 -04:00
601e3660d3 has to be a new version 2025-08-17 22:43:01 -04:00
f5de37523a better logging 2025-08-17 22:26:41 -04:00
e30adf8d82 track migrations, whoops 2025-08-17 22:15:31 -04:00
00779b6515 use the right url 2025-08-17 21:47:29 -04:00
e58b702bc4 idk cors 2025-08-17 20:50:15 -04:00
ac48d43827 less restrictive cors 2025-08-17 20:33:14 -04:00
0484ca6fc1 force redeploy 2025-08-17 20:13:20 -04:00
ef5a5e059a ""auth"" working nicely 2025-08-17 19:57:55 -04:00
c755b83d3d can change name 2025-08-16 00:15:07 -05:00
1c915d1713 some proper auth 2025-08-15 15:27:24 -05:00
4419dd7acc style cleanup 2025-08-12 19:42:30 -04:00
01d1571b0e works I guess? 2025-08-11 23:41:48 -04:00
bfb4fd3a63 Merge branch 'prod' into dev 2025-08-11 22:31:10 -04:00
e12def2e10 use older vite 2025-08-10 21:22:41 -04:00
eed6db3373 build ready (I hope) 2025-08-10 18:53:00 -04:00
a8a6d02cc7 build ready (I hope) 2025-08-10 18:46:45 -04:00
32c516bf37 working e2e 2025-08-10 12:40:02 -04:00
5e8978c550 closer 2025-08-09 15:48:14 -04:00
2ff5d781fd wip but I need to sleep 2025-08-09 01:40:42 -04:00
a7e339a8ce prisma experiment 2025-08-08 23:31:07 -04:00
96df75972a elysia is the truth 2025-08-08 22:44:39 -04:00
fb204e8869 housekeeping 2025-08-08 21:44:08 -04:00
eb064273ed full e2e behavior, nice 2025-08-08 00:04:46 -04:00
9d4b17b762 lets fuckin go 2025-08-07 22:44:33 -04:00
a90a914d2f works! 2025-08-07 19:16:57 -04:00
e9b8258fd9 getting there 2025-08-06 23:44:06 -04:00
839d596b55 basic shape 2025-08-06 23:29:55 -04:00
3891e8b85b deployment 2025-08-06 00:08:18 -04:00
2ce46088d5 cooking with sqlite 2025-08-05 23:49:13 -04:00
9277089e04 remove arangojs 2025-08-05 19:33:18 -04:00
0263ee9a4f setup prisma<>sqlite 2025-08-05 19:31:41 -04:00
5fd0df8135 arangodb 2025-08-05 00:26:05 -04:00
5527506cb9 something 2025-08-04 00:16:33 -04:00
0dc0a9ce62 can put them back 2025-08-03 22:16:19 -04:00
5f05eb4adf favicon 2025-08-03 17:59:25 -04:00
0124b69440 basic drawing 2025-08-03 14:43:29 -04:00
e4f6e1899d cards render well 2025-08-03 12:30:26 -04:00
e6cee7c2fa 0.0.1 2025-08-02 00:01:27 -04:00
7e5bc73b0d test minor change 2025-08-01 11:01:27 -04:00
9d573b4b7d lets go back to basics 2025-08-01 01:35:12 -04:00
552c544014 try arm64v6 2025-08-01 01:26:29 -04:00
50c94b8e1e allow build scripts (really) 2025-08-01 01:01:40 -04:00
24dd8b1e99 maybe this works 2025-08-01 00:44:41 -04:00
e3710f7152 force recompilation of binaries 2025-08-01 00:27:37 -04:00
cac5ebff3a arm-optimized base images 2025-08-01 00:16:24 -04:00
5480e2921b this dockerfile rocks now 2025-08-01 00:02:24 -04:00
27d47764f8 vinxi cache in docker build 2025-07-31 23:15:51 -04:00
6edbaa7a68 actual docker fix 2025-07-31 22:34:59 -04:00
9bc6cfbf7c docker platform fix 2025-07-31 18:00:19 -04:00
600cf1c290 docker update 2025-07-31 17:55:21 -04:00
7a5ac26f45 docker explicit platform 2025-07-31 00:46:11 -04:00
44e7586baf docker fix 2025-07-31 00:36:21 -04:00
4efc60861b dont use pnpm in docker 2025-07-31 00:25:40 -04:00
58038a6e86 whoops 2025-07-31 00:20:20 -04:00
5fc503778d build with docker 2025-07-31 00:19:05 -04:00
369fbec9e5 update gitignore 2025-07-30 23:51:06 -04:00
ac944e4c87 Merge pull request 'Initial version' (#1) from dev into prod
Reviewed-on: #1
2025-07-30 23:31:40 -04:00
130 changed files with 4718 additions and 4785 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
assets/** filter=lfs diff=lfs merge=lfs -text

6
.gitignore vendored
View File

@@ -1,3 +1,9 @@
.output
.vinxi
*.db
.DS_STORE
# ---> Node
# Logs
logs

1
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1 @@
{}

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
import { defineConfig } from "@solidjs/start/config";
export default defineConfig({});

BIN
assets/sources/Card_back_01.svg LFS Normal file

Binary file not shown.

Binary file not shown.

BIN
assets/sources/cards.png LFS Normal file

Binary file not shown.

7
deploy Executable file
View 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
View 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"

View File

@@ -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
View 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
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -0,0 +1 @@
../../../../assets/sources/Card_back_01.svg

View File

@@ -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
View 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
View 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]);

View 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
>;

View 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>
);
};

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

View 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
>;

View 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
>;

View 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>
);
};

View 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>
);
};

View File

@@ -0,0 +1,5 @@
import simple from "./simple";
export default {
simple,
};

View 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 />
</>
);
};

View 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
View 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
View 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",
});

View File

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

View 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>
);
};

View 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
View 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
View 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