Skip to content

Commit 730b520

Browse files
committed
✨ leaderboard
1 parent 59a061d commit 730b520

File tree

14 files changed

+615
-190
lines changed

14 files changed

+615
-190
lines changed

.env

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
VITE_LEADERBOARD_ENDPOINT="https://yar-htzee-leaderboard.platane.workers.dev"
2+
VITE_XR8_API_KEY=

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
node_modules
22
dist
3-
.env
3+
.env.local
44
.dev.vars
55
.wrangler

src/App/Game.tsx

Lines changed: 77 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,23 @@ import { Dice } from "./Scene/Dice";
66
import { ThrowHint } from "./Ui/Hints/ThrowHint";
77
import { PickHint } from "./Ui/Hints/PickHint";
88
import { PullHint } from "./Ui/Hints/PullHint";
9-
import { isDiceValue, isScoreSheetEmpty } from "../gameRules/types";
10-
import { Overlay } from "./Ui/Overlay";
11-
import { ScoreSheet } from "./Ui/ScoreSheet/ScoreSheet";
9+
import {
10+
type Category,
11+
categories,
12+
isDiceValue,
13+
isScoreSheetEmpty,
14+
isScoreSheetFinished,
15+
} from "../gameRules/types";
1216
import { ScaleOnPulse } from "./Scene/ScaleOnPulse";
1317
import { SelectedDiceHint } from "./Scene/SelectedDiceHint";
1418
import { useDelay } from "./Ui/useDelay";
1519
import { Target } from "./Scene/Target";
1620
import { createGameWorld } from "../gameWorld/state";
1721
import { type Game as IGame, isBlank, nReroll } from "../gameRules/game";
22+
import { createSceneScreenshot } from "./createSceneScreenshot";
23+
import { ScoreSheetContent } from "./Ui/ScoreSheet/ScoreSheetContent";
24+
import { LeaderboardSubmission } from "./Ui/ScoreSheet/LeaderboardSubmission";
25+
import { DialogModal } from "./Ui/DialogModal-fallback";
1826

1927
export const Game_ = ({
2028
UiPortal,
@@ -23,6 +31,16 @@ export const Game_ = ({
2331
}) => {
2432
const world = React.useMemo(createGameWorld, []);
2533

34+
const [screenshots, saveScreenshot] = React.useReducer(
35+
(s, { category, blob }: { category: Category; blob: Blob }) => ({
36+
...s,
37+
[category]: blob,
38+
}),
39+
{} as Record<Category, Blob>
40+
);
41+
const { scene } = useThree();
42+
const { getImage } = React.useMemo(createSceneScreenshot, []);
43+
2644
const [dragging, setDragging] = React.useState(false);
2745
const [scoresheetOpen, setScoresheetOpen] = React.useState(false);
2846

@@ -75,6 +93,7 @@ export const Game_ = ({
7593
e.stopPropagation();
7694
world.toggleDicePicked(i);
7795
}}
96+
userData={{ diceIndex: i }}
7897
/>
7998
</ScaleOnPulse>
8099
))}
@@ -105,49 +124,62 @@ export const Game_ = ({
105124

106125
{!dragging && !scoresheetOpen && <Hint game={world.state.game} />}
107126

108-
{scoresheetOpen && (
109-
<Overlay>
110-
<ScoreSheet
111-
style={{
112-
width: "calc( 100% - 40px )",
113-
maxWidth: "600px",
114-
pointerEvents: "auto",
115-
}}
116-
scoreSheet={world.state.game.scoreSheet}
117-
onClose={() => setScoresheetOpen(false)}
118-
onSelectCategory={
119-
world.state.game.roll.every(isDiceValue)
120-
? (c) => {
121-
world.selectCategoryForDiceRoll(c);
127+
<DialogModal
128+
open={scoresheetOpen}
129+
onClose={() => setScoresheetOpen(false)}
130+
style={{ width: "min(100%,600px)" }}
131+
>
132+
<h3 style={{ paddingLeft: "10px" }}>Score Sheet</h3>
133+
134+
<ScoreSheetContent
135+
scoreSheet={world.state.game.scoreSheet}
136+
rollCandidate={
137+
(world.state.game.roll.every(isDiceValue) &&
138+
world.state.game.roll) ||
139+
null
140+
}
141+
onSelectCategory={
142+
world.state.game.roll.every(isDiceValue)
143+
? (category) => {
144+
const roll = world.state.game.roll;
145+
if (!roll.every(isDiceValue)) return;
146+
147+
getImage(scene, roll, category).then((blob) =>
148+
saveScreenshot({ blob, category })
149+
);
150+
151+
world.selectCategoryForDiceRoll(category);
152+
if (!isScoreSheetFinished(world.state.game.scoreSheet))
122153
setScoresheetOpen(false);
123-
}
124-
: undefined
125-
}
126-
rollCandidate={
127-
world.state.game.roll.every(isDiceValue)
128-
? world.state.game.roll
129-
: undefined
130-
}
131-
/>
132-
</Overlay>
133-
)}
134-
135-
{!scoresheetOpen && (
136-
<button
137-
style={{
138-
position: "absolute",
139-
width: "160px",
140-
height: "40px",
141-
bottom: "10px",
142-
right: "60px",
143-
zIndex: 1,
144-
pointerEvents: "auto",
145-
}}
146-
onClick={() => setScoresheetOpen(true)}
147-
>
148-
score sheet
149-
</button>
150-
)}
154+
}
155+
: undefined
156+
}
157+
/>
158+
159+
{isScoreSheetFinished(world.state.game.scoreSheet) && (
160+
<div style={{ marginTop: "16px", minHeight: "30px" }}>
161+
<LeaderboardSubmission
162+
scoreSheet={world.state.game.scoreSheet}
163+
screenshots={screenshots}
164+
/>
165+
</div>
166+
)}
167+
</DialogModal>
168+
169+
<button
170+
style={{
171+
position: "absolute",
172+
width: "160px",
173+
height: "40px",
174+
bottom: "10px",
175+
right: "60px",
176+
zIndex: 1,
177+
pointerEvents: "auto",
178+
}}
179+
onClick={() => setScoresheetOpen(true)}
180+
>
181+
score sheet
182+
</button>
151183
</UiPortal>
152184
</>
153185
);
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import * as React from "react";
2+
3+
/**
4+
* implementation of a dialog modal
5+
* without relying on html dialog
6+
* because the dialog does not work in fullscreen mode (or webXR AR mode)
7+
*/
8+
export const DialogModal = ({
9+
open,
10+
children,
11+
onClose,
12+
...props
13+
}: React.ComponentProps<"div"> & { onClose: () => void; open: boolean }) => {
14+
return (
15+
<>
16+
<style>{style}</style>
17+
<div
18+
data-backdrop
19+
style={{ visibility: open ? "visible" : "hidden" }}
20+
onClick={(e) => {
21+
let el = e.target as HTMLElement | null;
22+
while (!!el?.tagName) {
23+
if (el.hasAttribute("data-dialog")) return;
24+
el = el.parentElement;
25+
}
26+
onClose();
27+
}}
28+
>
29+
<div
30+
data-dialog
31+
{...props}
32+
style={{
33+
// @ts-ignore
34+
"--min-margin": "16px",
35+
visibility: open ? "visible" : "hidden",
36+
...props.style,
37+
}}
38+
>
39+
{children}
40+
<button
41+
className="dialog-close-button"
42+
onClick={onClose}
43+
title="close"
44+
>
45+
×
46+
</button>
47+
</div>
48+
</div>
49+
</>
50+
);
51+
};
52+
53+
const style = `
54+
[data-dialog] {
55+
box-sizing: border-box;
56+
border-radius: 2px;
57+
box-shadow: 5px 8px 16px 0 rgba(0,0,0,0.2);
58+
border: solid #aaa 1px;
59+
background-color: #fff;
60+
padding:16px;
61+
62+
min-width: min( 100vw, 200px );
63+
max-width: calc( min( 1200px, 100vw ) - var(--min-margin) * 2 );
64+
width: fit-content;
65+
min-height: min(500px, 60vh);
66+
max-height: min(860px, 100vh - var(--min-margin) * 2);
67+
position: relative;
68+
69+
}
70+
[data-backdrop] {
71+
background-color: rgba(0, 0, 0, 0.25);
72+
background-image: radial-gradient(
73+
ellipse at center,
74+
transparent 0,
75+
transparent 70%,
76+
rgba(0, 0, 0, 0.05) 100%
77+
);
78+
position:fixed;
79+
top:0;
80+
left:0;
81+
width:100vw;
82+
height:100vh;
83+
z-index:999;
84+
display:flex;
85+
flex-direction:column;
86+
align-items:center;
87+
justify-content:center;
88+
}
89+
90+
[data-dialog] .dialog-close-button {
91+
position: absolute;
92+
top: 12px;
93+
right: 12px;
94+
height: 24px;
95+
width: 24px;
96+
}
97+
`;

src/App/Ui/DialogModal.tsx

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import * as React from "react";
2+
3+
export const DialogModal = ({
4+
open,
5+
children,
6+
...props
7+
}: React.ComponentProps<"dialog">) => {
8+
const ref = React.useRef<HTMLDialogElement | null>(null);
9+
React.useLayoutEffect(() => {
10+
if (open) ref.current?.showModal();
11+
else ref.current?.close();
12+
}, [open]);
13+
14+
return (
15+
<>
16+
<style>{style}</style>
17+
<dialog
18+
ref={ref}
19+
{...props}
20+
onClick={onClickOutSideClose}
21+
style={{
22+
// @ts-ignore
23+
"--min-margin": "16px",
24+
...props.style,
25+
}}
26+
>
27+
{children}
28+
<button
29+
className="dialog-close-button"
30+
onClick={closeDialog}
31+
title="close"
32+
>
33+
×
34+
</button>
35+
</dialog>
36+
</>
37+
);
38+
};
39+
40+
const style = `
41+
dialog {
42+
box-sizing: border-box;
43+
border-radius: 2px;
44+
box-shadow: 5px 8px 16px 0 rgba(0,0,0,0.2);
45+
border: solid #aaa 1px;
46+
background-color: #fff;
47+
padding:16px;
48+
49+
min-width: min( 100vw, 200px );
50+
max-width: calc( min( 1200px, 100vw ) - var(--min-margin) * 2 );
51+
width: fit-content;
52+
min-height: min(500px, 60vh);
53+
max-height: min(800px, 100vh - var(--min-margin) * 2);
54+
position: relative;
55+
}
56+
dialog::backdrop {
57+
background-color: rgba(0, 0, 0, 0.25);
58+
background-image: radial-gradient(
59+
ellipse at center,
60+
transparent 0,
61+
transparent 70%,
62+
rgba(0, 0, 0, 0.05) 100%
63+
);
64+
}
65+
dialog .dialog-close-button {
66+
position: absolute;
67+
top: 12px;
68+
right: 12px;
69+
height: 24px;
70+
width: 24px;
71+
}
72+
`;
73+
74+
const onClickOutSideClose = (e: React.MouseEvent) => {
75+
const target = e.target;
76+
77+
if (!isHTMLDialogElement(target)) return;
78+
79+
const rect = target.getBoundingClientRect();
80+
81+
const clickedInDialog =
82+
rect.top <= e.clientY &&
83+
e.clientY <= rect.top + rect.height &&
84+
rect.left <= e.clientX &&
85+
e.clientX <= rect.left + rect.width;
86+
87+
if (clickedInDialog === false) target.close();
88+
};
89+
90+
const isHTMLDialogElement = (e: any): e is HTMLDialogElement =>
91+
e?.tagName === "DIALOG";
92+
93+
const isHTMLElement = (e: any): e is HTMLElement => !!e?.tagName;
94+
95+
/**
96+
* attach that to a onClick to close the parent dialog
97+
*/
98+
export const closeDialog = (e: React.MouseEvent) => {
99+
let target: HTMLElement | null = e.target as HTMLElement;
100+
while (isHTMLElement(target)) {
101+
if (isHTMLDialogElement(target)) {
102+
target.close();
103+
return;
104+
}
105+
target = target.parentElement;
106+
}
107+
};

0 commit comments

Comments
 (0)