better plumbing for games frontend
This commit is contained in:
@@ -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.
|
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
5
pkg/client/src/components/games/index.ts
Normal file
5
pkg/client/src/components/games/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import simple from "./simple";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
simple,
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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()),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user