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

View File

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

View File

@@ -22,11 +22,12 @@ 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 });
const results = table.wsEvents
.thru(extractProperty("results"))
.thru(createObservable);
return ( return (
<GameContext.Provider value={{ view, submitAction }}> <GameContext.Provider value={{ view, submitAction }}>
{/* Configuration */} {/* Configuration */}
@@ -53,13 +54,13 @@ export default () => {
{/* Other players' hands */} {/* Other players' hands */}
<For <For
each={Object.entries(view().playerHandCounts).filter(([key, _]) => each={Object.entries(view().playerHandCounts).filter(
table.players().includes(key) ([key, _]) => key in table.players
)} )}
> >
{([playerKey, handCount], i) => ( {([playerKey, handCount], i) => (
<Portal <Portal
mount={table.playerRefs[playerKey]} mount={table.players[playerKey].ref}
ref={(ref) => { ref={(ref) => {
console.log("Setting hand ref"); console.log("Setting hand ref");
const midOffset = const midOffset =
@@ -86,7 +87,7 @@ export default () => {
<span class="font-bold"> <span class="font-bold">
{view().playerTurn == me() {view().playerTurn == me()
? "your" ? "your"
: table.playerNames[view().playerTurn] + "'s"} : table.players[view().playerTurn].name + "'s"}
</span>{" "} </span>{" "}
turn turn
</div> </div>
@@ -105,7 +106,7 @@ export default () => {
{/* Results */} {/* Results */}
<Show when={results() != null}> <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"> <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> </span>
</Show> </Show>
</GameContext.Provider> </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 { Accessor, createSignal } from "solid-js";
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
@@ -39,21 +39,27 @@ export const createObservableWithInit = <T>(
export const cx = (...classes: string[]) => classes.join(" "); export const cx = (...classes: string[]) => classes.join(" ");
export const createObservableStore = <T extends object = {}>( export const createObservableStore =
obs: Observable<T, any>, <T extends object = {}>(init: T) =>
init: T (obs: Observable<T, any>) => {
) => {
const [store, setStore] = createStore<T>(init); const [store, setStore] = createStore<T>(init);
obs.onValue((val) => setStore(val)); obs.onValue((val) => setStore(val));
return store; return store;
}; };
type UnionKeys<T> = T extends any ? keyof T : never; type UnionKeys<T> = T extends any ? keyof T : never;
export const extractProperty = <T extends object, P extends UnionKeys<T>>( type ExtractPropertyType<T, P extends string | number | symbol> = T extends {
obs: Observable<T, any>, [K in P]: any;
property: P }
): Property<T[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 obs
.filter((o) => property in o) .filter((o) => property in o)
.map((o) => o[property]!) .map(
(o) => (o as { [K in P]: any })[property] as ExtractPropertyType<T, P>
)
.toProperty(); .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 { 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 mePromise = api.whoami.post().then((r) => r.data);
export const [me] = createResource(() => mePromise); export const [me] = createResource(() => mePromise);
createEffect(() => console.log(me()));
export const playerColor = (humanKey: string) => export const playerColor = (humanKey: string) =>
"#" + hash(humanKey).substring(0, 6); "#" + hash(humanKey).substring(0, 6);

View File

@@ -16,7 +16,7 @@ import { log } from "./logging";
export const WsOut = t.Union([ export const WsOut = t.Union([
t.Object({ playersPresent: t.Array(t.String()) }), t.Object({ playersPresent: t.Array(t.String()) }),
t.Object({ playerNames: t.Record(t.String(), 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({ t.Object({
gameConfig: t.Object({ game: t.String(), players: t.Array(t.String()) }), gameConfig: t.Object({ game: t.String(), players: t.Array(t.String()) }),
}), }),