package -> pkg
This commit is contained in:
49
pkg/client/src/components/Card.tsx
Normal file
49
pkg/client/src/components/Card.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Component, Suspense } from "solid-js";
|
||||
|
||||
import type { Card } from "@games/shared/cards";
|
||||
import { Clickable, Sizable, 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={props.style}
|
||||
width={props.width ?? "100px"}
|
||||
height={props.height}
|
||||
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 &
|
||||
Sizable
|
||||
>;
|
||||
29
pkg/client/src/components/FannedHand.tsx
Normal file
29
pkg/client/src/components/FannedHand.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Hand } from "@games/shared/cards";
|
||||
import { For } from "solid-js";
|
||||
import Card from "./Card";
|
||||
|
||||
export default (props: { handCount: number }) => {
|
||||
return (
|
||||
<For each={Array(props.handCount)}>
|
||||
{(_, i) => {
|
||||
const midOffset = i() + 0.5 - props.handCount / 2;
|
||||
return (
|
||||
<Card
|
||||
face="down"
|
||||
width="40px"
|
||||
style={{
|
||||
"margin-left": "-10px",
|
||||
"margin-right": "-10px",
|
||||
transform: `rotate(${
|
||||
midOffset * 0.2
|
||||
}rad) translate(0px, ${
|
||||
2 ** Math.abs(midOffset) * 2
|
||||
}px)`,
|
||||
"box-shadow": "-4px 4px 4px rgba(0, 0, 0, 0.7)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
);
|
||||
};
|
||||
73
pkg/client/src/components/Game.tsx
Normal file
73
pkg/client/src/components/Game.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Accessor, createContext, For, useContext } from "solid-js";
|
||||
import type {
|
||||
SimpleAction,
|
||||
SimplePlayerView,
|
||||
} from "@games/server/src/games/simple";
|
||||
import { me, profile } from "~/profile";
|
||||
import Hand from "./Hand";
|
||||
import Pile from "./Pile";
|
||||
import { TableContext } from "./Table";
|
||||
import { Portal } from "solid-js/web";
|
||||
import FannedHand from "./FannedHand";
|
||||
|
||||
export const GameContext = createContext<{
|
||||
view: Accessor<SimplePlayerView>;
|
||||
submitAction: (action: SimpleAction) => any;
|
||||
}>();
|
||||
|
||||
export default () => {
|
||||
const table = useContext(TableContext)!;
|
||||
const view = table.view as Accessor<SimplePlayerView>;
|
||||
const submitAction = (action: SimpleAction) => table.sendWs({ action });
|
||||
|
||||
return (
|
||||
<GameContext.Provider value={{ view, submitAction }}>
|
||||
<Pile
|
||||
count={view().deckCount}
|
||||
class="cursor-pointer fixed center"
|
||||
onClick={() => submitAction({ type: "draw" })}
|
||||
/>
|
||||
|
||||
<Hand
|
||||
class="fixed bc"
|
||||
hand={view().myHand}
|
||||
onClickCard={(card) => submitAction({ type: "discard", card })}
|
||||
/>
|
||||
<div class="absolute tc text-align-center">
|
||||
It's{" "}
|
||||
<span class="font-bold">
|
||||
{view().playerTurn == me()
|
||||
? "your"
|
||||
: profile(view().playerTurn)()?.name + "'s"}
|
||||
</span>{" "}
|
||||
turn
|
||||
</div>
|
||||
<button
|
||||
class="button fixed tl m-4 p-1"
|
||||
onClick={() => table.sendWs({ quit: true })}
|
||||
>
|
||||
Quit
|
||||
</button>
|
||||
<For each={Object.entries(view().playerHandCounts)}>
|
||||
{([playerKey, handCount], i) => (
|
||||
<Portal
|
||||
mount={document.getElementById(`player-${playerKey}`)!}
|
||||
ref={(ref) => {
|
||||
const midOffset =
|
||||
i() +
|
||||
0.5 -
|
||||
Object.values(view().playerHandCounts).length /
|
||||
2;
|
||||
|
||||
ref.style = `position: absolute; display: flex; justify-content: center; top: 65%; transform: translate(${Math.abs(
|
||||
midOffset * 0
|
||||
)}px, 0px) rotate(${midOffset * 1}rad)`;
|
||||
}}
|
||||
>
|
||||
<FannedHand handCount={handCount} />
|
||||
</Portal>
|
||||
)}
|
||||
</For>
|
||||
</GameContext.Provider>
|
||||
);
|
||||
};
|
||||
24
pkg/client/src/components/Hand.tsx
Normal file
24
pkg/client/src/components/Hand.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
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";
|
||||
|
||||
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
|
||||
>;
|
||||
22
pkg/client/src/components/Pile.tsx
Normal file
22
pkg/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
|
||||
>;
|
||||
38
pkg/client/src/components/Player.tsx
Normal file
38
pkg/client/src/components/Player.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createSignal, useContext } from "solid-js";
|
||||
import { playerColor, profile } from "~/profile";
|
||||
import { TableContext } from "./Table";
|
||||
import { Stylable } from "./toolbox";
|
||||
import { createObservable, createObservableWithInit } from "~/fn";
|
||||
import { GameContext } from "./Game";
|
||||
|
||||
export default (props: { playerKey: string } & Stylable) => {
|
||||
const table = useContext(TableContext);
|
||||
const playerReady =
|
||||
table?.wsEvents
|
||||
.filter((evt) => evt.playersReady != null)
|
||||
.map((evt) => evt.playersReady![props.playerKey])
|
||||
.thru((Evt) => createObservableWithInit(Evt, false)) ??
|
||||
createSignal(false)[0];
|
||||
|
||||
const game = useContext(GameContext);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`player-${props.playerKey}`}
|
||||
style={{
|
||||
...props.style,
|
||||
"background-color": playerColor(props.playerKey),
|
||||
...(playerReady() && table?.view() == null
|
||||
? {
|
||||
border: "10px solid green",
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
class={`${props.class} w-20 h-20 rounded-full flex justify-center items-center`}
|
||||
>
|
||||
<p style={{ "font-size": "1em" }}>
|
||||
{profile(props.playerKey)()?.name}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
136
pkg/client/src/components/Table.tsx
Normal file
136
pkg/client/src/components/Table.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { TWsIn, TWsOut } from "@games/server/src/table";
|
||||
import { fromPromise, Stream } from "kefir";
|
||||
import {
|
||||
Accessor,
|
||||
createContext,
|
||||
createEffect,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
Show,
|
||||
} from "solid-js";
|
||||
import api, { fromWebsocket } from "~/api";
|
||||
import { createObservable, createObservableWithInit, cx } from "~/fn";
|
||||
import { me, mePromise } from "~/profile";
|
||||
import Game from "./Game";
|
||||
import Player from "./Player";
|
||||
import games from "@games/shared/games/index";
|
||||
|
||||
export const TableContext = createContext<{
|
||||
view: Accessor<any>;
|
||||
sendWs: (msg: TWsIn) => void;
|
||||
wsEvents: Stream<TWsOut, any>;
|
||||
}>();
|
||||
|
||||
export default (props: { tableKey: string }) => {
|
||||
const wsPromise = new Promise<
|
||||
ReturnType<ReturnType<typeof api.ws>["subscribe"]>
|
||||
>((res) => {
|
||||
const ws = api.ws(props).subscribe();
|
||||
ws.on("open", () => res(ws));
|
||||
ws.on("error", () => res(ws));
|
||||
});
|
||||
|
||||
const sendWs = (msg: TWsIn) => wsPromise.then((ws) => ws.send(msg));
|
||||
const wsEvents = fromPromise(wsPromise).flatMap((ws) =>
|
||||
fromWebsocket<TWsOut>(ws)
|
||||
);
|
||||
onCleanup(() => wsPromise.then((ws) => ws.close()));
|
||||
|
||||
const presenceEvents = wsEvents.filter((evt) => evt.players != null);
|
||||
const gameEvents = wsEvents.filter((evt) => evt.view !== undefined);
|
||||
|
||||
const players = createObservableWithInit<string[]>(
|
||||
presenceEvents.map((evt) => evt.players!),
|
||||
[]
|
||||
);
|
||||
|
||||
const [ready, setReady] = createSignal(false);
|
||||
mePromise.then(
|
||||
(me) =>
|
||||
me &&
|
||||
wsEvents
|
||||
.filter((evt) => evt.playersReady !== undefined)
|
||||
.map((evt) => evt.playersReady?.[me] ?? false)
|
||||
.onValue(setReady)
|
||||
);
|
||||
|
||||
createEffect(() => sendWs({ ready: ready() }));
|
||||
const view = createObservable(gameEvents.map((evt) => evt.view));
|
||||
|
||||
return (
|
||||
<TableContext.Provider
|
||||
value={{
|
||||
sendWs,
|
||||
wsEvents,
|
||||
view,
|
||||
}}
|
||||
>
|
||||
<div class="flex justify-around p-t-14">
|
||||
<For each={players().filter((p) => p != me())}>
|
||||
{(player, i) => {
|
||||
const verticalOffset = () => {
|
||||
const N = 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>
|
||||
<div
|
||||
id="table"
|
||||
class={cx(
|
||||
"fixed",
|
||||
|
||||
"bg-radial",
|
||||
"from-orange-950",
|
||||
"to-stone-950",
|
||||
|
||||
"border-4",
|
||||
"border-neutral-950",
|
||||
"shadow-lg",
|
||||
|
||||
"top-40",
|
||||
"bottom-20",
|
||||
"left-10",
|
||||
"right-10"
|
||||
)}
|
||||
style={{
|
||||
"border-radius": "50%",
|
||||
}}
|
||||
>
|
||||
<Show when={view() == null}>
|
||||
<div class="absolute tc mt-8 flex gap-4">
|
||||
<select>
|
||||
<For each={Object.entries(games)}>
|
||||
{([gameId, game]) => (
|
||||
<option value={gameId}>{game.title}</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setReady((prev) => !prev)}
|
||||
class="button p-1 "
|
||||
>
|
||||
{ready() ? "Not Ready" : "Ready"}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={view() != null}>
|
||||
<Game />
|
||||
</Show>
|
||||
</TableContext.Provider>
|
||||
);
|
||||
};
|
||||
21
pkg/client/src/components/toolbox.tsx
Normal file
21
pkg/client/src/components/toolbox.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
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 Sizable = {
|
||||
width?: string;
|
||||
height?: string;
|
||||
};
|
||||
Reference in New Issue
Block a user