Compare commits
35 Commits
0124b69440
...
601e3660d3
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -6,4 +6,5 @@ README.md
|
||||
.vinxi
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
.dockerignore
|
||||
*.db
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
.output
|
||||
.vinxi
|
||||
*.db
|
||||
|
||||
# ---> Node
|
||||
# Logs
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
FROM arm64v8/node:22 AS base
|
||||
ENV SHARP_IGNORE_GLOBAL_CLI_BINARIES=1
|
||||
ENV VIPS_DISABLE_DEPS=1
|
||||
ENV PNPM_SCRIPT_RUNNER_ALLOW_BUILD=true
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS builder
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile --dangerously-allow-all-builds
|
||||
COPY . .
|
||||
# ^ copy source code
|
||||
RUN --mount=type=cache,target=/app/.vinxi pnpm run build
|
||||
# ^ produces .output (build artifact) and .vinxi (build cache)
|
||||
|
||||
|
||||
FROM base AS production_builder
|
||||
RUN apt-get update && apt-get install -y jq
|
||||
COPY --from=builder /app/package.json ./
|
||||
RUN jq 'del(.devDependencies)' package.json > package.prod.json
|
||||
COPY --from=builder /app/pnpm-lock.yaml ./
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile --dangerously-allow-all-builds
|
||||
|
||||
# Prod image
|
||||
FROM arm64v8/node:22-alpine
|
||||
ENV SHARP_IGNORE_GLOBAL_CLI_BINARIES=1
|
||||
ENV VIPS_DISABLE_DEPS=1
|
||||
ENV PNPM_SCRIPT_RUNNER_ALLOW_BUILD=true
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=production_builder /app/package.prod.json ./package.json
|
||||
COPY --from=production_builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
|
||||
|
||||
COPY --from=builder /app/.output ./.output
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
13
Makefile
13
Makefile
@@ -1,13 +1,10 @@
|
||||
SHELL := /bin/bash
|
||||
|
||||
build:
|
||||
sudo docker buildx build --load \
|
||||
--cache-from=type=local,src=/var/cache/docker-build \
|
||||
--cache-to=type=local,dest=/var/cache/docker-build \
|
||||
--platform linux/arm64 \
|
||||
--progress=plain \
|
||||
--tag games .
|
||||
|
||||
pnpm install
|
||||
pnpm run -F client build
|
||||
pnpm run -F server dbdeploy
|
||||
pnpm run -F server dbtypes
|
||||
|
||||
start:
|
||||
sudo docker run -p $(PORT):3000 -t games
|
||||
PORT=$(PORT) pnpm start
|
||||
|
||||
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/cards.png
LFS
Normal file
BIN
assets/sources/cards.png
LFS
Normal file
Binary file not shown.
@@ -1 +0,0 @@
|
||||
../sources/Card_back_01.svg
|
||||
@@ -1 +0,0 @@
|
||||
../sources/Vector-Cards-Version-3.2/FACES (BORDERED)/STANDARD BORDERED/Single Cards (One Per FIle)/
|
||||
19
package.json
19
package.json
@@ -3,17 +3,16 @@
|
||||
"type": "module",
|
||||
"version": "0.0.2",
|
||||
"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/router": "^0.15.3",
|
||||
"@solidjs/start": "^1.1.0",
|
||||
"solid-js": "^1.9.5",
|
||||
"vinxi": "^0.5.7"
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"object-hash": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"devDependencies": {
|
||||
"@types/object-hash": "^3.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
11
packages/client/index.html
Normal file
11
packages/client/index.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<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>
|
||||
<div id="app" />
|
||||
</body>
|
||||
</html>
|
||||
26
packages/client/package.json
Normal file
26
packages/client/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@games/client",
|
||||
"type": "module",
|
||||
"version": "0.0.3",
|
||||
"scripts": {
|
||||
"dev": "vite --port 3000",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/eden": "^1.3.2",
|
||||
"@solid-primitives/scheduled": "^1.5.2",
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"js-cookie": "^3.0.5",
|
||||
"object-hash": "^3.0.0",
|
||||
"solid-js": "^1.9.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/solar": "^1.2.4",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@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
packages/client/public/favicon.ico
Normal file
BIN
packages/client/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
1
packages/client/public/views/back.svg
Symbolic link
1
packages/client/public/views/back.svg
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../assets/sources/Card_back_01.svg
|
||||
1
packages/client/public/views/cards
Symbolic link
1
packages/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)
|
||||
10
packages/client/src/api.ts
Normal file
10
packages/client/src/api.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { type Api } from "../../server/src/api";
|
||||
import { treaty } from "@elysiajs/eden";
|
||||
|
||||
const { api } = treaty<Api>(
|
||||
import.meta.env.DEV ? "http://localhost:5001" : window.location.origin,
|
||||
{
|
||||
fetch: { credentials: "include" },
|
||||
}
|
||||
);
|
||||
export default api;
|
||||
66
packages/client/src/app.tsx
Normal file
66
packages/client/src/app.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Route, Router } from "@solidjs/router";
|
||||
import { createResource, lazy, Suspense } from "solid-js";
|
||||
import { render } from "solid-js/web";
|
||||
import "virtual:uno.css";
|
||||
import pkg from "../package.json";
|
||||
import "./style.css";
|
||||
import api from "./api";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
const Profile = () => {
|
||||
let dialogRef!: HTMLDialogElement;
|
||||
const [profile] = createResource(async () => api.profile.get());
|
||||
|
||||
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={profile()?.data?.name ?? ""}
|
||||
onChange={(e) =>
|
||||
api.setName.post({ name: e.target.value })
|
||||
}
|
||||
class="bg-emerald-200 border-1.5 rounded-full px-4"
|
||||
/>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => (
|
||||
<Router
|
||||
root={(props) => (
|
||||
<>
|
||||
<Suspense>{props.children}</Suspense>
|
||||
<Profile />
|
||||
|
||||
{/* Version */}
|
||||
<span class="fixed br m-2 font-mono text-xs">
|
||||
{"v" + pkg.version}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<Route path="/" component={lazy(() => import("./routes/index"))} />
|
||||
<Route
|
||||
path="/:game"
|
||||
component={lazy(() => import("./routes/[game]/index"))}
|
||||
/>
|
||||
<Route
|
||||
path="/:game/:instance"
|
||||
component={lazy(() => import("./routes/[game]/[instance]"))}
|
||||
/>
|
||||
</Router>
|
||||
);
|
||||
|
||||
// todo: fix this
|
||||
(Cookies.get("token") == null ? api.whoami.post() : Promise.resolve()).then(
|
||||
() => render(App, document.getElementById("app")!)
|
||||
);
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Component, createResource, JSX, Suspense } from "solid-js";
|
||||
import { Card } from "../types/cards";
|
||||
|
||||
import { Clickable, Stylable } from "./toolbox";
|
||||
import { Card } from "../../../shared/cards";
|
||||
|
||||
const cardToSvgFilename = (card: Card) => {
|
||||
if (card.kind == "joker") {
|
||||
@@ -16,34 +18,30 @@ const cardToSvgFilename = (card: Card) => {
|
||||
};
|
||||
|
||||
export default ((props) => {
|
||||
const [svgPath] = createResource(() =>
|
||||
props.face == "down"
|
||||
? // @ts-ignore
|
||||
import("~/../assets/views/back.svg")
|
||||
: import(
|
||||
`~/../assets/views/cards/${cardToSvgFilename(
|
||||
props.card
|
||||
)}.svg`
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<img
|
||||
onClick={props.onClick}
|
||||
draggable={false}
|
||||
class={props.class}
|
||||
style={{
|
||||
"border-radius": "5px",
|
||||
...props.style,
|
||||
}}
|
||||
style={props.style}
|
||||
width="100px"
|
||||
src={svgPath()?.default}
|
||||
src={
|
||||
props.face == "down"
|
||||
? "/views/back.svg"
|
||||
: `/views/cards/${cardToSvgFilename(props.card)}.svg`
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}) satisfies Component<{
|
||||
class?: string;
|
||||
style?: JSX.CSSProperties;
|
||||
card: Card;
|
||||
face?: "up" | "down";
|
||||
}>;
|
||||
}) satisfies Component<
|
||||
(
|
||||
| {
|
||||
card: Card;
|
||||
face?: "up";
|
||||
}
|
||||
| { card?: Card; face: "down" }
|
||||
) &
|
||||
Stylable &
|
||||
Clickable
|
||||
>;
|
||||
43
packages/client/src/components/Game.tsx
Normal file
43
packages/client/src/components/Game.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Accessor, createContext, createResource, Show } from "solid-js";
|
||||
import {
|
||||
GameState,
|
||||
Action,
|
||||
vGameState,
|
||||
PlayerView,
|
||||
} from "../../../server/src/games/simple";
|
||||
import api from "../api";
|
||||
import Hand from "./Hand";
|
||||
import Pile from "./Pile";
|
||||
|
||||
export const GameContext = createContext<{
|
||||
view: Accessor<PlayerView | undefined>;
|
||||
submitAction: (action: Action) => Promise<any>;
|
||||
}>();
|
||||
|
||||
export default (props: { instanceId: string }) => {
|
||||
const [view, { mutate }] = createResource(() =>
|
||||
api
|
||||
.simple(props)
|
||||
.get()
|
||||
.then((res) => res.data as PlayerView)
|
||||
);
|
||||
const submitAction = (action: Action) =>
|
||||
api
|
||||
.simple(props)
|
||||
.post({ action })
|
||||
.then((res) => res.status == 200 && mutate(res.data as PlayerView));
|
||||
|
||||
return (
|
||||
<GameContext.Provider value={{ view, submitAction }}>
|
||||
<Show when={view.latest != undefined}>
|
||||
<Pile
|
||||
count={view.latest!.deckCount}
|
||||
class="cursor-pointer fixed center"
|
||||
onClick={() => submitAction({ type: "draw" })}
|
||||
/>
|
||||
|
||||
<Hand class="fixed bc" hand={view.latest!.myHand} />
|
||||
</Show>
|
||||
</GameContext.Provider>
|
||||
);
|
||||
};
|
||||
26
packages/client/src/components/Hand.tsx
Normal file
26
packages/client/src/components/Hand.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Component, For, useContext } from "solid-js";
|
||||
import Card from "./Card";
|
||||
import { Hand } from "../../../shared/cards";
|
||||
import { GameContext } from "./Game";
|
||||
import { produce } from "solid-js/store";
|
||||
import { Stylable } from "./toolbox";
|
||||
|
||||
export default ((props) => {
|
||||
const { submitAction, view } = useContext(GameContext)!;
|
||||
|
||||
return (
|
||||
<div class={"hand " + props.class} style={props.style}>
|
||||
<For each={props.hand}>
|
||||
{(card) => (
|
||||
<Card
|
||||
card={card}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => submitAction({ type: "discard", card })}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}) satisfies Component<{ hand: Hand } & Stylable>;
|
||||
22
packages/client/src/components/Pile.tsx
Normal file
22
packages/client/src/components/Pile.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Component, For, JSX, Show } from "solid-js";
|
||||
import Card from "./Card";
|
||||
|
||||
import { Clickable, Stylable } from "./toolbox";
|
||||
|
||||
export default ((props) => {
|
||||
return (
|
||||
<Show when={props.count > 0}>
|
||||
<Card
|
||||
onClick={props.onClick}
|
||||
style={props.style}
|
||||
class={props.class + " shadow-lg shadow-black"}
|
||||
face="down"
|
||||
/>
|
||||
</Show>
|
||||
);
|
||||
}) satisfies Component<
|
||||
{
|
||||
count: number;
|
||||
} & Stylable &
|
||||
Clickable
|
||||
>;
|
||||
16
packages/client/src/components/toolbox.tsx
Normal file
16
packages/client/src/components/toolbox.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
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;
|
||||
};
|
||||
16
packages/client/src/routes/[game]/[instance].tsx
Normal file
16
packages/client/src/routes/[game]/[instance].tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { A, useParams } from "@solidjs/router";
|
||||
|
||||
import Game from "../../components/Game";
|
||||
|
||||
export default () => {
|
||||
const params = useParams<{ game: string; instance: string }>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Game instanceId={params.instance} />
|
||||
<A href={`/${params.game}`} class="fixed tl m-4 px-2 py-1.5 button">
|
||||
Back
|
||||
</A>
|
||||
</>
|
||||
);
|
||||
};
|
||||
38
packages/client/src/routes/[game]/index.tsx
Normal file
38
packages/client/src/routes/[game]/index.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { A, useParams } from "@solidjs/router";
|
||||
|
||||
import { createEffect, createResource, For, Suspense } from "solid-js";
|
||||
import api from "../../api";
|
||||
|
||||
export default () => {
|
||||
const param = useParams<{ game: string }>();
|
||||
|
||||
const [instances, { refetch }] = createResource(
|
||||
() => param.game,
|
||||
async () => api.instances.get({ query: param }).then((res) => res.data)
|
||||
);
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<div style={{ padding: "20px" }}>
|
||||
<p class="text-[40px]">{param.game}</p>
|
||||
<button
|
||||
class="px-2 py-1.5 m-4 button rounded"
|
||||
onClick={() => api.simple.newGame.post().then(refetch)}
|
||||
>
|
||||
New Game
|
||||
</button>
|
||||
<ul>
|
||||
<For each={instances() ?? []}>
|
||||
{(instance) => (
|
||||
<li>
|
||||
<A href={`/${param.game}/${instance.id}`}>
|
||||
{instance.id}
|
||||
</A>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
17
packages/client/src/routes/index.tsx
Normal file
17
packages/client/src/routes/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { A } from "@solidjs/router";
|
||||
import { createEffect, createResource, For } from "solid-js";
|
||||
import api from "../api";
|
||||
|
||||
export default () => {
|
||||
const [games] = createResource(async () =>
|
||||
api.games.get().then((res) => res.data)
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: "20px" }}>
|
||||
<For each={games()}>
|
||||
{(game) => <A href={`/${game.key}`}>{game.name}</A>}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
62
packages/client/src/style.css
Normal file
62
packages/client/src/style.css
Normal file
@@ -0,0 +1,62 @@
|
||||
html {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Trebuchet MS", "Lucida Sans Unicode", "Lucida Grande",
|
||||
"Lucida Sans", Arial, sans-serif;
|
||||
color: white;
|
||||
height: 100%;
|
||||
}
|
||||
body::before {
|
||||
z-index: -1;
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* a {
|
||||
color: rgb(18, 229, 113);
|
||||
}
|
||||
a:visited {
|
||||
color: rgb(23, 138, 125);
|
||||
} */
|
||||
|
||||
.button {
|
||||
cursor: pointer;
|
||||
background-color: white;
|
||||
color: black;
|
||||
box-shadow: 0px 5px 10px black;
|
||||
border-radius: 10%;
|
||||
transition: background-color 0.15s, color 0.15s, transform 0.15s;
|
||||
}
|
||||
.button:hover {
|
||||
background-color: rgb(23, 138, 125);
|
||||
color: white;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
background: radial-gradient(rgb(24, 82, 65), rgb(1, 42, 16));
|
||||
}
|
||||
|
||||
.hand {
|
||||
height: 160px;
|
||||
background: radial-gradient(rgb(24, 70, 82), rgb(1, 42, 41));
|
||||
min-width: 100px;
|
||||
width: fit-content;
|
||||
max-width: 90%;
|
||||
border: 2px dashed white;
|
||||
border-radius: 12px;
|
||||
|
||||
margin-bottom: 50px;
|
||||
padding: 10px;
|
||||
|
||||
overflow: scroll;
|
||||
scrollbar-width: none;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
20
packages/client/tsconfig.json
Normal file
20
packages/client/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"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": {
|
||||
"~/*": ["./src/*"],
|
||||
"$/*": ["./"],
|
||||
"@/*": ["./public/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
40
packages/client/uno.config.ts
Normal file
40
packages/client/uno.config.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import presetWind4 from "@unocss/preset-wind4";
|
||||
import { defineConfig } from "unocss";
|
||||
import { presetIcons } from "unocss";
|
||||
import {} from "@iconify-json/solar";
|
||||
|
||||
export default defineConfig({
|
||||
presets: [
|
||||
presetWind4(),
|
||||
presetIcons({
|
||||
collections: {
|
||||
solar: () =>
|
||||
import("@iconify-json/solar/icons.json").then(
|
||||
(i) => i.default as any
|
||||
),
|
||||
},
|
||||
}),
|
||||
],
|
||||
shortcuts: [[/^s-(\d+)$/, ([, d]) => `w-${d} h-${d}`]],
|
||||
rules: [
|
||||
["tl", { top: 0, left: 0 }],
|
||||
["tr", { top: 0, right: 0 }],
|
||||
["bl", { bottom: 0, left: 0 }],
|
||||
["br", { bottom: 0, right: 0 }],
|
||||
[
|
||||
"bc",
|
||||
{
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
"margin-left": "auto",
|
||||
"margin-right": "auto",
|
||||
},
|
||||
],
|
||||
|
||||
[
|
||||
"center",
|
||||
{ top: "50%", left: "50%", transform: "translate(-50%, -50%)" },
|
||||
],
|
||||
],
|
||||
});
|
||||
11
packages/client/vite.config.ts
Normal file
11
packages/client/vite.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vite";
|
||||
import solidPlugin from "vite-plugin-solid";
|
||||
import UnoCSS from "unocss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [solidPlugin(), UnoCSS()],
|
||||
build: {
|
||||
outDir: "../server/public",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
3
packages/server/.gitignore
vendored
Normal file
3
packages/server/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# deps
|
||||
node_modules/
|
||||
public
|
||||
@@ -0,0 +1,19 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Game" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"rules" TEXT
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Instance" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"gameId" INTEGER NOT NULL,
|
||||
"gameState" JSONB NOT NULL,
|
||||
CONSTRAINT "Instance_gameId_fkey" FOREIGN KEY ("gameId") REFERENCES "Game" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Game_name_key" ON "Game"("name");
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `Game` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to drop the column `id` on the `Game` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `gameId` on the `Instance` table. All the data in the column will be lost.
|
||||
- Added the required column `key` to the `Game` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `gameKey` to the `Instance` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Game" (
|
||||
"key" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"rules" TEXT
|
||||
);
|
||||
INSERT INTO "new_Game" ("createdAt", "name", "rules", "updatedAt") SELECT "createdAt", "name", "rules", "updatedAt" FROM "Game";
|
||||
DROP TABLE "Game";
|
||||
ALTER TABLE "new_Game" RENAME TO "Game";
|
||||
CREATE TABLE "new_Instance" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"gameKey" TEXT NOT NULL,
|
||||
"gameState" JSONB NOT NULL,
|
||||
CONSTRAINT "Instance_gameKey_fkey" FOREIGN KEY ("gameKey") REFERENCES "Game" ("key") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Instance" ("gameState", "id") SELECT "gameState", "id" FROM "Instance";
|
||||
DROP TABLE "Instance";
|
||||
ALTER TABLE "new_Instance" RENAME TO "Instance";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `Instance` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
|
||||
*/
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Instance" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"gameKey" TEXT NOT NULL,
|
||||
"gameState" JSONB NOT NULL,
|
||||
CONSTRAINT "Instance_gameKey_fkey" FOREIGN KEY ("gameKey") REFERENCES "Game" ("key") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Instance" ("gameKey", "gameState", "id") SELECT "gameKey", "gameState", "id" FROM "Instance";
|
||||
DROP TABLE "Instance";
|
||||
ALTER TABLE "new_Instance" RENAME TO "Instance";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `createdByKey` to the `Instance` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- CreateTable
|
||||
CREATE TABLE "Human" (
|
||||
"key" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Instance" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdByKey" TEXT NOT NULL,
|
||||
"gameKey" TEXT NOT NULL,
|
||||
"gameState" JSONB NOT NULL,
|
||||
CONSTRAINT "Instance_createdByKey_fkey" FOREIGN KEY ("createdByKey") REFERENCES "Human" ("key") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Instance_gameKey_fkey" FOREIGN KEY ("gameKey") REFERENCES "Game" ("key") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Instance" ("gameKey", "gameState", "id") SELECT "gameKey", "gameState", "id" FROM "Instance";
|
||||
DROP TABLE "Instance";
|
||||
ALTER TABLE "new_Instance" RENAME TO "Instance";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Human" (
|
||||
"key" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
INSERT INTO "new_Human" ("key", "name") SELECT "key", "name" FROM "Human";
|
||||
DROP TABLE "Human";
|
||||
ALTER TABLE "new_Human" RENAME TO "Human";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
3
packages/server/db/migrations/migration_lock.toml
Normal file
3
packages/server/db/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
32
packages/server/db/schema.prisma
Normal file
32
packages/server/db/schema.prisma
Normal file
@@ -0,0 +1,32 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = "file:./dev.db"
|
||||
}
|
||||
|
||||
model Game {
|
||||
key String @id
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
name String
|
||||
rules String?
|
||||
instances Instance[]
|
||||
}
|
||||
|
||||
model Human {
|
||||
key String @id @default(cuid(2))
|
||||
name String @default("")
|
||||
Instance Instance[]
|
||||
}
|
||||
|
||||
model Instance {
|
||||
id String @id @default(cuid(2))
|
||||
createdByKey String
|
||||
createdBy Human @relation(fields: [createdByKey], references: [key])
|
||||
gameKey String
|
||||
game Game @relation(fields: [gameKey], references: [key])
|
||||
gameState Json
|
||||
}
|
||||
27
packages/server/package.json
Normal file
27
packages/server/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@games/server",
|
||||
"scripts": {
|
||||
"dev": "concurrently 'pnpm run devserver' 'pnpm run dbstudio'",
|
||||
"devserver": "NODE_ENV=development PORT=5001 bun run --hot src/index.ts",
|
||||
"dbstudio": "pnpm dlx prisma studio --browser none",
|
||||
"dbdeploy": "pnpm dlx prisma migrate deploy",
|
||||
"dbtypes": "pnpm dlx prisma generate",
|
||||
"dbsync": "pnpm dlx prisma migrate dev",
|
||||
"dbwipe": "pnpm dlx prisma migrate reset",
|
||||
"prod": "NODE_ENV=production bun run src/index.ts",
|
||||
"start": "concurrently 'pnpm run prod' 'pnpm run dbstudio'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/cors": "^1.3.3",
|
||||
"@elysiajs/static": "^1.3.0",
|
||||
"@games/shared": "workspace:*",
|
||||
"@prisma/client": "6.13.0",
|
||||
"elysia": "^1.3.8",
|
||||
"object-hash": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"concurrently": "^9.2.0",
|
||||
"prisma": "6.13.0"
|
||||
}
|
||||
}
|
||||
6
packages/server/prisma.config.ts
Normal file
6
packages/server/prisma.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: path.join("db", "schema.prisma"),
|
||||
});
|
||||
56
packages/server/src/api.ts
Normal file
56
packages/server/src/api.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { prisma } from "./db/db";
|
||||
import { Elysia, t } from "elysia";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { simpleApi } from "./games/simple";
|
||||
import { human } from "./human";
|
||||
|
||||
const api = new Elysia({ prefix: "/api" })
|
||||
.post("/whoami", async ({ cookie: { token } }) => {
|
||||
if (token.value == null) {
|
||||
const newHuman = await prisma.human.create({
|
||||
data: {},
|
||||
});
|
||||
token.value = newHuman.key;
|
||||
}
|
||||
return token.value;
|
||||
})
|
||||
.use(human)
|
||||
.post(
|
||||
"/setName",
|
||||
({ body: { name }, humanKey }) =>
|
||||
prisma.human.update({
|
||||
where: {
|
||||
key: humanKey,
|
||||
},
|
||||
data: {
|
||||
name,
|
||||
},
|
||||
}),
|
||||
{
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
}),
|
||||
}
|
||||
)
|
||||
.get("/profile", ({ humanKey }) =>
|
||||
prisma.human.findFirst({ where: { key: humanKey } })
|
||||
)
|
||||
.get("/games", () => prisma.game.findMany())
|
||||
|
||||
.get("/instances", ({ query: { game } }) =>
|
||||
prisma.instance.findMany({
|
||||
where: {
|
||||
game: {
|
||||
name: game,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
.use(simpleApi);
|
||||
|
||||
export default api;
|
||||
export type Api = typeof api;
|
||||
4
packages/server/src/db/Games.ts
Normal file
4
packages/server/src/db/Games.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
"use server";
|
||||
import { prisma } from "./db";
|
||||
|
||||
export const queryAll = async () => await prisma.game.findMany();
|
||||
25
packages/server/src/db/Instances.ts
Normal file
25
packages/server/src/db/Instances.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
"use server";
|
||||
|
||||
import { GameState, newDeck, shuffle } from "../types/cards";
|
||||
import { prisma } from "./db";
|
||||
|
||||
export const queryInstances = async (gameName: string) =>
|
||||
prisma.instance.findMany({ where: { game: { name: gameName } } });
|
||||
|
||||
export const createInstance = (gameName: string) =>
|
||||
prisma.instance.create({
|
||||
data: {
|
||||
gameState: { deck: shuffle(newDeck()), hand: [] } as GameState,
|
||||
game: { connect: { name: gameName } },
|
||||
},
|
||||
});
|
||||
|
||||
export const getGameState = (instanceId: number) =>
|
||||
prisma.instance
|
||||
.findUnique({ where: { id: instanceId } })
|
||||
.then((i) => i?.gameState as GameState | undefined);
|
||||
|
||||
export const updateGameState = async (
|
||||
instanceId: number,
|
||||
gameState: GameState
|
||||
) => prisma.instance.update({ where: { id: instanceId }, data: { gameState } });
|
||||
5
packages/server/src/db/db.ts
Normal file
5
packages/server/src/db/db.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
"use server";
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
10
packages/server/src/games/index.ts
Normal file
10
packages/server/src/games/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import * as renaissance from "./renaissance";
|
||||
import * as simple from "./simple";
|
||||
|
||||
const games = {
|
||||
renaissance,
|
||||
simple,
|
||||
};
|
||||
export default games;
|
||||
|
||||
export type Game = keyof typeof games;
|
||||
1
packages/server/src/games/renaissance.ts
Normal file
1
packages/server/src/games/renaissance.ts
Normal file
@@ -0,0 +1 @@
|
||||
export default {};
|
||||
176
packages/server/src/games/simple.ts
Normal file
176
packages/server/src/games/simple.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
Card,
|
||||
Hand,
|
||||
newDeck,
|
||||
Pile,
|
||||
shuffle,
|
||||
vCard,
|
||||
} from "../../../shared/cards";
|
||||
import { heq } from "@games/shared/utils";
|
||||
import { Elysia, t } from "elysia";
|
||||
import { prisma } from "../db/db";
|
||||
import { human } from "../human";
|
||||
|
||||
// omniscient game state
|
||||
export type GameState = {
|
||||
prev?: {
|
||||
action: Action;
|
||||
};
|
||||
|
||||
deck: Pile;
|
||||
players: { [humanId: string]: Hand };
|
||||
};
|
||||
|
||||
// a particular player's knowledge of the global game state
|
||||
export type vGameState = {
|
||||
humanId: string;
|
||||
|
||||
deck: Pile<vCard>;
|
||||
players: { [humanId: string]: Hand<vCard> };
|
||||
};
|
||||
|
||||
// a particular player's point of view in the game
|
||||
export type PlayerView = {
|
||||
humanId: string;
|
||||
|
||||
deckCount: number;
|
||||
playerHandCounts: { [humanId: string]: number };
|
||||
myHand: Hand<Card>;
|
||||
};
|
||||
|
||||
export type Action = { type: "draw" } | { type: "discard"; card: Card };
|
||||
|
||||
export const newGame = (players: string[]) => {
|
||||
console.log("new game called with", JSON.stringify(players));
|
||||
return {
|
||||
deck: shuffle(newDeck()),
|
||||
players: Object.fromEntries(players.map((humanId) => [humanId, []])),
|
||||
} as GameState;
|
||||
};
|
||||
|
||||
export const getKnowledge = (
|
||||
state: GameState,
|
||||
humanId: string
|
||||
): vGameState => ({
|
||||
humanId,
|
||||
deck: state.deck.map((_) => null),
|
||||
players: Object.fromEntries(
|
||||
Object.entries(state.players).map(([id, hand]) => [
|
||||
id,
|
||||
hand.map(id === humanId ? (card) => card : (_) => null),
|
||||
])
|
||||
),
|
||||
});
|
||||
|
||||
const getView = (state: vGameState, humanId: string): PlayerView => ({
|
||||
humanId,
|
||||
deckCount: state.deck.length,
|
||||
myHand: state.players[humanId] as Hand,
|
||||
playerHandCounts: Object.fromEntries(
|
||||
Object.entries(state.players)
|
||||
.filter(([id]) => id != humanId)
|
||||
.map(([id, hand]) => [id, hand.length])
|
||||
),
|
||||
});
|
||||
|
||||
export const resolveAction = (
|
||||
state: GameState,
|
||||
humanId: string,
|
||||
action: Action
|
||||
): GameState => {
|
||||
// if (action.prevHash != hash(state)) {
|
||||
// throw new Error(
|
||||
// `action thinks it's applying to ${
|
||||
// action.prevHash
|
||||
// }, but we're checking it against ${hash(state)}`
|
||||
// );
|
||||
// }
|
||||
|
||||
const playerHand = state.players[humanId];
|
||||
if (action.type == "draw") {
|
||||
const [drawn, ...rest] = state.deck;
|
||||
return {
|
||||
deck: rest,
|
||||
players: {
|
||||
...state.players,
|
||||
[humanId]: [drawn, ...playerHand],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// action.type == discard
|
||||
const index = playerHand.findIndex(heq(action.card));
|
||||
return {
|
||||
deck: [action.card, ...state.deck],
|
||||
players: {
|
||||
...state.players,
|
||||
[humanId]: playerHand
|
||||
.slice(0, index)
|
||||
.concat(playerHand.slice(index + 1)),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const simpleApi = new Elysia({ prefix: "/simple" })
|
||||
.use(human)
|
||||
.post("/newGame", ({ humanKey }) => {
|
||||
return prisma.instance.create({
|
||||
data: {
|
||||
gameState: newGame([humanKey]),
|
||||
gameKey: "simple",
|
||||
createdByKey: humanKey,
|
||||
},
|
||||
});
|
||||
})
|
||||
.group("/:instanceId", (app) =>
|
||||
app
|
||||
.get("/", ({ params: { instanceId }, humanKey }) =>
|
||||
prisma.instance
|
||||
.findUnique({
|
||||
where: {
|
||||
id: instanceId,
|
||||
},
|
||||
})
|
||||
.then((game) =>
|
||||
getView(
|
||||
getKnowledge(
|
||||
game!.gameState as GameState,
|
||||
humanKey
|
||||
),
|
||||
humanKey
|
||||
)
|
||||
)
|
||||
)
|
||||
.post(
|
||||
"/",
|
||||
({ params: { instanceId }, body: { action }, humanKey }) =>
|
||||
prisma.instance
|
||||
.findUniqueOrThrow({
|
||||
where: {
|
||||
id: instanceId,
|
||||
},
|
||||
})
|
||||
.then(async (game) => {
|
||||
const newState = resolveAction(
|
||||
game.gameState as GameState,
|
||||
humanKey,
|
||||
action
|
||||
);
|
||||
await prisma.instance.update({
|
||||
data: { gameState: newState },
|
||||
where: {
|
||||
id: instanceId,
|
||||
},
|
||||
});
|
||||
return getView(
|
||||
getKnowledge(newState, humanKey),
|
||||
humanKey
|
||||
);
|
||||
}),
|
||||
{
|
||||
body: t.Object({
|
||||
action: t.Any(),
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
8
packages/server/src/human.ts
Normal file
8
packages/server/src/human.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import Elysia from "elysia";
|
||||
|
||||
export const human = new Elysia({ name: "human" })
|
||||
.derive(async ({ cookie: { token }, status }) => {
|
||||
const humanKey = token.value;
|
||||
return humanKey != null ? { humanKey } : status(401);
|
||||
})
|
||||
.as("scoped");
|
||||
34
packages/server/src/index.ts
Normal file
34
packages/server/src/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import api from "./api";
|
||||
import { Elysia, env } from "elysia";
|
||||
import { cors } from "@elysiajs/cors";
|
||||
import { staticPlugin } from "@elysiajs/static";
|
||||
import { error } from "node:console";
|
||||
|
||||
const port = env.PORT || 5001;
|
||||
|
||||
const app = new Elysia()
|
||||
.use(
|
||||
cors({
|
||||
origin: ["localhost:3000", "games.drm.dev"],
|
||||
})
|
||||
)
|
||||
.onRequest(({ request }) => {
|
||||
console.log(request.method, request.url);
|
||||
})
|
||||
.onError(({ error }) => {
|
||||
console.error(error);
|
||||
return error;
|
||||
})
|
||||
.get("/ping", () => "pong")
|
||||
.use(api)
|
||||
.get("/*", () => Bun.file("./public/index.html"))
|
||||
.use(
|
||||
staticPlugin({
|
||||
assets: "public",
|
||||
prefix: "/",
|
||||
alwaysStatic: true,
|
||||
})
|
||||
)
|
||||
.listen(port);
|
||||
|
||||
console.log("server started on", port);
|
||||
9
packages/server/tsconfig.json
Normal file
9
packages/server/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,11 @@ export type Card =
|
||||
}
|
||||
| { kind: "joker"; color: "red" | "black" };
|
||||
|
||||
export type Pile = Card[];
|
||||
export type Stack = Card[];
|
||||
export type Hand = Card[];
|
||||
export type Board = Card[];
|
||||
export type vCard = Card | null | number;
|
||||
export type Pile<C extends vCard = Card> = C[];
|
||||
export type Stack<C extends vCard = Card> = C[];
|
||||
export type Hand<C extends vCard = Card> = C[];
|
||||
export type Board<C extends vCard = Card> = C[];
|
||||
|
||||
export const newDeck = (withJokers = false): Pile =>
|
||||
suits
|
||||
7
packages/shared/package.json
Normal file
7
packages/shared/package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@games/shared",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"object-hash": "^3.0.0"
|
||||
}
|
||||
}
|
||||
6
packages/shared/tsconfig.json
Normal file
6
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
3
packages/shared/utils.ts
Normal file
3
packages/shared/utils.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import hash, { NotUndefined } from "object-hash";
|
||||
|
||||
export const heq = (a: NotUndefined) => (b: NotUndefined) => hash(a) == hash(b);
|
||||
6213
pnpm-lock.yaml
generated
6213
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
5
pnpm-workspace.yaml
Normal file
5
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
onlyBuiltDependencies:
|
||||
- "@parcel/watcher"
|
||||
- esbuild
|
||||
packages:
|
||||
- "packages/*"
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 664 B |
60
src/app.css
60
src/app.css
@@ -1,60 +0,0 @@
|
||||
html {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Trebuchet MS", "Lucida Sans Unicode", "Lucida Grande",
|
||||
"Lucida Sans", Arial, sans-serif;
|
||||
color: white;
|
||||
height: 100%;
|
||||
}
|
||||
body::before {
|
||||
z-index: -1;
|
||||
content: "";
|
||||
font-size: 28px;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
background: radial-gradient(rgb(24, 82, 65), rgb(1, 42, 16));
|
||||
}
|
||||
|
||||
.full {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.free {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.fixed-br {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.clear {
|
||||
pointer-events: none;
|
||||
}
|
||||
36
src/app.tsx
36
src/app.tsx
@@ -1,36 +0,0 @@
|
||||
import "./app.css";
|
||||
import { Router } from "@solidjs/router";
|
||||
import { FileRoutes } from "@solidjs/start/router";
|
||||
import { Suspense } from "solid-js";
|
||||
import pkg from "~/../package.json";
|
||||
|
||||
const Version = () => (
|
||||
<div class="full free clear">
|
||||
<span
|
||||
style={{
|
||||
margin: "5px",
|
||||
"font-size": "0.8rem",
|
||||
"font-family": "monospace",
|
||||
"pointer-events": "all",
|
||||
}}
|
||||
class="fixed-br"
|
||||
>
|
||||
v{pkg.version}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Router
|
||||
root={(props) => (
|
||||
<>
|
||||
<Suspense>{props.children}</Suspense>
|
||||
<Version />
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<FileRoutes />
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { createContext, JSX } from "solid-js";
|
||||
import Card from "./Card";
|
||||
import Hand from "./Hand";
|
||||
import Pile from "./Pile";
|
||||
import { newDeck, shuffle, Hand as THand } from "../types/cards";
|
||||
import { createStore, produce } from "solid-js/store";
|
||||
|
||||
const GameContext = createContext();
|
||||
|
||||
export default () => {
|
||||
const [gameState, setGameState] = createStore({
|
||||
pile: shuffle(newDeck()),
|
||||
hand: [] as THand,
|
||||
});
|
||||
|
||||
return (
|
||||
<GameContext.Provider value={{ gameState, setGameState }}>
|
||||
<div
|
||||
onClick={() => {}}
|
||||
class="full column"
|
||||
style={{ "row-gap": "20px", "font-size": "32px" }}
|
||||
>
|
||||
<div class="full center">
|
||||
<Pile
|
||||
pile={gameState.pile}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() =>
|
||||
setGameState(
|
||||
produce((state) => {
|
||||
state.hand.push(state.pile.pop()!);
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Hand hand={gameState.hand} />
|
||||
</div>
|
||||
</GameContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Component, For } from "solid-js";
|
||||
import Card from "./Card";
|
||||
import { Hand } from "../types/cards";
|
||||
|
||||
export default ((props) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: "2px dashed white",
|
||||
"border-radius": "12px",
|
||||
margin: "10px",
|
||||
"margin-bottom": "25px",
|
||||
padding: "10px",
|
||||
height: "200px",
|
||||
overflow: "scroll",
|
||||
"scrollbar-width": "none",
|
||||
display: "flex",
|
||||
gap: "5px",
|
||||
}}
|
||||
>
|
||||
<For each={props.hand}>{(card) => <Card card={card} />}</For>
|
||||
</div>
|
||||
);
|
||||
}) satisfies Component<{ hand: Hand }>;
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Component, For, JSX } from "solid-js";
|
||||
import Card from "./Card";
|
||||
import { Pile } from "../types/cards";
|
||||
|
||||
export default ((props) => {
|
||||
return (
|
||||
<div
|
||||
class="center"
|
||||
style={{ width: "200px", height: "400px", ...props.style }}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<For each={props.pile}>
|
||||
{(card, i) => (
|
||||
<Card
|
||||
card={card}
|
||||
face="down"
|
||||
style={{
|
||||
position: "absolute",
|
||||
transform: `translate(${i() * 0.8}px, ${
|
||||
-i() * 0.4
|
||||
}px)`,
|
||||
"z-index": 100 - i(),
|
||||
border: `0.1px solid rgb(${
|
||||
60 - i() + Math.random() * 10
|
||||
}, ${60 - i() + Math.random() * 10}, ${
|
||||
60 - i() + Math.random() * 10
|
||||
});`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}) satisfies Component<{
|
||||
pile: Pile;
|
||||
style?: JSX.CSSProperties;
|
||||
onClick?:
|
||||
| JSX.EventHandlerUnion<
|
||||
HTMLDivElement,
|
||||
MouseEvent,
|
||||
JSX.EventHandler<HTMLDivElement, MouseEvent>
|
||||
>
|
||||
| undefined;
|
||||
}>;
|
||||
@@ -1,4 +0,0 @@
|
||||
// @refresh reload
|
||||
import { mount, StartClient } from "@solidjs/start/client";
|
||||
|
||||
mount(() => <StartClient />, document.getElementById("app")!);
|
||||
@@ -1,21 +0,0 @@
|
||||
// @refresh reload
|
||||
import { createHandler, StartServer } from "@solidjs/start/server";
|
||||
|
||||
export default createHandler(() => (
|
||||
<StartServer
|
||||
document={({ assets, children, scripts }) => (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
{assets}
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">{children}</div>
|
||||
{scripts}
|
||||
</body>
|
||||
</html>
|
||||
)}
|
||||
/>
|
||||
));
|
||||
@@ -1,5 +0,0 @@
|
||||
import Game from "../components/Game";
|
||||
|
||||
export default () => {
|
||||
return <Game />;
|
||||
};
|
||||
@@ -1,19 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"types": ["vinxi/types/client"],
|
||||
"isolatedModules": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
"@games/*": ["packages/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user