basic shape

This commit is contained in:
2025-08-06 23:29:55 -04:00
parent 3891e8b85b
commit 839d596b55
46 changed files with 220 additions and 5507 deletions

View 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/entry-client.tsx"></script>
</head>
<body>
<div id="app" />
</body>
</html>

View File

@@ -0,0 +1,17 @@
{
"name": "client",
"type": "module",
"version": "0.0.2",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"@solidjs/router": "^0.15.3",
"solid-js": "^1.9.5"
},
"devDependencies": {
"vite": "^7.0.6",
"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)

View File

@@ -0,0 +1,71 @@
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%;
}
a {
color: rgb(18, 229, 113);
}
a:visited {
color: rgb(23, 138, 125);
}
#app {
height: 100%;
background: radial-gradient(rgb(24, 82, 65), rgb(1, 42, 16));
}
.hand {
background: radial-gradient(rgb(24, 70, 82), rgb(1, 42, 41));
}
.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;
}

View File

@@ -0,0 +1,35 @@
import "./app.css";
import { Route, Router } from "@solidjs/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 />
</>
)}
>
<Route path="/" component={() => "tesingt"} />
</Router>
);
}

View File

@@ -0,0 +1,45 @@
import { Component, createResource, JSX, Suspense } from "solid-js";
import { Card, newDeck } from "../types/cards";
import { Clickable, 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 default ((props) => {
return (
<Suspense>
<img
onClick={props.onClick}
draggable={false}
class={props.class}
style={{
"border-radius": "5px",
...props.style,
}}
width="100px"
src={
props.face == "down"
? "/views/back.svg"
: `/views/cards/${cardToSvgFilename(props.card)}.svg`
}
/>
</Suspense>
);
}) satisfies Component<
{
card: Card;
face?: "up" | "down";
} & Stylable &
Clickable
>;

View File

@@ -0,0 +1,54 @@
import {
Accessor,
createContext,
createResource,
JSX,
Show,
Suspense,
} from "solid-js";
import Card from "./Card";
import Hand from "./Hand";
import Pile from "./Pile";
import { GameState, newDeck, shuffle, Hand as THand } from "../types/cards";
import { createStore, produce, SetStoreFunction, Store } from "solid-js/store";
import { getGameState, updateGameState } from "../db/Instances";
export const GameContext = createContext<{
gameState: Accessor<GameState | undefined>;
setGameState: (state: GameState) => Promise<any>;
}>();
export default (props: { instanceId: number }) => {
const [gameState, { refetch }] = createResource(() =>
getGameState(props.instanceId)
);
const setGameState = (state: GameState) =>
updateGameState(props.instanceId, state).then(refetch);
return (
<GameContext.Provider value={{ gameState, setGameState }}>
<Show when={gameState() != undefined}>
<div
onClick={() => {}}
class="full column center"
style={{ "row-gap": "20px", "font-size": "32px" }}
>
<div class="full center">
<Pile
pile={gameState()!.deck}
style={{ cursor: "pointer" }}
onClick={() => {
const [drawn, ...rest] = gameState()!.deck;
setGameState({
deck: rest,
hand: [drawn, ...gameState()!.hand],
});
}}
/>
</div>
<Hand hand={gameState()!.hand} />
</div>
</Show>
</GameContext.Provider>
);
};

View File

@@ -0,0 +1,51 @@
import { Component, For, useContext } from "solid-js";
import Card from "./Card";
import { Hand } from "../types/cards";
import { GameContext } from "./Game";
import { produce } from "solid-js/store";
export default ((props) => {
const { setGameState, gameState } = useContext(GameContext)!;
return (
<div
class="hand"
style={{
"min-width": "100px",
width: "fit-content",
"max-width": "80%",
border: "2px dashed white",
"border-radius": "12px",
margin: "10px",
"margin-bottom": "25px",
padding: "10px",
height: "180px",
overflow: "scroll",
"scrollbar-width": "none",
display: "flex",
gap: "5px",
}}
>
<For each={props.hand}>
{(card) => (
<Card
card={card}
style={{
cursor: "pointer",
}}
onClick={() => {
const index = gameState()!.hand.indexOf(card);
setGameState({
deck: [card, ...gameState()!.deck],
hand: [
...gameState()!.hand.slice(0, index),
...gameState()!.hand.slice(index + 1),
],
});
}}
/>
)}
</For>
</div>
);
}) satisfies Component<{ hand: Hand }>;

View File

@@ -0,0 +1,46 @@
import { Component, For, JSX } from "solid-js";
import Card from "./Card";
import { Pile } from "../types/cards";
import { type ComponentProps } from "solid-js";
import { Clickable, Stylable } from "./toolbox";
export default ((props) => {
return (
<div
{...props}
class={`center ${props.class ?? ""}}`.trim()}
style={{
width: "200px",
height: "400px",
...(props.style as JSX.CSSProperties),
}}
onClick={props.onClick}
>
<For each={props.pile}>
{(card, i) => (
<Card
card={card}
face="down"
style={{
position: "absolute",
transform: `translate(${i() * 0.5}px, ${
i() * 0.2
}px)`,
// "z-index": 100 - i(),
border: `0.1px solid rgb(${
10 + i() + Math.random() * 50
}, ${10 + i() + Math.random() * 50}, ${
10 + i() + Math.random() * 50
});`,
}}
/>
)}
</For>
</div>
);
}) satisfies Component<
{
pile: Pile;
} & Stylable &
Clickable
>;

View File

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

View File

@@ -0,0 +1,4 @@
"use server";
import { prisma } from "./db";
export const queryAll = async () => await prisma.game.findMany();

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

View File

@@ -0,0 +1,5 @@
"use server";
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();

View File

@@ -0,0 +1,4 @@
import App from "./app";
import { render } from "solid-js/web";
render(App, document.getElementById("app")!);

View File

@@ -0,0 +1,9 @@
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));

1
packages/client/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="@solidjs/start/env" />

View File

@@ -0,0 +1,82 @@
import { Board, Card, Hand, Pile, Stack, Suit } from "./types/cards";
import { clone } from "./fn";
const AGG: Suit = "spades";
const CUL: Suit = "hearts";
const TECH: Suit = "diamonds";
const MIL: Suit = "clubs";
type GameState = {
mainPile: Pile;
discardStack: Stack;
deadPile: Pile;
players: {
hand: Hand;
board: Board;
}[];
playerTurn: number;
lastMove: Move;
prevState: GameState | null;
};
type Move =
| {
type: "draw";
fromPile: "main" | "discard";
}
| { type: "play"; card: Card }
| { type: "remove"; card: Card }
| { type: "attack"; army: Card[] }
| { type: "pass" };
type Action = Move | { type: "discard"; cards: Card[] };
const techTier = (techValue: number) => Math.ceil(techValue / 14);
const moveCost = (move: Move) => (move.type == "attack" ? move.army.length : 1);
const movesSpent = (state: GameState): number =>
state.playerTurn != state.prevState?.playerTurn
? 0
: moveCost(state.lastMove) + movesSpent(state.prevState);
const value = (suit: Suit) => (board: Board) =>
board
.filter((card) => card.suit === suit)
.reduce((v, card) => v + card.value, 0);
export const getNextState = (p: {
state: GameState;
move: Move;
}): GameState | { illegal: string } => {
const { state, move } = p;
const currentPlayer = state.players[state.playerTurn];
const population = currentPlayer.board.thru(value(AGG));
const culture = currentPlayer.board.thru(value(CUL));
const tech = techTier(currentPlayer.board.thru(value(TECH)));
const movesRemaining = tech - movesSpent(state);
const newState = clone(state);
if (move.type === "draw") {
const pile =
move.fromPile === "main"
? newState.mainPile
: newState.discardStack;
if (pile.length === 0) {
return {
illegal: `The ${move.fromPile} pile is empty; cannot draw`,
};
}
newState.players[newState.playerTurn].hand.push(pile.pop()!);
if (movesRemaining == 1) {
newState.playerTurn =
(newState.playerTurn + 1) % newState.players.length;
}
return newState;
}
return { illegal: "idk bruh" };
};

View File

@@ -0,0 +1,30 @@
import { A, useParams } from "@solidjs/router";
import { createEffect, createResource, Show, Suspense } from "solid-js";
import Game from "../../components/Game";
import { getGameState } from "../../db/Instances";
export default () => {
const params = useParams<{ game: string; instance: string }>();
return (
<>
<Game instanceId={Number.parseInt(params.instance)} />
<A
href={`/${params.game}`}
style={{
position: "absolute",
padding: "10px",
top: "0",
left: "0",
margin: "20px",
"background-color": "white",
"border-radius": "8px",
border: "2px solid black",
}}
>
Back
</A>
</>
);
};

View File

@@ -0,0 +1,39 @@
import { A, useParams } from "@solidjs/router";
import { createEffect, createResource, For, Suspense } from "solid-js";
import * as Instance from "../../db/Instances";
export default () => {
const params = useParams<{ game: string }>();
const [instances, { refetch }] = createResource(
() => params.game,
() => Instance.queryInstances(params.game)
);
return (
<Suspense>
<div style={{ padding: "20px" }}>
<h1 style={{ margin: 0 }}>{params.game}</h1>
<button
onClick={() =>
Instance.createInstance(params.game).then(refetch)
}
>
New Game
</button>
<ul>
<For each={instances() ?? []}>
{(instance) => (
<li>
<A href={`/${params.game}/${instance.id}`}>
{instance.id}
</A>
</li>
)}
</For>
</ul>
</div>
</Suspense>
);
};

View File

@@ -0,0 +1,14 @@
import { A } from "@solidjs/router";
import { createResource, For } from "solid-js";
import * as Games from "../db/Games";
export default () => {
const [games] = createResource(() => Games.queryAll());
return (
<div style={{ padding: "20px" }}>
<For each={games()}>
{(game) => <A href={`/${game.name}`}>{game.name}</A>}
</For>
</div>
);
};

View File

@@ -0,0 +1,67 @@
const suits = ["heart", "diamond", "spade", "club"] as const;
export type Suit = (typeof suits)[number];
const ranks = [
2,
3,
4,
5,
6,
7,
8,
9,
10,
"jack",
"queen",
"king",
"ace",
] as const;
export type Rank = (typeof ranks)[number];
export type Card =
| {
kind: "normal";
suit: Suit;
rank: Rank;
}
| { kind: "joker"; color: "red" | "black" };
export type Pile = Card[];
export type Stack = Card[];
export type Hand = Card[];
export type Board = Card[];
export const newDeck = (withJokers = false): Pile =>
suits
.map((suit) =>
ranks.map((rank) => ({ kind: "normal", suit, rank } as Card))
)
.flat()
.concat(
withJokers
? [
{ kind: "joker", color: "red" },
{ kind: "joker", color: "black" },
]
: []
);
export const shuffle = (cards: Card[]) => {
let i = cards.length;
while (i > 0) {
const j = Math.floor(Math.random() * i);
i--;
[cards[i], cards[j]] = [cards[j], cards[i]];
}
return cards;
};
export type GameState = {
deck: Pile;
hand: Hand;
};
export const newGame = () =>
({
deck: shuffle(newDeck()),
hand: [],
} as GameState);

View 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/*"]
}
}
}

View File

@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import solidPlugin from "vite-plugin-solid";
export default defineConfig({
plugins: [solidPlugin()],
});