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,5 +0,0 @@
# 2025-09-03-224857
The backend has a pretty good abstraction of a `Game`, that it can swap in implemenations for.
The frontend is still mostly hardcoded to the test `simple` game.
Will need to refine the concept of `Game` to include the necessary components of displaying and interacting with a game.

View File

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

View File

@@ -1,5 +1,6 @@
import type { TWsIn, TWsOut } from "@games/server/src/table"; 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 { import {
Accessor, Accessor,
createContext, createContext,
@@ -9,19 +10,19 @@ import {
onCleanup, onCleanup,
Show, Show,
} from "solid-js"; } from "solid-js";
import { Store } from "solid-js/store";
import { Dynamic } from "solid-js/web";
import api, { fromWebsocket } from "~/api"; import api, { fromWebsocket } from "~/api";
import { import {
createObservable, createObservable,
createObservableStore, createObservableStore,
createObservableWithInit, createObservableWithInit,
cx, cx,
extractProperty,
} from "~/fn"; } from "~/fn";
import { me, mePromise } from "~/profile"; import { me, mePromise, name } from "~/profile";
import Simple from "./games/simple"; import GAMES from "./games";
import Player from "./Player"; 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<{ export const TableContext = createContext<{
view: Accessor<any>; view: Accessor<any>;
@@ -49,6 +50,11 @@ export default (props: { tableKey: string }) => {
const presenceEvents = wsEvents.filter( const presenceEvents = wsEvents.filter(
(evt) => evt.playersPresent !== undefined (evt) => evt.playersPresent !== undefined
); );
const gameConfig = extractProperty(wsEvents, "gameConfig").thru(
createObservable
);
const gameEvents = wsEvents.filter((evt) => evt.view !== undefined); const gameEvents = wsEvents.filter((evt) => evt.view !== undefined);
const resultEvents = wsEvents.filter((evt) => evt.results !== undefined); const resultEvents = wsEvents.filter((evt) => evt.results !== undefined);
@@ -77,16 +83,8 @@ export default (props: { tableKey: string }) => {
createEffect(() => sendWs({ ready: ready() })); createEffect(() => sendWs({ ready: ready() }));
createEffect(() => sendWs({ name: name() })); createEffect(() => sendWs({ name: name() }));
const viewProp = gameEvents.map((evt) => evt.view).toProperty(); const viewProp = gameEvents.map((evt) => evt.view).toProperty();
viewProp.log();
const view = createObservable(viewProp); 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 ( return (
<TableContext.Provider <TableContext.Provider
@@ -98,6 +96,7 @@ export default (props: { tableKey: string }) => {
players, players,
}} }}
> >
{/* Player avatars around the table */}
<div class="flex justify-around p-t-14"> <div class="flex justify-around p-t-14">
<For each={players().filter((p) => p != me())}> <For each={players().filter((p) => p != me())}>
{(player, i) => { {(player, i) => {
@@ -118,6 +117,8 @@ export default (props: { tableKey: string }) => {
}} }}
</For> </For>
</div> </div>
{/* The table body itself */}
<div <div
id="table" id="table"
class={cx( class={cx(
@@ -156,14 +157,15 @@ export default (props: { tableKey: string }) => {
</div> </div>
</Show> </Show>
</div> </div>
<Show when={view() != null}>
<Simple /> {/* The game being played */}
</Show> <Dynamic
<Show when={results() != null}> component={
<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"> gameConfig()?.game ?? "" in GAMES
{playerNames[results()!]} won! ? GAMES[gameConfig()!.game as keyof typeof GAMES]
</span> : undefined
</Show> }
/>
</TableContext.Provider> </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 { import type {
SimpleAction, SimpleAction,
SimplePlayerView, SimplePlayerView,
@@ -10,6 +10,8 @@ import Pile from "../Pile";
import { TableContext } from "../Table"; import { TableContext } from "../Table";
import { Portal } from "solid-js/web"; import { Portal } from "solid-js/web";
import FannedHand from "../FannedHand"; import FannedHand from "../FannedHand";
import { createObservable, extractProperty } from "../../fn";
import { Property } from "kefir";
export const GameContext = createContext<{ export const GameContext = createContext<{
view: Accessor<SimplePlayerView>; view: Accessor<SimplePlayerView>;
@@ -19,10 +21,22 @@ export const GameContext = createContext<{
export default () => { export default () => {
const table = useContext(TableContext)!; const table = useContext(TableContext)!;
const view = table.view as Accessor<SimplePlayerView>; 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 }); const submitAction = (action: SimpleAction) => table.sendWs({ action });
return ( return (
<GameContext.Provider value={{ view, submitAction }}> <GameContext.Provider value={{ view, submitAction }}>
{/* Configuration */}
<Show when={view() == null}>
<div>Configuration!</div>
</Show>
{/* Active game */}
<Show when={view() != null}>
{/* Main pile in the middle of the table */}
<Pile <Pile
count={view().deckCount} count={view().deckCount}
scale={0.8} scale={0.8}
@@ -30,35 +44,14 @@ export default () => {
onClick={() => submitAction({ type: "draw" })} onClick={() => submitAction({ type: "draw" })}
/> />
{/* Your own hand */}
<Hand <Hand
class="fixed bc" class="fixed bc"
hand={view().myHand} hand={view().myHand}
onClickCard={(card) => submitAction({ type: "discard", card })} onClickCard={(card) => submitAction({ type: "discard", card })}
/> />
<div
class="absolute tc text-align-center" {/* Other players' hands */}
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 <For
each={Object.entries(view().playerHandCounts).filter(([key, _]) => each={Object.entries(view().playerHandCounts).filter(([key, _]) =>
table.players().includes(key) table.players().includes(key)
@@ -79,6 +72,42 @@ export default () => {
</Portal> </Portal>
)} )}
</For> </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> </GameContext.Provider>
); );
}; };

View File

@@ -1,4 +1,4 @@
import { Observable } from "kefir"; import { Observable, Property } from "kefir";
import { Accessor, createSignal } from "solid-js"; import { Accessor, createSignal } from "solid-js";
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
@@ -47,3 +47,15 @@ export const createObservableStore = <T extends object = {}>(
obs.onValue((val) => setStore(val)); obs.onValue((val) => setStore(val));
return store; 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())), playersPresent: t.Optional(t.Array(t.String())),
playerNames: t.Optional(t.Record(t.String(), t.String())), playerNames: t.Optional(t.Record(t.String(), t.String())),
playersReady: t.Optional(t.Nullable(t.Record(t.String(), t.Boolean()))), 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()), view: t.Optional(t.Any()),
results: t.Optional(t.Any()), results: t.Optional(t.Any()),
}); });