better plumbing for games frontend

This commit is contained in:
2025-09-04 23:48:49 -04:00
parent b3e040f03f
commit 9e3697ffef
7 changed files with 131 additions and 89 deletions

View File

@@ -1,9 +1,8 @@
import { createSignal, onMount, useContext } from "solid-js";
import { createObservableWithInit } from "~/fn";
import { playerColor } 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);
@@ -14,8 +13,6 @@ export default (props: { playerKey: string } & Stylable) => {
.thru((Evt) => createObservableWithInit(Evt, false)) ??
createSignal(false)[0];
const game = useContext(GameContext);
onMount(() => console.log("Player mounted"));
return (

View File

@@ -1,5 +1,6 @@
import type { TWsIn, TWsOut } from "@games/server/src/table";
import { fromPromise, merge, Stream } from "kefir";
import games from "@games/shared/games/index";
import { fromPromise, Stream } from "kefir";
import {
Accessor,
createContext,
@@ -9,19 +10,19 @@ import {
onCleanup,
Show,
} from "solid-js";
import { Store } from "solid-js/store";
import { Dynamic } from "solid-js/web";
import api, { fromWebsocket } from "~/api";
import {
createObservable,
createObservableStore,
createObservableWithInit,
cx,
extractProperty,
} from "~/fn";
import { me, mePromise } from "~/profile";
import Simple from "./games/simple";
import { me, mePromise, name } from "~/profile";
import GAMES from "./games";
import Player from "./Player";
import games from "@games/shared/games/index";
import { createStore, Store } from "solid-js/store";
import { name } from "~/profile";
export const TableContext = createContext<{
view: Accessor<any>;
@@ -49,6 +50,11 @@ export default (props: { tableKey: string }) => {
const presenceEvents = wsEvents.filter(
(evt) => evt.playersPresent !== undefined
);
const gameConfig = extractProperty(wsEvents, "gameConfig").thru(
createObservable
);
const gameEvents = wsEvents.filter((evt) => evt.view !== undefined);
const resultEvents = wsEvents.filter((evt) => evt.results !== undefined);
@@ -77,16 +83,8 @@ export default (props: { tableKey: string }) => {
createEffect(() => sendWs({ ready: ready() }));
createEffect(() => sendWs({ name: name() }));
const viewProp = gameEvents.map((evt) => evt.view).toProperty();
viewProp.log();
const view = createObservable(viewProp);
const results = createObservable<string>(
merge([
gameEvents
.filter((evt) => "view" in evt && evt.view == null)
.map(() => undefined),
resultEvents.map((evt) => evt.results),
])
);
return (
<TableContext.Provider
@@ -98,6 +96,7 @@ export default (props: { tableKey: string }) => {
players,
}}
>
{/* Player avatars around the table */}
<div class="flex justify-around p-t-14">
<For each={players().filter((p) => p != me())}>
{(player, i) => {
@@ -118,6 +117,8 @@ export default (props: { tableKey: string }) => {
}}
</For>
</div>
{/* The table body itself */}
<div
id="table"
class={cx(
@@ -156,14 +157,15 @@ export default (props: { tableKey: string }) => {
</div>
</Show>
</div>
<Show when={view() != null}>
<Simple />
</Show>
<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">
{playerNames[results()!]} won!
</span>
</Show>
{/* 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

@@ -1,4 +1,4 @@
import { Accessor, createContext, For, useContext } from "solid-js";
import { Accessor, createContext, For, Show, useContext } from "solid-js";
import type {
SimpleAction,
SimplePlayerView,
@@ -10,6 +10,8 @@ import Pile from "../Pile";
import { TableContext } from "../Table";
import { Portal } from "solid-js/web";
import FannedHand from "../FannedHand";
import { createObservable, extractProperty } from "../../fn";
import { Property } from "kefir";
export const GameContext = createContext<{
view: Accessor<SimplePlayerView>;
@@ -19,66 +21,93 @@ export const GameContext = createContext<{
export default () => {
const table = useContext(TableContext)!;
const view = table.view as Accessor<SimplePlayerView>;
const results = (
extractProperty(table.wsEvents, "results") as Property<string, any>
).thru(createObservable);
const submitAction = (action: SimpleAction) => table.sendWs({ action });
return (
<GameContext.Provider value={{ view, submitAction }}>
<Pile
count={view().deckCount}
scale={0.8}
class="cursor-pointer fixed center"
onClick={() => submitAction({ type: "draw" })}
/>
{/* Configuration */}
<Show when={view() == null}>
<div>Configuration!</div>
</Show>
<Hand
class="fixed bc"
hand={view().myHand}
onClickCard={(card) => submitAction({ type: "discard", card })}
/>
<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.playerNames[view().playerTurn] + "'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).filter(([key, _]) =>
table.players().includes(key)
)}
>
{([playerKey, handCount], i) => (
<Portal
mount={document.getElementById(`player-${playerKey}`)!}
ref={(ref) => {
console.log("Setting hand ref");
const midOffset =
i() + 0.5 - Object.values(view().playerHandCounts).length / 2;
{/* Active game */}
<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" })}
/>
ref.style = `position: absolute; display: flex; justify-content: center; top: 65%;`;
}}
>
<FannedHand handCount={handCount} />
</Portal>
)}
</For>
{/* 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, _]) =>
table.players().includes(key)
)}
>
{([playerKey, handCount], i) => (
<Portal
mount={document.getElementById(`player-${playerKey}`)!}
ref={(ref) => {
console.log("Setting hand 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.playerNames[view().playerTurn] + "'s"}
</span>{" "}
turn
</div>
{/* Quit button */}
<button
class="button fixed tl m-4 p-1"
onClick={() => {
table.sendWs({ quit: true });
}}
>
Quit
</button>
</Show>
{/* 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.playerNames[results()!]} won!
</span>
</Show>
</GameContext.Provider>
);
};

View File

@@ -1,4 +1,4 @@
import { Observable } from "kefir";
import { Observable, Property } from "kefir";
import { Accessor, createSignal } from "solid-js";
import { createStore } from "solid-js/store";
@@ -47,3 +47,15 @@ export const createObservableStore = <T extends object = {}>(
obs.onValue((val) => setStore(val));
return store;
};
export const extractProperty = <
P extends string,
T extends { [key in P]?: any }
>(
obs: Observable<T, any>,
property: P
): Property<T[P], any> =>
obs
.filter((o) => property in o)
.map((o) => o[property]!)
.toProperty();

View File

@@ -17,7 +17,9 @@ export const WsOut = t.Object({
playersPresent: t.Optional(t.Array(t.String())),
playerNames: t.Optional(t.Record(t.String(), t.String())),
playersReady: t.Optional(t.Nullable(t.Record(t.String(), t.Boolean()))),
gameConfig: t.Optional(t.Any()),
gameConfig: t.Optional(
t.Object({ game: t.String(), players: t.Array(t.String()) })
),
view: t.Optional(t.Any()),
results: t.Optional(t.Any()),
});