really solid

This commit is contained in:
2025-09-06 23:22:58 -04:00
parent b854fec9e5
commit 46002403c8
6 changed files with 114 additions and 89 deletions

View File

@@ -1,29 +1,21 @@
import { createSignal, onMount, useContext } from "solid-js";
import { createObservableWithInit } from "~/fn";
import { createObservableWithInit, extractProperty } from "~/fn";
import { playerColor } from "~/profile";
import { TableContext } from "./Table";
import { Stylable } from "./toolbox";
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];
onMount(() => console.log("Player mounted"));
return (
<div
ref={(e) => {
if (table != null) table.playerRefs[props.playerKey] = e;
}}
ref={(e) => table?.setPlayers(props.playerKey, "ref", e)}
style={{
...props.style,
"background-color": playerColor(props.playerKey),
...(playerReady() && table?.view() == null
...(table?.view() == null && table?.players[props.playerKey].ready
? {
border: "10px solid green",
}
@@ -31,8 +23,8 @@ export default (props: { playerKey: string } & Stylable) => {
}}
class={`${props.class} w-20 h-20 rounded-full flex justify-center items-center`}
>
<p style={{ "font-size": "1em" }}>
{table?.playerNames[props.playerKey]}
<p class="font-[1em] text-align-center">
{table?.players[props.playerKey].name}
</p>
</div>
);

View File

@@ -10,7 +10,7 @@ import {
onCleanup,
Show,
} from "solid-js";
import { Store } from "solid-js/store";
import { createStore, SetStoreFunction, Store } from "solid-js/store";
import { Dynamic } from "solid-js/web";
import api, { fromWebsocket } from "~/api";
import {
@@ -24,16 +24,25 @@ import { me, mePromise, name } from "~/profile";
import GAMES from "./games";
import Player from "./Player";
type PlayerStore = Store<{
[key: string]: {
name: string;
ready: boolean;
ref?: HTMLDivElement;
};
}>;
export const TableContext = createContext<{
view: Accessor<any>;
sendWs: (msg: TWsIn) => void;
wsEvents: Stream<TWsOut, any>;
playerNames: Store<{ [key: string]: string }>;
players: Accessor<string[]>;
playerRefs: { [key: string]: HTMLDivElement };
sendWs: (msg: TWsIn) => void;
players: PlayerStore;
setPlayers: SetStoreFunction<PlayerStore>;
view: Accessor<any>;
}>();
export default (props: { tableKey: string }) => {
// #region Websocket Setup
const wsPromise = new Promise<
ReturnType<ReturnType<typeof api.ws>["subscribe"]>
>((res) => {
@@ -46,51 +55,82 @@ export default (props: { tableKey: string }) => {
fromWebsocket<TWsOut>(ws)
);
onCleanup(() => wsPromise.then((ws) => ws.close()));
// #endregion
const gameConfig = extractProperty(wsEvents, "gameConfig").thru(
createObservable
);
const view = extractProperty(wsEvents, "view").thru(createObservable);
const players = extractProperty(wsEvents, "playersPresent").thru(
createObservable
);
const playerNames = createObservableStore(
extractProperty(wsEvents, "playerNames"),
{}
);
const [ready, setReady] = createSignal(false);
mePromise.then(
(me) =>
me &&
// #region inbound table properties
const [players, setPlayers] = createStore<PlayerStore>({});
const playerKeys = () => Object.keys(players);
wsEvents
.filter((evt) => evt.playersReady !== undefined)
.map((evt) => evt.playersReady?.[me] ?? false)
.onValue(setReady)
.thru(extractProperty("playersPresent"))
.onValue((P) =>
P.filter((p) => !(p in players)).forEach((p) =>
setPlayers(p, { name: "", ready: false })
)
);
wsEvents
.thru(extractProperty("playerNames"))
.onValue((P) =>
Object.entries(P).map(([player, name]) =>
setPlayers(player, "name", name)
)
);
wsEvents
.thru(extractProperty("playersReady"))
.onValue((P) =>
Object.entries(P).map(([player, ready]) =>
setPlayers(player, "ready", ready)
)
);
// #endregion
// #region inbound game properties
const gameConfig = wsEvents
.thru(extractProperty("gameConfig"))
.thru(createObservable);
const view = wsEvents.thru(extractProperty("view")).thru(createObservable);
// #endregion
// #region outbound signals
const [ready, setReady] = createSignal(false);
createEffect(() => sendWs({ ready: ready() }));
createEffect(() => sendWs({ name: name() }));
// #endregion
const GamePicker = () => {
return (
<div class="absolute tc mt-8 flex gap-4">
<select>
<For each={Object.entries(games)}>
{([gameId]) => <option value={gameId}>{gameId}</option>}
</For>
</select>
<button onClick={() => setReady((prev) => !prev)} class="button p-1 ">
{ready() ? "Not Ready" : "Ready"}
</button>
</div>
);
};
return (
<TableContext.Provider
value={{
sendWs,
wsEvents,
view,
playerNames,
sendWs,
players,
playerRefs: {},
setPlayers,
view,
}}
>
{/* Player avatars around the table */}
<div class="flex justify-around p-t-14">
<For each={players().filter((p) => p != me())}>
<For each={playerKeys().filter((p) => p != me())}>
{(player, i) => {
const verticalOffset = () => {
const N = players().length - 1;
const N = playerKeys().length - 1;
const x = Math.abs((2 * i() + 1) / (N * 2) - 0.5);
const y = Math.sqrt(1 - x * x);
return 1 - y;
@@ -131,19 +171,7 @@ export default (props: { tableKey: string }) => {
}}
>
<Show when={view() == null}>
<div class="absolute tc mt-8 flex gap-4">
<select>
<For each={Object.entries(games)}>
{([gameId, game]) => <option value={gameId}>{gameId}</option>}
</For>
</select>
<button
onClick={() => setReady((prev) => !prev)}
class="button p-1 "
>
{ready() ? "Not Ready" : "Ready"}
</button>
</div>
<GamePicker />
</Show>
</div>

View File

@@ -22,11 +22,12 @@ 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 });
const results = table.wsEvents
.thru(extractProperty("results"))
.thru(createObservable);
return (
<GameContext.Provider value={{ view, submitAction }}>
{/* Configuration */}
@@ -53,13 +54,13 @@ export default () => {
{/* Other players' hands */}
<For
each={Object.entries(view().playerHandCounts).filter(([key, _]) =>
table.players().includes(key)
each={Object.entries(view().playerHandCounts).filter(
([key, _]) => key in table.players
)}
>
{([playerKey, handCount], i) => (
<Portal
mount={table.playerRefs[playerKey]}
mount={table.players[playerKey].ref}
ref={(ref) => {
console.log("Setting hand ref");
const midOffset =
@@ -86,7 +87,7 @@ export default () => {
<span class="font-bold">
{view().playerTurn == me()
? "your"
: table.playerNames[view().playerTurn] + "'s"}
: table.players[view().playerTurn].name + "'s"}
</span>{" "}
turn
</div>
@@ -105,7 +106,7 @@ export default () => {
{/* 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!
{table.players[results()!].name} won!
</span>
</Show>
</GameContext.Provider>

View File

@@ -1,4 +1,4 @@
import { Observable, Property } from "kefir";
import { Observable, Property, Stream } from "kefir";
import { Accessor, createSignal } from "solid-js";
import { createStore } from "solid-js/store";
@@ -39,21 +39,27 @@ export const createObservableWithInit = <T>(
export const cx = (...classes: string[]) => classes.join(" ");
export const createObservableStore = <T extends object = {}>(
obs: Observable<T, any>,
init: T
) => {
export const createObservableStore =
<T extends object = {}>(init: T) =>
(obs: Observable<T, any>) => {
const [store, setStore] = createStore<T>(init);
obs.onValue((val) => setStore(val));
return store;
};
type UnionKeys<T> = T extends any ? keyof T : never;
export const extractProperty = <T extends object, P extends UnionKeys<T>>(
obs: Observable<T, any>,
property: P
): Property<T[P], any> =>
type ExtractPropertyType<T, P extends string | number | symbol> = T extends {
[K in P]: any;
}
? T[P]
: never;
export const extractProperty =
<T extends object, P extends UnionKeys<T>>(property: P) =>
(obs: Observable<T, any>): Property<ExtractPropertyType<T, P>, any> =>
obs
.filter((o) => property in o)
.map((o) => o[property]!)
.map(
(o) => (o as { [K in P]: any })[property] as ExtractPropertyType<T, P>
)
.toProperty();

View File

@@ -1,12 +1,10 @@
import { createEffect, createResource, createSignal, Resource } from "solid-js";
import { ApiType } from "./fn";
import api from "./api";
import hash from "object-hash";
import { makePersisted } from "@solid-primitives/storage";
import hash from "object-hash";
import { createResource, createSignal } from "solid-js";
import api from "./api";
export const mePromise = api.whoami.post().then((r) => r.data);
export const [me] = createResource(() => mePromise);
createEffect(() => console.log(me()));
export const playerColor = (humanKey: string) =>
"#" + hash(humanKey).substring(0, 6);

View File

@@ -16,7 +16,7 @@ import { log } from "./logging";
export const WsOut = t.Union([
t.Object({ playersPresent: t.Array(t.String()) }),
t.Object({ playerNames: t.Record(t.String(), t.String()) }),
t.Object({ playersReady: t.Nullable(t.Record(t.String(), t.Boolean())) }),
t.Object({ playersReady: t.Record(t.String(), t.Boolean()) }),
t.Object({
gameConfig: t.Object({ game: t.String(), players: t.Array(t.String()) }),
}),