Compare commits

...

26 Commits

Author SHA1 Message Date
Viktor Rådberg
92f954130f bump 2024-03-31 19:13:27 +02:00
Viktor Rådberg
112023bdd5 log if it works or not 2024-03-31 19:08:17 +02:00
Viktor Rådberg
4e6dc56d99 add api key back 2024-03-31 19:02:59 +02:00
Viktor Rådberg
e427bfd0cf new analytics api key 2024-03-31 18:52:08 +02:00
Viktor Rådberg
ed10edc6d2 bump 2024-03-31 12:30:18 +02:00
Viktor Rådberg
7696b357b4 add trivia prestart mode 2024-03-31 12:30:03 +02:00
Viktor Rådberg
a7b78b8e7a settings tracking 2024-03-31 12:28:34 +02:00
Viktor Rådberg
fa95d171b7 bump 2024-03-30 14:23:26 +01:00
Viktor Rådberg
00a556be0e always include version in tracked events 2024-03-30 14:22:37 +01:00
Viktor Rådberg
3276dc81fc Clearer way to see if there is an update, more fun tracking 2024-03-30 14:04:12 +01:00
Viktor Rådberg
28c2ff536f bump 2024-03-30 10:28:49 +01:00
Viktor Rådberg
6beddf06e2 better invalid settings handling 2024-03-30 10:27:16 +01:00
Viktor Rådberg
2a885f9a43 bump 2024-03-29 23:25:50 +01:00
Viktor Rådberg
9c27f34261 More Pre-Start modes. (#34)
* do

* parse settings before setting
2024-03-29 23:24:35 +01:00
Viktor Rådberg
fa5829b402 fix keep away state 2024-03-23 16:23:03 +01:00
Viktor Rådberg
71f26d0dc5 fix keep awake toggle and layout styling 2024-03-23 16:22:48 +01:00
Viktor Rådberg
3a568fc3ab Better scaling on small devices 2024-03-23 16:05:29 +01:00
Viktor Rådberg
355f4bd4cd track only on prod, and add life changed amount tracking 2024-03-23 12:39:07 +01:00
Viktor Rådberg
17e174bfe1 new deploy 2024-03-23 11:24:14 +01:00
Viktor Rådberg
e1e8da858b remove prod check 2024-03-23 11:23:46 +01:00
Viktor Rådberg
e02f071415 new deploy 2024-03-23 11:23:06 +01:00
Viktor Rådberg
e04f31bb67 prod log 2024-03-23 11:22:12 +01:00
Viktor Rådberg
e5386d08a4 fix settings cog color 2024-03-17 19:01:15 +01:00
Viktor Rådberg
d6cd678e9f fix random interval wrapper showing if enabled but show player is disabled 2024-03-17 18:41:58 +01:00
Viktor Rådberg
334b46db6e bump 2024-03-16 22:29:25 +01:00
Viktor Rådberg
e03ecc6f51 Merge pull request #33 from Vikeo/random-player-interval
random player interval
2024-03-16 22:28:49 +01:00
32 changed files with 11075 additions and 9398 deletions

View File

@@ -1,8 +1,12 @@
index.html,1705225256081,6ef0d7e2de82bf64addbb9294fb28845fd06daaa544b010a47422c12ae3ad97f index.html,1711905081687,5707f310c48bfbf4b0777999bec2b3216159b24efaac7d4ef5b3f774031a5bd2
robots.txt,1705225255906,391d14b3c2f8c9143a27a28c7399585142228d4d1bdbe2c87ac946de411fa9a2 manifest.webmanifest,1711905081687,f2bf253209f6e292a6b0dbfa06fb4ac188eb5f2dba568c3ad5511b9ed52c1f51
manifest.json,1705225255906,91ce94afb71f33a477f5d8d48c3f98bd7de422279c74f17b6500eec72003ac1a manifest.json,1711905081475,7ff5111aa04a42adff3b38924ec467b6f345ed0309dba1486dc9b783b60c2a9d
assets/index-08359bdb.css,1705225256081,d2766260d28230d960d75362810713efaddf40687205e697432b52869f162af7 registerSW.js,1711905081687,5b6445b5215737c53ef0d379c151d57de165a056de2d1c5812ed614f158ebcbd
logo192.png,1705225255905,3b0fcf91fe2128f493de0bce2f6e2d35520a4260a04e05b8d855181359b3d3fe sw.js,1711905082939,e3333155a3ccec8e315325341364c6fb441239fe35e70c1912b660bfdfbe5714
favicon.ico,1705225255905,75661e6187b524767554b4f28ec09a64bc72b0bb102a0b453aaead88519d9ed3 robots.txt,1711905081475,391d14b3c2f8c9143a27a28c7399585142228d4d1bdbe2c87ac946de411fa9a2
logo512.png,1705225255906,cf49739c9e6890bbfcd4157f299dde425df60759b7320ae9188d7ab9dc51e8ca assets/index-7m_Zw4yH.css,1711905081687,37997d06b32b3d0c724c054913e3c0583b86f786358121cb1615e6646ff46b56
assets/index-20658f4b.js,1705225256081,742f2c10740beea3a23f269aa6266b3c288d1fd9c7e20b6829034e8a898bf1e1 workbox-3e911b1d.js,1711905082939,d5dbc868a5c07af633d29de7ba3ffe37542aaaabdf33713b4298df31f92f11ff
logo192.png,1711905081474,3d1e2e6f064d4fd325828f21bb6493ff0dbf2390b0e7d2aba9f2b6def4829799
favicon.ico,1711905081474,8cefe5adbf00d337d8633fb744b9f2c4914f769b319be4bb7e184b7a4aa17160
logo512.png,1711905081475,892a4da1cc5434929a83a71fcbcb0d0d80aa82f44e3c21e9b20ffe9267197133
assets/index-B28HjoRb.js,1711905081687,7475dc6e6963e1027f4ccaa29b43412d71fbe80a2dd42baccf9c058e69485a2f

View File

@@ -7,7 +7,8 @@ jobs:
build_and_deploy: build_and_deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
REPO_READ_ACCESS_TOKEN: ${{ secrets.REPO_READ_ACCESS_TOKEN }} VITE_REPO_READ_ACCESS_TOKEN: ${{ secrets.REPO_READ_ACCESS_TOKEN }}
VITE_FIREBASE_ANALYTICS_API_KEY: ${{ secrets.FIREBASE_ANALYTICS_API_KEY }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
node-linker=hoisted

1
env.d.ts vendored
View File

@@ -1 +0,0 @@
declare const APP_VERSION: string;

View File

@@ -1,11 +1,12 @@
{ {
"name": "life-trinket", "name": "life-trinket",
"private": true, "private": true,
"version": "0.6.7", "version": "0.9.42",
"type": "commonjs", "type": "commonjs",
"engines": { "engines": {
"node": ">=18", "node": ">=18",
"npm": "please use bun or yarn :) " "yarn": "use pnpm",
"npm": "please use pnpm"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

9603
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -21,9 +21,9 @@ const Container = twc.div<RotationDivProps>((props) => [
]); ]);
export const ExtraCountersGrid = twc.div<RotationDivProps>((props) => [ export const ExtraCountersGrid = twc.div<RotationDivProps>((props) => [
'flex absolute flex-row flex-grow pointer-events-none', 'flex absolute flex-row flex-grow pointer-events-none overflow-x-scroll overflow-y-hidden',
props.$rotation === Rotation.SideFlipped || props.$rotation === Rotation.Side props.$rotation === Rotation.SideFlipped || props.$rotation === Rotation.Side
? 'flex-col-reverse h-full w-auto bottom-auto' ? 'flex-col-reverse h-full w-auto bottom-auto right-0'
: 'w-full bottom-0', : 'w-full bottom-0',
]); ]);

View File

@@ -1,10 +1,12 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useSwipeable } from 'react-swipeable'; import { useSwipeable } from 'react-swipeable';
import { twc } from 'react-twc'; import { twc } from 'react-twc';
import { useAnalytics } from '../../Hooks/useAnalytics';
import { useGlobalSettings } from '../../Hooks/useGlobalSettings'; import { useGlobalSettings } from '../../Hooks/useGlobalSettings';
import { usePlayers } from '../../Hooks/usePlayers'; import { usePlayers } from '../../Hooks/usePlayers';
import { Cog } from '../../Icons/generated'; import { Cog } from '../../Icons/generated';
import { Player, Rotation } from '../../Types/Player'; import { Player, Rotation } from '../../Types/Player';
import { checkContrast } from '../../Utils/checkContrast';
import { import {
RotationButtonProps, RotationButtonProps,
RotationDivProps, RotationDivProps,
@@ -12,10 +14,9 @@ import {
import { LoseGameButton } from '../Buttons/LoseButton'; import { LoseGameButton } from '../Buttons/LoseButton';
import CommanderDamageBar from '../Counters/CommanderDamageBar'; import CommanderDamageBar from '../Counters/CommanderDamageBar';
import ExtraCountersBar from '../Counters/ExtraCountersBar'; import ExtraCountersBar from '../Counters/ExtraCountersBar';
import { Paragraph } from '../Misc/TextComponents';
import PlayerMenu from '../Players/PlayerMenu'; import PlayerMenu from '../Players/PlayerMenu';
import { StartingPlayerCard } from '../PreStartGame/StartingPlayerCard';
import Health from './Health'; import Health from './Health';
import { baseColors } from '../../../tailwind.config';
const SettingsButtonTwc = twc.button<RotationButtonProps>((props) => [ const SettingsButtonTwc = twc.button<RotationButtonProps>((props) => [
'absolute flex-grow border-none outline-none cursor-pointer bg-transparent z-[1] select-none webkit-user-select-none', 'absolute flex-grow border-none outline-none cursor-pointer bg-transparent z-[1] select-none webkit-user-select-none',
@@ -27,16 +28,33 @@ const SettingsButtonTwc = twc.button<RotationButtonProps>((props) => [
type SettingsButtonProps = { type SettingsButtonProps = {
onClick: () => void; onClick: () => void;
rotation: Rotation; rotation: Rotation;
color: string;
}; };
const SettingsButton = ({ onClick, rotation }: SettingsButtonProps) => { const SettingsButton = ({ onClick, rotation, color }: SettingsButtonProps) => {
const [iconColor, setIconColor] = useState<'dark' | 'light'>('dark');
useEffect(() => {
const contrast = checkContrast(color, '#00000080');
if (contrast === 'Fail') {
setIconColor('light');
} else {
setIconColor('dark');
}
}, [color]);
return ( return (
<SettingsButtonTwc <SettingsButtonTwc
onClick={onClick} onClick={onClick}
$rotation={rotation} $rotation={rotation}
aria-label={`Settings`} aria-label={`Settings`}
> >
<Cog size="5vmin" color="black" opacity="0.3" /> <Cog
size="5vmin"
data-contrast={iconColor}
className="data-[contrast=dark]:text-icons-dark data-[contrast=light]:text-icons-light"
/>
</SettingsButtonTwc> </SettingsButtonTwc>
); );
}; };
@@ -58,8 +76,6 @@ const PlayerLostWrapper = twc.div<RotationDivProps>((props) => [
: '', : '',
]); ]);
const DynamicText = twc.div`text-[8vmin] whitespace-nowrap`;
const hasCommanderDamageReached21 = (player: Player) => { const hasCommanderDamageReached21 = (player: Player) => {
const commanderDamageTotals = player.commanderDamage.map( const commanderDamageTotals = player.commanderDamage.map(
(commanderDamage) => commanderDamage.damageTotal (commanderDamage) => commanderDamage.damageTotal
@@ -97,9 +113,10 @@ const RECENT_DIFFERENCE_TTL = 3_000;
const LifeCounter = ({ player, opponents }: LifeCounterProps) => { const LifeCounter = ({ player, opponents }: LifeCounterProps) => {
const { updatePlayer, updateLifeTotal } = usePlayers(); const { updatePlayer, updateLifeTotal } = usePlayers();
const { settings, playing, setPlaying, stopPlayerRandomization } = const { settings, playing } = useGlobalSettings();
useGlobalSettings(); const recentDifferenceTimerRef = useRef<NodeJS.Timeout | undefined>(
const playingTimerRef = useRef<NodeJS.Timeout | undefined>(undefined); undefined
);
const [showPlayerMenu, setShowPlayerMenu] = useState(false); const [showPlayerMenu, setShowPlayerMenu] = useState(false);
const [recentDifference, setRecentDifference] = useState(0); const [recentDifference, setRecentDifference] = useState(0);
@@ -116,10 +133,12 @@ const LifeCounter = ({ player, opponents }: LifeCounterProps) => {
trackMouse: true, trackMouse: true,
onSwipedDown: (e) => { onSwipedDown: (e) => {
e.event.stopPropagation(); e.event.stopPropagation();
analytics.trackEvent('open_player_menu_swipe');
setShowPlayerMenu(true); setShowPlayerMenu(true);
}, },
onSwipedUp: (e) => { onSwipedUp: (e) => {
e.event.stopPropagation(); e.event.stopPropagation();
analytics.trackEvent('close_player_menu_swipe');
setShowPlayerMenu(false); setShowPlayerMenu(false);
}, },
@@ -127,50 +146,41 @@ const LifeCounter = ({ player, opponents }: LifeCounterProps) => {
onSwiping: (e) => e.event.stopPropagation(), onSwiping: (e) => e.event.stopPropagation(),
rotationAngle, rotationAngle,
}); });
const analytics = useAnalytics();
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { if (recentDifference === 0) {
clearTimeout(recentDifferenceTimerRef.current);
return;
}
recentDifferenceTimerRef.current = setTimeout(() => {
analytics.trackEvent('life_changed', {
lifeChangedAmount: recentDifference,
});
setRecentDifference(0); setRecentDifference(0);
}, RECENT_DIFFERENCE_TTL); }, RECENT_DIFFERENCE_TTL);
return () => {
clearTimeout(recentDifferenceTimerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recentDifference]);
useEffect(() => {
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
if (document.body.clientWidth > document.body.clientHeight) if (document.body.clientWidth > document.body.clientHeight)
setIsLandscape(true); setIsLandscape(true);
else setIsLandscape(false); else setIsLandscape(false);
return; return () => {
// Cleanup: disconnect the ResizeObserver when the component unmounts.
resizeObserver.disconnect();
};
}); });
resizeObserver.observe(document.body); resizeObserver.observe(document.body);
return () => {
clearTimeout(timer);
// Cleanup: disconnect the ResizeObserver when the component unmounts.
resizeObserver.disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [recentDifference, document.body.clientHeight, document.body.clientWidth]); }, [document.body.clientHeight, document.body.clientWidth]);
useEffect(() => {
if (
player.isStartingPlayer &&
((!playing &&
settings.useRandomStartingPlayerInterval &&
stopPlayerRandomization) ||
(!settings.useRandomStartingPlayerInterval && !playing))
) {
playingTimerRef.current = setTimeout(() => {
setPlaying(true);
}, 10_000);
}
return () => clearTimeout(playingTimerRef.current);
}, [
player.isStartingPlayer,
playing,
setPlaying,
settings.useRandomStartingPlayerInterval,
stopPlayerRandomization,
]);
player.settings.rotation === Rotation.SideFlipped || player.settings.rotation === Rotation.SideFlipped ||
player.settings.rotation === Rotation.Side; player.settings.rotation === Rotation.Side;
@@ -192,11 +202,7 @@ const LifeCounter = ({ player, opponents }: LifeCounterProps) => {
? player.settings.rotation - 90 ? player.settings.rotation - 90
: player.settings.rotation; : player.settings.rotation;
const calcTextRotation = const amountOfPlayers = opponents.length + 1;
player.settings.rotation === Rotation.SideFlipped ||
player.settings.rotation === Rotation.Side
? player.settings.rotation - 180
: player.settings.rotation;
return ( return (
<LifeCounterContentWrapper style={{ background: player.color }}> <LifeCounterContentWrapper style={{ background: player.color }}>
@@ -205,52 +211,15 @@ const LifeCounter = ({ player, opponents }: LifeCounterProps) => {
style={{ rotate: `${calcRotation}deg` }} style={{ rotate: `${calcRotation}deg` }}
{...handlers} {...handlers}
> >
{!playing && settings.showStartingPlayer && player.isStartingPlayer && ( {amountOfPlayers > 1 &&
<div !playing &&
className="z-20 flex absolute w-full h-full justify-center items-center select-none cursor-pointer webkit-user-select-none" settings.showStartingPlayer &&
style={{ player.isStartingPlayer && <StartingPlayerCard player={player} />}
rotate: `${calcRotation}deg`,
backgroundImage:
stopPlayerRandomization ||
!settings.useRandomStartingPlayerInterval
? `radial-gradient(circle at center, ${player.color}, ${baseColors.primary.main})`
: 'none',
}}
onClick={() => {
clearTimeout(playingTimerRef.current);
setPlaying(true);
}}
>
<DynamicText
style={{
rotate: `${calcTextRotation}deg`,
}}
>
<div className="flex flex-col justify-center items-center">
<Paragraph>👑</Paragraph>
{(stopPlayerRandomization ||
!settings.useRandomStartingPlayerInterval) && (
<>
<Paragraph>You start!</Paragraph>
<Paragraph className="text-xl">(Press to hide)</Paragraph>
</>
)}
</div>
</DynamicText>
</div>
)}
{player.hasLost && ( {player.hasLost && (
<PlayerLostWrapper $rotation={player.settings.rotation} /> <PlayerLostWrapper $rotation={player.settings.rotation} />
)} )}
{settings.useRandomStartingPlayerInterval &&
!stopPlayerRandomization &&
!playing && (
<div
className="flex absolute w-full h-full justify-center items-center pointer-events-none select-none webkit-user-select-none z-10"
style={{ backgroundColor: player.color }}
/>
)}
<CommanderDamageBar <CommanderDamageBar
opponents={opponents} opponents={opponents}
player={player} player={player}
@@ -260,9 +229,11 @@ const LifeCounter = ({ player, opponents }: LifeCounterProps) => {
{settings.showPlayerMenuCog && ( {settings.showPlayerMenuCog && (
<SettingsButton <SettingsButton
onClick={() => { onClick={() => {
analytics.trackEvent('open_player_menu_button');
setShowPlayerMenu(!showPlayerMenu); setShowPlayerMenu(!showPlayerMenu);
}} }}
rotation={player.settings.rotation} rotation={player.settings.rotation}
color={player.color}
/> />
)} )}
{playerCanLose(player) && ( {playerCanLose(player) && (

View File

@@ -3,6 +3,8 @@ import { twc } from 'react-twc';
import { Separator } from './Separator'; import { Separator } from './Separator';
import { Paragraph } from './TextComponents'; import { Paragraph } from './TextComponents';
import { Cross } from '../../Icons/generated'; import { Cross } from '../../Icons/generated';
import { useEffect } from 'react';
import { useAnalytics } from '../../Hooks/useAnalytics';
export const ModalWrapper = twc.div`absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[85vh] bg-background-default p-4 overflow-scroll rounded-2xl border-none text-text-primary w-[95vw] max-w-[548px]`; export const ModalWrapper = twc.div`absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[85vh] bg-background-default p-4 overflow-scroll rounded-2xl border-none text-text-primary w-[95vw] max-w-[548px]`;
@@ -12,6 +14,17 @@ type InfoModalProps = {
}; };
export const InfoModal = ({ isOpen, closeModal }: InfoModalProps) => { export const InfoModal = ({ isOpen, closeModal }: InfoModalProps) => {
const analytics = useAnalytics();
useEffect(() => {
if (!isOpen) {
return;
}
analytics.trackEvent('info_opened');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
return ( return (
<Modal <Modal
open={isOpen} open={isOpen}

View File

@@ -1,81 +1,61 @@
import { Button, FormLabel, Modal, Switch } from '@mui/material'; import { Modal, Switch } from '@mui/material';
import { useEffect } from 'react';
import { twc } from 'react-twc'; import { twc } from 'react-twc';
import { useGlobalSettings } from '../../Hooks/useGlobalSettings'; import { useGlobalSettings } from '../../Hooks/useGlobalSettings';
import { Cross } from '../../Icons/generated';
import { PreStartMode } from '../../Types/Settings';
import { ModalWrapper } from './InfoModal'; import { ModalWrapper } from './InfoModal';
import { Separator } from './Separator'; import { Separator } from './Separator';
import { Paragraph } from './TextComponents'; import { Paragraph } from './TextComponents';
import { useEffect, useState } from 'react'; import { useAnalytics } from '../../Hooks/useAnalytics';
import { Cross } from '../../Icons/generated';
const SettingContainer = twc.div`w-full flex flex-col mb-2`; const SettingContainer = twc.div`w-full flex flex-col mb-2`;
const ToggleContainer = twc.div`flex flex-row justify-between items-center -mb-1`; const ToggleContainer = twc.div`flex flex-row justify-between items-center -mb-1`;
const Container = twc.div`flex flex-col items-center w-full`; const Container = twc.div`flex flex-col items-start w-full`;
const Description = twc.p`mr-16 text-xs text-left text-text-secondary`; const Description = twc.p`mr-16 text-xs text-left text-text-secondary`;
const baseGithubReleasesUrl =
'https://github.com/Vikeo/LifeTrinket/releases/tag/';
type SettingsModalProps = { type SettingsModalProps = {
isOpen: boolean; isOpen: boolean;
closeModal: () => void; closeModal: () => void;
}; };
export const SettingsModal = ({ isOpen, closeModal }: SettingsModalProps) => { export const SettingsModal = ({ isOpen, closeModal }: SettingsModalProps) => {
const { settings, setSettings, isPWA } = useGlobalSettings(); const { settings, setSettings, isPWA, version } = useGlobalSettings();
const [isLatestVersion, setIsLatestVersion] = useState(false); const analytics = useAnalytics();
const [newVersion, setNewVersion] = useState<string | undefined>(undefined);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
return; return;
} }
async function checkIfLatestVersion() {
try {
const result = await fetch(
'https://api.github.com/repos/Vikeo/LifeTrinket/releases/latest',
{
headers: {
/* @ts-expect-error is defined in vite.config.ts*/
Authorization: `Bearer ${REPO_READ_ACCESS_TOKEN}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
}
);
const data = await result.json();
if (!data.name) { analytics.trackEvent('settings_opened');
setNewVersion(undefined); version.checkForNewVersion('settings');
setIsLatestVersion(false); // eslint-disable-next-line react-hooks/exhaustive-deps
return;
}
setNewVersion(data.name);
/* @ts-expect-error is defined in vite.config.ts*/
if (data.name === APP_VERSION) {
setIsLatestVersion(true);
return;
}
setIsLatestVersion(false);
} catch (error) {
console.error('error getting latest version string', error);
}
}
checkIfLatestVersion();
}, [isOpen]); }, [isOpen]);
return ( return (
<Modal <Modal
open={isOpen} open={isOpen}
onClose={closeModal} onClose={() => {
analytics.trackEvent('settings_outside_clicked');
closeModal();
}}
className="w-full flex justify-center" className="w-full flex justify-center"
> >
<> <>
<div className="flex justify-center items-center relative w-full max-w-[532px]"> <div className="flex justify-center items-center relative w-full max-w-[532px]">
<button <button
onClick={closeModal} onClick={() => {
analytics.trackEvent('settings_cross_clicked');
closeModal();
}}
className="flex absolute top-12 right-0 z-10 w-10 h-10 bg-primary-main items-center justify-center rounded-full border-solid border-primary-dark border-2" className="flex absolute top-12 right-0 z-10 w-10 h-10 bg-primary-main items-center justify-center rounded-full border-solid border-primary-dark border-2"
> >
<Cross size="16px" className="text-text-primary " /> <Cross size="16px" className="text-text-primary " />
@@ -83,29 +63,76 @@ export const SettingsModal = ({ isOpen, closeModal }: SettingsModalProps) => {
</div> </div>
<ModalWrapper> <ModalWrapper>
<Container> <Container>
<h2 className="text-center text-2xl mb-2"> Settings </h2> <h2 className="text-center text-2xl mb-2 w-full"> Settings </h2>
<div className="flex flex-col mb-2 w-full">
<div className="text-text-primary flex items-center gap-2">
Current version: {version.installedVersion}{' '}
{version.isLatest && (
<span className="text-sm text-text-secondary">(latest)</span>
)}
<div className="text-xs text-text-primary opacity-75">
(
<a
href={baseGithubReleasesUrl + version.installedVersion}
target="_blank"
className="underline"
onClick={() => {
analytics.trackEvent(
`current_change_log_clicked_v${version.installedVersion}`
);
}}
>
Release notes
</a>
)
</div>
</div>
{!version.isLatest && version.remoteVersion && (
<>
<div className="flex gap-2 items-center mt-2">
<Paragraph className="text-text-secondary">
{version.remoteVersion} available!
</Paragraph>
<div className="text-xs text-text-primary opacity-75">
(
<a
href={baseGithubReleasesUrl + version.remoteVersion}
target="_blank"
className="underline"
onClick={() => {
analytics.trackEvent(
`new_change_log_clicked_v${version.remoteVersion}`
);
}}
>
Release notes
</a>
)
</div>
</div>
<button
className="flex justify-center items-center self-start mt-2 bg-primary-main px-3 py-1 rounded-md"
onClick={() => {
{
analytics.trackEvent(`pressed_update`, {
toVersion: version.remoteVersion,
fromVersion: version.installedVersion,
});
window?.location?.reload();
}
}}
>
<span className="text-sm">Update</span>
<span className="text-xs">&nbsp;(reload app)</span>
</button>
</>
)}
</div>
<Separator height="1px" /> <Separator height="1px" />
<SettingContainer> <SettingContainer>
<ToggleContainer> <ToggleContainer>
<FormLabel>Show Start Player</FormLabel> <label>Show Player Menu Cog</label>
<Switch
checked={settings.showStartingPlayer}
onChange={() => {
setSettings({
...settings,
showStartingPlayer: !settings.showStartingPlayer,
});
}}
/>
</ToggleContainer>
<Description>
On start or reset of game, will pick a random player who will
start first if this is enabled.
</Description>
</SettingContainer>
<SettingContainer>
<ToggleContainer>
<FormLabel>Show Player Menu Cog</FormLabel>
<Switch <Switch
checked={settings.showPlayerMenuCog} checked={settings.showPlayerMenuCog}
onChange={() => { onChange={() => {
@@ -123,27 +150,84 @@ export const SettingsModal = ({ isOpen, closeModal }: SettingsModalProps) => {
</SettingContainer> </SettingContainer>
<SettingContainer> <SettingContainer>
<ToggleContainer> <ToggleContainer>
<FormLabel>Randomize starting player with interval</FormLabel> <label>Show Start Player</label>
<Switch <Switch
checked={settings.useRandomStartingPlayerInterval} checked={settings.showStartingPlayer}
onChange={() => { onChange={() => {
setSettings({ setSettings({
...settings, ...settings,
useRandomStartingPlayerInterval: showStartingPlayer: !settings.showStartingPlayer,
!settings.useRandomStartingPlayerInterval,
}); });
}} }}
/> />
</ToggleContainer> </ToggleContainer>
<Description> <Description>
Will randomize between all players at when starting a game, On start or reset of game, will pick a random starting player,
pressing the screen aborts the interval and chooses the player according to the <b>Pre-Start mode</b>
that has the crown.
</Description> </Description>
</SettingContainer> </SettingContainer>
<SettingContainer>
<div className="flex flex-row justify-between items-center mb-1">
<label htmlFor="pre-start-modes">Player selection style</label>
<select
name="pre-start-modes"
id="pre-start-modes"
value={settings.preStartMode}
className="bg-primary-main border-none outline-none text-text-primary rounded-md p-1 text-xs disabled:bg-primary-dark"
onChange={(e) => {
setSettings({
...settings,
preStartMode: e.target.value as PreStartMode,
});
}}
disabled={!settings.showStartingPlayer}
>
<option value={PreStartMode.None}>Instant</option>
<option value={PreStartMode.RandomKing}>Royal Shuffle</option>
<option value={PreStartMode.FingerGame}>
Touch Roulette
</option>
<option value={PreStartMode.Trivia}>Group Trivia</option>
</select>
</div>
<div className="text-xs text-left text-text-secondary">
Different ways to determine the starting player before the game
starts.
</div>
{settings.preStartMode === PreStartMode.None && (
<div className="text-xs text-left text-text-secondary mt-1">
<span className="text-text-primary">Instant:</span> A random
starting player will simply be shown on start.
</div>
)}
{settings.preStartMode === PreStartMode.RandomKing && (
<div className="text-xs text-left text-text-secondary mt-1">
<span className="text-text-primary">Royal Shuffle:</span>{' '}
Randomly pass a crown between all players, press the screen to
stop it. The player who has the crown when it stops gets to
start.
</div>
)}
{settings.preStartMode === PreStartMode.FingerGame && (
<div className="text-xs text-left text-text-secondary mt-1">
<span className="text-text-primary">Touch Roulette:</span> All
players put a finger on the screen, one will be chosen at
random.
</div>
)}
{settings.preStartMode === PreStartMode.Trivia && (
<div className="text-xs text-left text-text-secondary mt-1">
<span className="text-text-primary">Group Trivia:</span> A
random "who is the most ..." type question will be shown, the
group decides which player fits the question best.
</div>
)}
</SettingContainer>
<SettingContainer> <SettingContainer>
<ToggleContainer> <ToggleContainer>
<FormLabel>Keep Awake</FormLabel> <label>Keep Awake</label>
<Switch <Switch
checked={settings.keepAwake} checked={settings.keepAwake}
onChange={() => { onChange={() => {
@@ -161,7 +245,10 @@ export const SettingsModal = ({ isOpen, closeModal }: SettingsModalProps) => {
</SettingContainer> </SettingContainer>
<SettingContainer> <SettingContainer>
<ToggleContainer> <ToggleContainer>
<FormLabel>Go fullscreen on start (Android only)</FormLabel> <label>
Fullscreen on start{' '}
<span className="text-xs">(Android only)</span>
</label>
<Switch <Switch
checked={settings.goFullscreenOnStart} checked={settings.goFullscreenOnStart}
onChange={() => { onChange={() => {
@@ -177,6 +264,16 @@ export const SettingsModal = ({ isOpen, closeModal }: SettingsModalProps) => {
enabled. enabled.
</Description> </Description>
</SettingContainer> </SettingContainer>
<Separator height="1px" />
<button
className="flex justify-center self-center items-center mt-1 mb-1 bg-primary-main px-3 py-1 rounded-md"
onClick={() => {
analytics.trackEvent('settings_save_clicked');
closeModal();
}}
>
<span className="text-sm">Save and Close</span>
</button>
{!isPWA && ( {!isPWA && (
<> <>
<Separator height="1px" /> <Separator height="1px" />
@@ -197,39 +294,6 @@ export const SettingsModal = ({ isOpen, closeModal }: SettingsModalProps) => {
</> </>
)} )}
<Separator height="1px" /> <Separator height="1px" />
<SettingContainer>
<Paragraph>
{/* @ts-expect-error is defined in vite.config.ts*/}
Current version: {APP_VERSION}{' '}
{isLatestVersion && (
<span className="text-sm text-text-secondary">(latest)</span>
)}
</Paragraph>
{!isLatestVersion && newVersion && (
<Paragraph className="text-text-secondary text-lg text-center">
New version ({newVersion}) is available!{' '}
</Paragraph>
)}
</SettingContainer>
{!isLatestVersion && newVersion && (
<Button
variant="contained"
style={{ marginTop: '0.25rem', marginBottom: '0.25rem' }}
onClick={() => window?.location?.reload()}
>
<span>Update</span>
<span className="text-xs">&nbsp;(reload app)</span>
</Button>
)}
<Separator height="1px" />
<Button
variant="contained"
onClick={closeModal}
style={{ marginTop: '0.25rem' }}
>
Save and Close
</Button>
</Container> </Container>
</ModalWrapper> </ModalWrapper>
</> </>

View File

@@ -1,4 +1,4 @@
import { Button, Checkbox } from '@mui/material'; import { Checkbox } from '@mui/material';
import { useRef } from 'react'; import { useRef } from 'react';
import { twc } from 'react-twc'; import { twc } from 'react-twc';
import { theme } from '../../Data/theme'; import { theme } from '../../Data/theme';
@@ -18,8 +18,7 @@ import {
} from '../../Icons/generated'; } from '../../Icons/generated';
import { Player, Rotation } from '../../Types/Player'; import { Player, Rotation } from '../../Types/Player';
import { RotationDivProps } from '../Buttons/CommanderDamage'; import { RotationDivProps } from '../Buttons/CommanderDamage';
import { useAnalytics } from '../../Hooks/useAnalytics';
const CheckboxContainer = twc.div``;
const PlayerMenuWrapper = twc.div` const PlayerMenuWrapper = twc.div`
flex flex
@@ -51,7 +50,6 @@ const TogglesSection = twc.div`
flex-row flex-row
flex-wrap flex-wrap
relative relative
gap-2
h-full h-full
justify-evenly justify-evenly
items-center items-center
@@ -60,11 +58,11 @@ const TogglesSection = twc.div`
const ButtonsSections = twc.div` const ButtonsSections = twc.div`
flex flex
max-w-full max-w-full
gap-4 justify-evenly
justify-between
p-[3%]
items-center items-center
flex-wrap flex-wrap
mt-0
px-2
`; `;
const ColorPickerButton = twc.div` const ColorPickerButton = twc.div`
@@ -79,7 +77,7 @@ const ColorPickerButton = twc.div`
`; `;
const SettingsContainer = twc.div<RotationDivProps>((props) => [ const SettingsContainer = twc.div<RotationDivProps>((props) => [
'flex flex-wrap h-full w-full', 'flex flex-wrap h-full w-full overflow-y-scroll',
props.$rotation === Rotation.SideFlipped || props.$rotation === Rotation.Side props.$rotation === Rotation.SideFlipped || props.$rotation === Rotation.Side
? 'flex-col' ? 'flex-col'
: 'flex-row', : 'flex-row',
@@ -98,6 +96,7 @@ const PlayerMenu = ({
}: PlayerMenuProps) => { }: PlayerMenuProps) => {
const settingsContainerRef = useRef<HTMLDivElement | null>(null); const settingsContainerRef = useRef<HTMLDivElement | null>(null);
const resetGameDialogRef = useRef<HTMLDialogElement | null>(null); const resetGameDialogRef = useRef<HTMLDialogElement | null>(null);
const endGameDialogRef = useRef<HTMLDialogElement | null>(null);
const { isSide } = useSafeRotate({ const { isSide } = useSafeRotate({
rotation: player.settings.rotation, rotation: player.settings.rotation,
@@ -110,8 +109,11 @@ const PlayerMenu = ({
goToStart, goToStart,
settings, settings,
setPlaying, setPlaying,
setStopPlayerRandomization, setRandomizingPlayer,
} = useGlobalSettings(); } = useGlobalSettings();
const analytics = useAnalytics();
const { updatePlayer, resetCurrentGame } = usePlayers(); const { updatePlayer, resetCurrentGame } = usePlayers();
const handleColorChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleColorChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -130,12 +132,13 @@ const PlayerMenu = ({
resetCurrentGame(); resetCurrentGame();
setShowPlayerMenu(false); setShowPlayerMenu(false);
setPlaying(false); setPlaying(false);
setStopPlayerRandomization(false); setRandomizingPlayer(true);
analytics.trackEvent('reset_game');
}; };
const handleGoToStart = () => { const handleGoToStart = () => {
goToStart(); goToStart();
setStopPlayerRandomization(false); setRandomizingPlayer(true);
}; };
const toggleFullscreen = () => { const toggleFullscreen = () => {
@@ -173,14 +176,16 @@ const PlayerMenu = ({
}} }}
ref={settingsContainerRef} ref={settingsContainerRef}
> >
{settings.showPlayerMenuCog && ( <button
<button onClick={() => {
onClick={() => setShowPlayerMenu(false)} analytics.trackEvent('close_player_menu_button');
className="flex absolute top-0 right-2 z-10 w-8 h-8 bg-transparent items-center justify-center rounded-full border-solid border-primary-main border-2" setShowPlayerMenu(false);
> }}
<Cross size="16px" className="text-primary-main " /> className="flex absolute top-0 right-2 z-10 bg-transparent items-center justify-center rounded-full border-solid border-primary-main border-2 p-[0.2rem]"
</button> >
)} <Cross size={buttonFontSize} className="text-primary-main " />
</button>
<BetterRowContainer> <BetterRowContainer>
<TogglesSection> <TogglesSection>
<ColorPickerButton aria-label="Color picker"> <ColorPickerButton aria-label="Color picker">
@@ -189,10 +194,15 @@ const PlayerMenu = ({
type="color" type="color"
className="size-[200%] absolute -left-2 -top-2" className="size-[200%] absolute -left-2 -top-2"
value={player.color} value={player.color}
onClick={() => {
analytics.trackEvent('color_picker_opened', {
player: player.index,
});
}}
/> />
</ColorPickerButton> </ColorPickerButton>
{player.settings.useCommanderDamage && ( {player.settings.useCommanderDamage && (
<CheckboxContainer> <div>
<Checkbox <Checkbox
name="usePartner" name="usePartner"
checked={player.settings.usePartner} checked={player.settings.usePartner}
@@ -212,14 +222,19 @@ const PlayerMenu = ({
strokeWidth="30" strokeWidth="30"
/> />
} }
onChange={handleSettingsChange} onChange={(e) => {
analytics.trackEvent('toggle_partner', {
checked: e.target.checked,
});
handleSettingsChange(e);
}}
role="checkbox" role="checkbox"
aria-checked={player.settings.usePartner} aria-checked={player.settings.usePartner}
aria-label="Partner" aria-label="Partner"
/> />
</CheckboxContainer> </div>
)} )}
<CheckboxContainer> <div>
<Checkbox <Checkbox
name="usePoison" name="usePoison"
checked={player.settings.usePoison} checked={player.settings.usePoison}
@@ -239,13 +254,18 @@ const PlayerMenu = ({
strokeWidth="30" strokeWidth="30"
/> />
} }
onChange={handleSettingsChange} onChange={(e) => {
analytics.trackEvent('toggle_poison', {
checked: e.target.checked,
});
handleSettingsChange(e);
}}
role="checkbox" role="checkbox"
aria-checked={player.settings.usePoison} aria-checked={player.settings.usePoison}
aria-label="Poison" aria-label="Poison"
/> />
</CheckboxContainer> </div>
<CheckboxContainer> <div>
<Checkbox <Checkbox
name="useEnergy" name="useEnergy"
checked={player.settings.useEnergy} checked={player.settings.useEnergy}
@@ -265,13 +285,18 @@ const PlayerMenu = ({
strokeWidth="15" strokeWidth="15"
/> />
} }
onChange={handleSettingsChange} onChange={(e) => {
analytics.trackEvent('toggle_energy', {
checked: e.target.checked,
});
handleSettingsChange(e);
}}
role="checkbox" role="checkbox"
aria-checked={player.settings.useEnergy} aria-checked={player.settings.useEnergy}
aria-label="Energy" aria-label="Energy"
/> />
</CheckboxContainer> </div>
<CheckboxContainer> <div>
<Checkbox <Checkbox
name="useExperience" name="useExperience"
checked={player.settings.useExperience} checked={player.settings.useExperience}
@@ -291,26 +316,32 @@ const PlayerMenu = ({
strokeWidth="15" strokeWidth="15"
/> />
} }
onChange={handleSettingsChange} onChange={(e) => {
analytics.trackEvent('toggle_experience', {
checked: e.target.checked,
});
handleSettingsChange(e);
}}
role="checkbox" role="checkbox"
aria-checked={player.settings.useExperience} aria-checked={player.settings.useExperience}
aria-label="Experience" aria-label="Experience"
/> />
</CheckboxContainer> </div>
</TogglesSection> </TogglesSection>
<ButtonsSections className="mt-4"> <ButtonsSections>
<Button <button
variant="text" className="text-primary-main cursor-pointer webkit-user-select-none"
style={{ onClick={() => endGameDialogRef.current?.show()}
cursor: 'pointer',
userSelect: 'none',
}}
onClick={handleGoToStart}
aria-label="Back to start" aria-label="Back to start"
> >
<Exit size={iconSize} style={{ rotate: '180deg' }} /> <Exit size={iconSize} style={{ rotate: '180deg' }} />
</Button> </button>
<CheckboxContainer> <div
data-fullscreen={document.fullscreenElement ? true : false}
className="flex
data-[fullscreen=true]:bg-secondary-dark rounded-lg border border-transparent
data-[fullscreen=true]:border-primary-main"
>
<Checkbox <Checkbox
name="fullscreen" name="fullscreen"
checked={document.fullscreenElement ? true : false} checked={document.fullscreenElement ? true : false}
@@ -325,65 +356,113 @@ const PlayerMenu = ({
role="checkbox" role="checkbox"
aria-checked={document.fullscreenElement ? true : false} aria-checked={document.fullscreenElement ? true : false}
aria-label="Fullscreen" aria-label="Fullscreen"
style={{ padding: '4px' }}
/> />
</CheckboxContainer> </div>
<Button <button
variant={wakeLock.active ? 'contained' : 'outlined'} data-wake-lock-active={settings.keepAwake}
style={{ style={{
cursor: 'pointer',
userSelect: 'none',
fontSize: buttonFontSize, fontSize: buttonFontSize,
padding: '0 4px 0 4px',
}} }}
onClick={wakeLock.toggleWakeLock} className="text-primary-main px-1 webkit-user-select-none cursor-pointer
data-[wake-lock-active=true]:bg-secondary-dark rounded-lg border border-transparent
data-[wake-lock-active=true]:border-primary-main
"
onClick={() => {
wakeLock.toggleWakeLock();
}}
role="checkbox" role="checkbox"
aria-checked={wakeLock.active} aria-checked={settings.keepAwake}
aria-label="Keep awake" aria-label="Keep awake"
> >
Keep Awake Keep Awake
</Button> </button>
<Button <button
style={{ style={{
cursor: 'pointer', cursor: 'pointer',
userSelect: 'none', userSelect: 'none',
fontSize: buttonFontSize, fontSize: buttonFontSize,
padding: '4px', padding: '2px',
}} }}
className="text-primary-main"
onClick={() => resetGameDialogRef.current?.show()} onClick={() => resetGameDialogRef.current?.show()}
role="checkbox" role="checkbox"
aria-checked={wakeLock.active}
aria-label="Reset Game" aria-label="Reset Game"
> >
<ResetGame size={iconSize} /> <ResetGame size={iconSize} />
</Button> </button>
</ButtonsSections> </ButtonsSections>
</BetterRowContainer> </BetterRowContainer>
<dialog <dialog
ref={resetGameDialogRef} ref={resetGameDialogRef}
className="z-[999] size-full bg-background-settings" className="z-[999] size-full bg-background-settings overflow-y-scroll"
onClick={() => resetGameDialogRef.current?.close()} onClick={() => resetGameDialogRef.current?.close()}
> >
<div className="flex size-full items-center justify-center"> <div className="flex size-full items-center justify-center">
<div className="flex flex-col justify-center p-4 gap-2 bg-background-default rounded-2xl border-none"> <div className="flex flex-col justify-center p-4 gap-2 bg-background-default rounded-xl border-none">
<h1 className="text-center text-text-primary">Reset Game?</h1> <h1
<div className="flex justify-evenly gap-4"> className="text-center text-text-primary"
<Button style={{ fontSize: extraCountersSize }}
variant="contained" >
Reset Game?
</h1>
<div className="flex justify-evenly gap-2">
<button
className="bg-primary-main border border-primary-dark text-text-primary rounded-lg flex-grow"
style={{ fontSize: iconSize }}
onClick={() => resetGameDialogRef.current?.close()} onClick={() => resetGameDialogRef.current?.close()}
> >
No No
</Button> </button>
<Button <button
variant="contained" className="bg-primary-main border border-primary-dark text-text-primary rounded-lg flex-grow"
onClick={() => { onClick={() => {
handleResetGame(); handleResetGame();
resetGameDialogRef.current?.close(); resetGameDialogRef.current?.close();
}} }}
style={{ fontSize: iconSize }}
> >
Yes Yes
</Button> </button>
</div>
</div>
</div>
</dialog>
<dialog
ref={endGameDialogRef}
className="z-[999] size-full bg-background-settings overflow-y-scroll"
onClick={() => endGameDialogRef.current?.close()}
>
<div className="flex size-full items-center justify-center">
<div className="flex flex-col justify-center p-4 gap-2 bg-background-default rounded-xl border-none">
<h1
className="text-center text-text-primary"
style={{ fontSize: extraCountersSize }}
>
End Game?
</h1>
<div className="flex justify-evenly gap-2">
<button
className="bg-primary-main border border-primary-dark text-text-primary rounded-lg flex-grow"
style={{ fontSize: iconSize }}
onClick={() => endGameDialogRef.current?.close()}
>
No
</button>
<button
className="bg-primary-main border border-primary-dark text-text-primary rounded-lg flex-grow"
onClick={() => {
handleGoToStart();
endGameDialogRef.current?.close();
}}
style={{ fontSize: iconSize }}
>
Yes
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,8 @@
import { useEffect, useRef } from 'react';
import { twc } from 'react-twc'; import { twc } from 'react-twc';
import { useGlobalSettings } from '../../Hooks/useGlobalSettings';
import { usePlayers } from '../../Hooks/usePlayers'; import { usePlayers } from '../../Hooks/usePlayers';
import { Player as PlayerType } from '../../Types/Player'; import { Player as PlayerType } from '../../Types/Player';
import LifeCounter from '../LifeCounter/LifeCounter'; import LifeCounter from '../LifeCounter/LifeCounter';
import { GridLayout } from '../Views/Play';
const getGridArea = (player: PlayerType) => { const getGridArea = (player: PlayerType) => {
switch (player.index) { switch (player.index) {
@@ -26,104 +25,14 @@ const getGridArea = (player: PlayerType) => {
const PlayersWrapper = twc.div`w-full h-full bg-black`; const PlayersWrapper = twc.div`w-full h-full bg-black`;
export const Players = (players: PlayerType[], gridClasses: string) => { export const Players = ({ gridLayout }: { gridLayout: GridLayout }) => {
const randomIntervalRef = useRef<NodeJS.Timeout | null>(null); const { players } = usePlayers();
const prevRandomIndexRef = useRef<number>(-1);
const {
settings,
stopPlayerRandomization,
setStopPlayerRandomization,
playing,
} = useGlobalSettings();
const { setPlayers } = usePlayers();
useEffect(() => {
if (
settings.useRandomStartingPlayerInterval &&
!stopPlayerRandomization &&
!playing
) {
randomIntervalRef.current = setInterval(() => {
let randomIndex: number;
do {
randomIndex = Math.floor(Math.random() * players.length);
} while (randomIndex === prevRandomIndexRef.current);
prevRandomIndexRef.current = randomIndex;
setPlayers(
players.map((p) =>
p.index === prevRandomIndexRef.current
? {
...p,
isStartingPlayer: true,
}
: {
...p,
isStartingPlayer: false,
}
)
);
}, 100);
}
if (!settings.useRandomStartingPlayerInterval) {
const randomPlayerIndex = Math.floor(Math.random() * players.length);
setPlayers(
players.map((p) =>
p.index === randomPlayerIndex
? {
...p,
isStartingPlayer: true,
}
: {
...p,
isStartingPlayer: false,
}
)
);
}
return () => {
if (randomIntervalRef.current) {
clearInterval(randomIntervalRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
players.length,
playing,
setPlayers,
settings.useRandomStartingPlayerInterval,
stopPlayerRandomization,
]);
return ( return (
<PlayersWrapper> <PlayersWrapper>
{settings.useRandomStartingPlayerInterval && <div className={`grid w-full h-full gap-1 box-border ${gridLayout} `}>
!stopPlayerRandomization &&
!playing && (
<div
className="absolute flex justify-center items-center bg-black bg-opacity-40 h-screen w-screen portrait:h-[100vw] portrait:w-[100vh] z-50 cursor-pointer text-5xl"
onClick={() => {
if (randomIntervalRef.current) {
clearInterval(randomIntervalRef.current);
randomIntervalRef.current = null;
}
setStopPlayerRandomization(true);
}}
>
<div className="bg-primary-main px-8 py-2 rounded-2xl opacity-70 text-[5vmax]">
PRESS TO SELECT PLAYER
</div>
</div>
)}
<div className={`grid w-full h-full gap-1 box-border ${gridClasses} `}>
{players.map((player) => { {players.map((player) => {
const gridArea = getGridArea(player); const gridArea = getGridArea(player);
return ( return (
<div <div
key={player.index} key={player.index}

View File

@@ -0,0 +1,198 @@
import { useEffect, useRef, useState } from 'react';
import { useGlobalSettings } from '../../../Hooks/useGlobalSettings';
import { usePlayers } from '../../../Hooks/usePlayers';
type TouchPoint = {
x: number;
y: number;
id: number;
};
const getOrientation = () => {
return window.matchMedia('(orientation: portrait)').matches
? 'portrait'
: 'landscape';
};
export const FingerGame = () => {
const { players } = usePlayers();
const aboutToStartTimerRef = useRef<NodeJS.Timeout | null>(null);
const selectingPlayerTimerRef = useRef<NodeJS.Timeout | null>(null);
const [touchPoints, setTouchPoints] = useState<TouchPoint[]>([]);
const [selectedTouchPoint, setSelectedTouchPoint] = useState<
TouchPoint | undefined
>();
const [timerStarted, setTimerStarted] = useState(false);
const { setPlaying, goToStart } = useGlobalSettings();
useEffect(() => {
//Start playing when someone is selected and any touch point is released
if (selectedTouchPoint && touchPoints.length !== players.length) {
aboutToStartTimerRef.current = setTimeout(() => {
setSelectedTouchPoint(undefined);
setPlaying(true);
}, 500);
setTimerStarted(true);
return;
}
// If no touch point is selected, select one with a delay
if (touchPoints.length === players.length && !selectedTouchPoint) {
selectingPlayerTimerRef.current = setTimeout(() => {
const randomIndex = Math.floor(Math.random() * touchPoints.length);
const randomTouchPoint = touchPoints[randomIndex];
setSelectedTouchPoint(randomTouchPoint);
}, 250);
return;
}
if (selectingPlayerTimerRef.current) {
clearTimeout(selectingPlayerTimerRef.current);
}
return () => {
if (aboutToStartTimerRef.current) {
clearTimeout(aboutToStartTimerRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [touchPoints, players.length]);
const handleOnTouchStart = (e: React.TouchEvent) => {
if (selectedTouchPoint) {
return;
}
//Get the first touch point id that isn't already in the touchPoints array
const touch = Array.from(e.changedTouches).find(
(t) => !touchPoints.find((p) => p.id === t.identifier)
);
if (!touch) {
console.error('No touch found');
return;
}
let { clientX, clientY } = touch;
// Adjust coordinates for portrait mode
if (getOrientation() === 'portrait') {
const tempX = clientX;
clientX = clientY;
clientY = window.innerWidth - tempX;
}
const newTouchPoints = {
x: clientX,
y: clientY,
id: touch.identifier,
};
setTouchPoints([...touchPoints, newTouchPoints]);
};
const handleOnTouchEnd = (e: React.TouchEvent) => {
if (selectedTouchPoint) {
aboutToStartTimerRef.current = setTimeout(() => {
setSelectedTouchPoint(undefined);
setPlaying(true);
}, 500);
setTimerStarted(true);
return;
}
// Get the touch point that was just released
const touch = e.changedTouches[e.changedTouches.length - 1];
// Get the index of the touch point that was just released
const index = touchPoints.findIndex((p) => p.id === touch.identifier);
// Remove the touch point that was just released
setTouchPoints([
...touchPoints.slice(0, index),
...touchPoints.slice(index + 1),
]);
};
return (
<div
className="absolute flex justify-center items-center w-full h-full portrait:h-[100dvw] portrait:w-[100dvh] z-50 bg-secondary-main overflow-hidden"
onTouchStart={handleOnTouchStart}
onTouchEnd={handleOnTouchEnd}
// FIXEME: This code is not performant, but updates a touch point's position when it moves
// onTouchMove={(e) => {
// e.preventDefault();
// // Get the touch point that was just moved
// const touch = Array.from(e.changedTouches).find((t) =>
// touchPoints.find((p) => p.id === t.identifier)
// );
// if (!touch) {
// console.error('No touch found');
// return;
// }
// let { clientX, clientY } = touch;
// // Adjust coordinates for portrait mode
// if (getOrientation() === 'portrait') {
// const tempX = clientX;
// clientX = clientY;
// clientY = window.innerWidth - tempX;
// }
// // Get the index of the touch point that was just moved
// const index = touchPoints.findIndex(
// (p) => p.id === touch.identifier
// );
// // Update the touch point that was just moved
// setTouchPoints([
// ...touchPoints.slice(0, index),
// { x: clientX, y: clientY, id: touch.identifier },
// ...touchPoints.slice(index + 1),
// ]);
// }}
>
<button
className="absolute flex top-4 left-4 rounded-lg px-2 py-1 justify-center bg-primary-main text-text-primary text-xs"
onClick={goToStart}
>
<div className="text-xl leading-4">{'<'}&nbsp;</div>
Back
</button>
{touchPoints.length !== players.length && (
<div className="flex flex-col items-center text-[13vmin] whitespace-nowrap pointer-events-none webkit-user-select-none">
Waiting for fingers <br />
<div className="tabular-nums">
{touchPoints.length}/{players.length}
</div>
</div>
)}
{touchPoints.map((point, index) => (
<div
key={`touch-point-${index}`}
data-is-selected={selectedTouchPoint?.id === point.id}
data-unloading={timerStarted}
className="absolute rounded-full translate-x-[-50%] translate-y-[-50%] transition-all duration-1000
h-[75px] w-[75px]
data-[unloading=false]:data-[is-selected=true]:h-[250px] data-[unloading=false]:data-[is-selected=true]:w-[250px]
data-[unloading=true]:h-[0px] data-[unloading=true]:w-[0px] data-[unloading=true]:duration-[400ms]
pointer-events-none
"
style={{
left: point.x,
top: point.y,
backgroundColor: players[index]?.color ?? 'red',
}}
/>
))}
</div>
);
};

View File

@@ -0,0 +1,49 @@
import { usePlayers } from '../../../../Hooks/usePlayers';
import { Player } from '../../../../Types/Player';
import { GridLayout } from '../../../Views/Play';
import { RoulettePlayerCard } from './RoulettePlayerCard';
const getGridArea = (player: Player) => {
switch (player.index) {
case 0:
return 'grid-in-player0';
case 1:
return 'grid-in-player1';
case 2:
return 'grid-in-player2';
case 3:
return 'grid-in-player3';
case 4:
return 'grid-in-player4';
case 5:
return 'grid-in-player5';
default:
throw new Error('Invalid player index');
}
};
export const RandomKingPlayers = ({
gridLayout,
}: {
gridLayout: GridLayout;
}) => {
const { players } = usePlayers();
return (
<div className="w-full h-full bg-black">
<div className={`grid w-full h-full gap-1 box-border ${gridLayout} `}>
{players.map((player) => {
const gridArea = getGridArea(player);
return (
<div
key={player.index}
className={`flex justify-center items-center align-middle ${gridArea}`}
>
<RoulettePlayerCard player={player} />
</div>
);
})}
</div>
</div>
);
};

View File

@@ -0,0 +1,94 @@
import { useEffect, useRef } from 'react';
import { useGlobalSettings } from '../../../../Hooks/useGlobalSettings';
import { usePlayers } from '../../../../Hooks/usePlayers';
export const RandomKingSelectWrapper = () => {
const { setRandomizingPlayer } = useGlobalSettings();
const randomIntervalRef = useRef<NodeJS.Timeout | null>(null);
const prevRandomIndexRef = useRef<number>(-1);
const { settings, randomizingPlayer, setPreStartCompleted } =
useGlobalSettings();
const { players, setPlayers } = usePlayers();
useEffect(() => {
if (
players.length > 1 &&
settings.showStartingPlayer &&
randomizingPlayer
) {
randomIntervalRef.current = setInterval(() => {
let randomIndex: number;
do {
randomIndex = Math.floor(Math.random() * players.length);
} while (randomIndex === prevRandomIndexRef.current);
prevRandomIndexRef.current = randomIndex;
setPlayers(
players.map((p) =>
p.index === prevRandomIndexRef.current
? {
...p,
isStartingPlayer: true,
}
: {
...p,
isStartingPlayer: false,
}
)
);
}, 100);
}
const randomPlayerIndex = Math.floor(Math.random() * players.length);
setPlayers(
players.map((p) =>
p.index === randomPlayerIndex
? {
...p,
isStartingPlayer: true,
}
: {
...p,
isStartingPlayer: false,
}
)
);
return () => {
if (randomIntervalRef.current) {
clearInterval(randomIntervalRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [players.length, setPlayers, randomizingPlayer]);
const gradientColors = players.map((player) => player.color).join(', ');
return (
<div
className="absolute flex justify-center items-center h-screen w-screen portrait:h-[100vw] portrait:w-[100vh] z-40 cursor-pointer text-5xl"
onClick={() => {
if (randomIntervalRef.current) {
clearInterval(randomIntervalRef.current);
randomIntervalRef.current = null;
}
setRandomizingPlayer(false);
setPreStartCompleted(true);
}}
>
<div className="absolute flex top-[30%] justify-center items-center px-8 py-4">
<div
className="absolute size-full blur-[3px] rounded-2xl opacity-90 saturate-150"
style={{
backgroundImage: `linear-gradient(60deg, ${gradientColors})`,
}}
/>
<p className="relative z-10 text-[5vmax]">PRESS TO SELECT PLAYER</p>
</div>
</div>
);
};

View File

@@ -0,0 +1,58 @@
import { useEffect, useRef } from 'react';
import { useGlobalSettings } from '../../../../Hooks/useGlobalSettings';
import { Player, Rotation } from '../../../../Types/Player';
import { Paragraph } from '../../../Misc/TextComponents';
import { DynamicText } from '../../StartingPlayerCard';
export const RoulettePlayerCard = ({ player }: { player: Player }) => {
const startPlayingTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const { settings, randomizingPlayer, playing, setPlaying } =
useGlobalSettings();
useEffect(() => {
if (
player.isStartingPlayer &&
((!playing && randomizingPlayer) || !playing)
) {
startPlayingTimerRef.current = setTimeout(() => {
setPlaying(true);
}, 10_000);
}
return () => clearTimeout(startPlayingTimerRef.current);
}, [
player.isStartingPlayer,
playing,
setPlaying,
settings.preStartMode,
randomizingPlayer,
]);
const calcTextRotation =
player.settings.rotation === Rotation.SideFlipped ||
player.settings.rotation === Rotation.Side
? player.settings.rotation - 180
: player.settings.rotation;
return (
<div className="relative flex flex-grow flex-col items-center w-full h-full overflow-hidden">
<div
className="flex absolute w-full h-full justify-center items-center pointer-events-none select-none webkit-user-select-none z-10"
style={{ backgroundColor: player.color }}
>
{player.isStartingPlayer && (
<DynamicText
style={{
rotate: `${calcTextRotation}deg`,
}}
>
<div className="flex flex-col justify-center items-center">
<Paragraph>👑</Paragraph>
</div>
</DynamicText>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,160 @@
import { useState } from 'react';
import { useGlobalSettings } from '../../../Hooks/useGlobalSettings';
const questions = [
'Who has the most siblings?',
'Who has the most pets?',
'Who has the most tattoos?',
'Who has the most piercings?',
'Who has the most expensive shoes?',
'Who has the most most amount of teeth?',
'Who has the most least amount of teeth?',
'Who has the most least amount of teeth?',
'Who lives closest to the equator?',
'Who is the tallest person in the group?',
'Who is the shortest person in the group?',
'Who speaks the most languages?',
'Who has traveled to the most countries?',
'Who has the earliest birthday in the year?',
'Who has won the most awards or trophies?',
'Who is the best cook among you?',
'Who is the fastest runner?',
'Who has the most unique hobby?',
'Who is the biggest movie buff?',
'Who is the most tech-savvy?',
'Who is the best at solving puzzles?',
'Who has the most extensive music collection?',
'Who has the most impressive collection of books?',
'Who has the most experience in a particular sport or activity?',
'Who has the most interesting job or profession?',
'Who has the most artistic talent?',
'Who is the most organized person?',
'Who is the best at keeping secrets?',
'Who has the most fascinating family history?',
'Who has the most embarrassing childhood nickname?',
'Who has the most unusual talent or skill?',
'Who has the most interesting family tradition?',
'Who has the most impressive celebrity encounter?',
'Who has the most unusual phobia?',
'Who has the most adventurous spirit?',
'Who has the most unique item in their wallet/purse?',
'Who has the most daring fashion sense?',
'Who has the most impressive party trick?',
'Who has the most memorable encounter with a wild animal?',
'Who has the most adventurous palate?',
'Who has the most unusual collection?',
'Who has the most unique bucket list item?',
'Who has the most inspiring life motto or mantra?',
'Who is the most likely to break out into song or dance in public?',
'Who is the most likely to be found binge-watching TV shows?',
'Who is the biggest procrastinator?',
'Who is the most likely to cry during a movie?',
'Who is the most adventurous when it comes to trying new foods?',
"Who is the most likely to forget someone's birthday?",
'Who is the best at giving advice?',
'Who is the worst at giving advice?',
'Who is the most likely to be found reading a book at a party?',
'Who is the most likely to win in a game of charades?',
'Who is the most likely to get lost in their own neighborhood?',
'Who is the most sentimental?',
'Who is the most likely to become famous?',
'Who is the most likely to become a millionaire?',
'Who is the most likely to start their own business?',
'Who is the most likely to become president?',
'Who is the most likely to go viral on social media?',
'Who is the most likely to win a Nobel Prize?',
'Who is the most likely to be a superhero in disguise?',
'Who is the most likely to survive a zombie apocalypse?',
'Who is the most likely to believe in aliens?',
'Who is the most likely to spend all their money on something silly?',
'Who is the most likely to write a bestselling novel?',
'Who is the most likely to be a secret agent?',
'Who is the most likely to be a professional athlete?',
'Who is the most likely to win a game of trivia?',
'Who is the most likely to win the upcoming game?',
'Who is the most likely to win at a game of Pokémon TCG?',
'Who has the most valuable card in their collection?',
'Who is the best at building decks?',
'Who has won the most games?',
'Who has the largest collection of cards?',
'Who is the most knowledgeable about Magic the Gathering lore?',
'Who is the most strategic?',
'Who is the most likely to trade away their most valuable card for something silly?',
'Who is the most competitive?',
'Who would be the most creative when it comes to making up new Magic the Gathering rules?',
'Who is the most likely to organize a Magic the Gathering draft tournament?',
'Who is the most enthusiastic about opening booster packs?',
'Who has the most unique and unusual Magic the Gathering deck?',
'Who is the most likely to cosplay as their favorite Magic the Gathering character?',
'Who is the most likely to forget to bring their Magic the Gathering deck to a game night?',
'Who is the most generous when it comes to lending out their decks?',
'Who is the most likely to start their own Magic the Gathering YouTube channel?',
'Who is the most skilled at bluffing during a game of Magic the Gathering?',
'Who is the most likely to spend all their money on Magic the Gathering cards?',
'Who is the most likely to rage quit during a game of Magic the Gathering?',
'Who is the most likely to win in a Magic the Gathering trivia contest?',
'Who is the most likely to build a themed Magic the Gathering deck?',
'Who is the most likely to organize a Magic the Gathering cube draft?',
'Who is the most likely to teach new players how to play Magic the Gathering?',
'Who is the most likely to build a commander deck with a ridiculous theme?',
'Who is the most likely to collect foreign-language Magic the Gathering cards?',
'Who is the most likely to participate in a Magic the Gathering charity event?',
'Who is the most likely to cosplay as their Magic the Gathering commander?',
'Who is the most likely to organize a Magic the Gathering charity tournament?',
];
export const Trivia = () => {
const { setPlaying, goToStart } = useGlobalSettings();
const [randomQuestion, setRandomQuestion] = useState(
questions[Math.floor(Math.random() * questions.length)]
);
const setUniqueRandomQuestion = () => {
let newRandomQuestion =
questions[Math.floor(Math.random() * questions.length)];
while (newRandomQuestion === randomQuestion) {
newRandomQuestion =
questions[Math.floor(Math.random() * questions.length)];
}
setRandomQuestion(newRandomQuestion);
};
return (
<div
className="absolute flex justify-center items-center w-full h-full portrait:h-[100dvw] portrait:w-[100dvh] z-50 bg-secondary-main overflow-hidden"
onClick={() => setPlaying(true)}
>
<button
className="absolute flex top-4 left-4 rounded-lg px-2 py-1 justify-center bg-primary-main text-text-primary text-xs"
onClick={goToStart}
>
<div className="text-xl leading-[0.80rem]">{'<'}&nbsp;</div>
Back
</button>
<button
className="absolute flex top-4 right-4 rounded-lg px-2 py-1 justify-center bg-primary-main text-text-primary text-xs"
onClick={(e) => {
e.stopPropagation();
setUniqueRandomQuestion();
}}
>
Reroll
</button>
<div className="size-full flex flex-col justify-between items-center whitespace-nowrap pointer-events-none webkit-user-select-none text-wrap text-center py-[10vmin] px-[10vmax]">
<div className="text-[6vmin]">Decide who starts by answering:</div>
<div className="flex flex-col">
<div className="text-[8vmin] rotate-180 text-text-primary opacity-40">
{randomQuestion}
</div>
<div className="text-[8vmin]">{randomQuestion}</div>
</div>
<div className="text-[6vmin]">(Tap the screen to dismiss)</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,35 @@
import { useGlobalSettings } from '../../Hooks/useGlobalSettings';
import { PreStartMode } from '../../Types/Settings';
import { GridLayout } from '../Views/Play';
import { FingerGame } from './Games/FingerGame';
import { RandomKingPlayers } from './Games/RandomKing/RandomKingPlayers';
import { RandomKingSelectWrapper } from './Games/RandomKing/RandomKingSelectWrapper';
import { Trivia } from './Games/Trivia';
export const PreStart = ({ gridLayout }: { gridLayout: GridLayout }) => {
const { settings, randomizingPlayer, goToStart } = useGlobalSettings();
if (settings.preStartMode === PreStartMode.RandomKing) {
if (!randomizingPlayer) {
return null;
}
return (
<>
<RandomKingSelectWrapper />
<RandomKingPlayers gridLayout={gridLayout} />
</>
);
}
if (settings.preStartMode === PreStartMode.FingerGame) {
return <FingerGame />;
}
if (settings.preStartMode === PreStartMode.Trivia) {
return <Trivia />;
}
goToStart();
return null;
};

View File

@@ -0,0 +1,60 @@
import { twc } from 'react-twc';
import { baseColors } from '../../../tailwind.config';
import { useGlobalSettings } from '../../Hooks/useGlobalSettings';
import { Player, Rotation } from '../../Types/Player';
import { PreStartMode } from '../../Types/Settings';
import { Paragraph } from '../Misc/TextComponents';
export const DynamicText = twc.div`text-[8vmin] whitespace-nowrap`;
export const StartingPlayerCard = ({ player }: { player: Player }) => {
const { settings, setPlaying, randomizingPlayer } = useGlobalSettings();
const calcTextRotation =
player.settings.rotation === Rotation.SideFlipped ||
player.settings.rotation === Rotation.Side
? player.settings.rotation - 180
: player.settings.rotation;
const calcRotation =
player.settings.rotation === Rotation.SideFlipped ||
player.settings.rotation === Rotation.Side
? player.settings.rotation - 90
: player.settings.rotation;
return (
<div
className="z-20 flex absolute w-full h-full justify-center items-center select-none cursor-pointer webkit-user-select-none"
style={{
rotate: `${calcRotation}deg`,
backgroundImage:
!randomizingPlayer ||
(settings.preStartMode !== PreStartMode.None &&
settings.preStartMode !== PreStartMode.FingerGame)
? `radial-gradient(circle at center, ${player.color}, ${baseColors.primary.main})`
: 'none',
}}
onClick={() => {
setPlaying(true);
}}
>
<DynamicText
style={{
rotate: `${calcTextRotation}deg`,
}}
>
<div className="flex flex-col justify-center items-center">
<Paragraph>👑</Paragraph>
{(!randomizingPlayer ||
(settings.preStartMode !== PreStartMode.None &&
settings.preStartMode !== PreStartMode.FingerGame)) && (
<>
<Paragraph>You start!</Paragraph>
<Paragraph className="text-xl">(Press to hide)</Paragraph>
</>
)}
</div>
</DynamicText>
</div>
);
};

View File

@@ -1,67 +1,115 @@
import { useEffect } from 'react';
import { twc } from 'react-twc';
import { twGridTemplateAreas } from '../../../tailwind.config';
import { useGlobalSettings } from '../../Hooks/useGlobalSettings'; import { useGlobalSettings } from '../../Hooks/useGlobalSettings';
import { usePlayers } from '../../Hooks/usePlayers'; import { usePlayers } from '../../Hooks/usePlayers';
import { Orientation } from '../../Types/Settings'; import { Orientation, PreStartMode } from '../../Types/Settings';
import { Players } from '../Players/Players'; import { Players } from '../Players/Players';
import { twc } from 'react-twc'; import { PreStart } from '../PreStartGame/PreStart';
const MainWrapper = twc.div`w-[100dvmax] h-[100dvmin] overflow-hidden`; const MainWrapper = twc.div`w-[100dvmax] h-[100dvmin] overflow-hidden, setPlayers`;
type GridTemplateAreasKeys = keyof typeof twGridTemplateAreas;
export type GridLayout = `grid-areas-${GridTemplateAreasKeys}`;
export const Play = () => { export const Play = () => {
const { players } = usePlayers(); const { players, setPlayers } = usePlayers();
const { initialGameSettings } = useGlobalSettings(); const { initialGameSettings, playing, settings, preStartCompleted } =
useGlobalSettings();
let Layout: JSX.Element; let gridLayout: GridLayout;
switch (players.length) { switch (players.length) {
case 1: case 1:
if (initialGameSettings?.orientation === Orientation.Portrait) { if (initialGameSettings?.orientation === Orientation.Portrait) {
Layout = Players(players, 'grid-areas-onePlayerPortrait'); gridLayout = 'grid-areas-onePlayerPortrait';
} }
Layout = Players(players, 'grid-areas-onePlayerLandscape'); gridLayout = 'grid-areas-onePlayerLandscape';
break; break;
case 2: case 2:
switch (initialGameSettings?.orientation) { switch (initialGameSettings?.orientation) {
case Orientation.Portrait: case Orientation.Portrait:
Layout = Players(players, 'grid-areas-twoPlayersOppositePortrait'); gridLayout = 'grid-areas-twoPlayersOppositePortrait';
break; break;
default: default:
case Orientation.Landscape: case Orientation.Landscape:
Layout = Players(players, 'grid-areas-twoPlayersSameSideLandscape'); gridLayout = 'grid-areas-twoPlayersSameSideLandscape';
break; break;
case Orientation.OppositeLandscape: case Orientation.OppositeLandscape:
Layout = Players(players, 'grid-areas-twoPlayersOppositeLandscape'); gridLayout = 'grid-areas-twoPlayersOppositeLandscape';
break; break;
} }
break; break;
case 3: case 3:
if (initialGameSettings?.orientation === Orientation.Portrait) { if (initialGameSettings?.orientation === Orientation.Portrait) {
Layout = Players(players, 'grid-areas-threePlayersSide'); gridLayout = 'grid-areas-threePlayersSide';
break; break;
} }
Layout = Players(players, 'grid-areas-threePlayers'); gridLayout = 'grid-areas-threePlayers';
break; break;
default: default:
case 4: case 4:
if (initialGameSettings?.orientation === Orientation.Portrait) { if (initialGameSettings?.orientation === Orientation.Portrait) {
Layout = Players(players, 'grid-areas-fourPlayerPortrait'); gridLayout = 'grid-areas-fourPlayerPortrait';
break; break;
} }
Layout = Players(players, 'grid-areas-fourPlayer'); gridLayout = 'grid-areas-fourPlayer';
break; break;
case 5: case 5:
if (initialGameSettings?.orientation === Orientation.Portrait) { if (initialGameSettings?.orientation === Orientation.Portrait) {
Layout = Players(players, 'grid-areas-fivePlayersSide'); gridLayout = 'grid-areas-fivePlayersSide';
break; break;
} }
Layout = Players(players, 'grid-areas-fivePlayers'); gridLayout = 'grid-areas-fivePlayers';
break; break;
case 6: case 6:
if (initialGameSettings?.orientation === Orientation.Portrait) { if (initialGameSettings?.orientation === Orientation.Portrait) {
Layout = Players(players, 'grid-areas-sixPlayersSide'); gridLayout = 'grid-areas-sixPlayersSide';
break; break;
} }
Layout = Players(players, 'grid-areas-sixPlayers'); gridLayout = 'grid-areas-sixPlayers';
break; break;
} }
return <MainWrapper>{Layout}</MainWrapper>; useEffect(() => {
if (settings.preStartMode !== PreStartMode.None) {
return;
}
const randomIndex = Math.floor(Math.random() * players.length);
setPlayers(
players.map((p) =>
p.index === randomIndex
? {
...p,
isStartingPlayer: true,
}
: {
...p,
isStartingPlayer: false,
}
)
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (
!preStartCompleted &&
settings.preStartMode !== PreStartMode.None &&
!playing &&
settings.showStartingPlayer
) {
return (
<MainWrapper>
<PreStart gridLayout={gridLayout} />
</MainWrapper>
);
}
return (
<MainWrapper>
<Players gridLayout={gridLayout} />
</MainWrapper>
);
}; };

View File

@@ -3,10 +3,12 @@ import React from 'react';
import { theme } from '../../../Data/theme'; import { theme } from '../../../Data/theme';
import { import {
FivePlayers, FivePlayers,
FivePlayersSide,
FourPlayers, FourPlayers,
FourPlayersSide, FourPlayersSide,
OnePlayerPortrait, OnePlayerPortrait,
SixPlayers, SixPlayers,
SixPlayersSide,
ThreePlayers, ThreePlayers,
ThreePlayersSide, ThreePlayersSide,
TwoPlayersOppositeLandscape, TwoPlayersOppositeLandscape,
@@ -40,7 +42,7 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
switch (numberOfPlayers) { switch (numberOfPlayers) {
case 1: case 1:
return ( return (
<div> <>
<FormControlLabel <FormControlLabel
value={Orientation.Landscape} value={Orientation.Landscape}
control={ control={
@@ -89,7 +91,7 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
} }
label="" label=""
/> />
</div> </>
); );
case 2: case 2:
return ( return (
@@ -303,20 +305,21 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
} }
label="" label=""
/> />
{/* <FormControlLabel <FormControlLabel
value={GridTemplateAreas.FivePlayersSide} value={Orientation.Portrait}
control={ control={
<Radio <Radio
style={{ maxWidth: iconMaxWidth, maxHeight: iconMaxHeight }}
icon={ icon={
<FivePlayersSide <FivePlayersSide
height={iconHeight} height={iconHeight}
width={iconWidth} width={iconWidth}
fill={theme.palette.secondary.main} fill={theme.palette.secondary.main}
/> />
} }
checkedIcon={ checkedIcon={
<FivePlayersSide <FivePlayersSide
height={iconHeight} height={iconHeight}
width={iconWidth} width={iconWidth}
fill={theme.palette.primary.main} fill={theme.palette.primary.main}
/> />
@@ -325,7 +328,7 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
/> />
} }
label="" label=""
/> */} />
</> </>
); );
@@ -356,20 +359,21 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
} }
label="" label=""
/> />
{/* <FormControlLabel <FormControlLabel
value={GridTemplateAreas.SixPlayersSide} value={Orientation.Portrait}
control={ control={
<Radio <Radio
style={{ maxWidth: iconMaxWidth, maxHeight: iconMaxHeight }}
icon={ icon={
<SixPlayersSide <SixPlayersSide
height={iconHeight} height={iconHeight}
width={iconWidth} width={iconWidth}
fill={theme.palette.secondary.main} fill={theme.palette.secondary.main}
/> />
} }
checkedIcon={ checkedIcon={
<SixPlayersSide <SixPlayersSide
height={iconHeight} height={iconHeight}
width={iconWidth} width={iconWidth}
fill={theme.palette.primary.main} fill={theme.palette.primary.main}
/> />
@@ -378,7 +382,7 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
/> />
} }
label="" label=""
/> */} />
</> </>
); );

View File

@@ -9,9 +9,10 @@ import { useGlobalSettings } from '../../../Hooks/useGlobalSettings';
import { usePlayers } from '../../../Hooks/usePlayers'; import { usePlayers } from '../../../Hooks/usePlayers';
import { Cog, Info } from '../../../Icons/generated'; import { Cog, Info } from '../../../Icons/generated';
import { import {
GameFormat,
InitialGameSettings, InitialGameSettings,
Orientation, Orientation,
PreStartMode,
defaultInitialGameSettings,
} from '../../../Types/Settings'; } from '../../../Types/Settings';
import { InfoModal } from '../../Misc/InfoModal'; import { InfoModal } from '../../Misc/InfoModal';
import { SettingsModal } from '../../Misc/SettingsModal'; import { SettingsModal } from '../../Misc/SettingsModal';
@@ -89,27 +90,50 @@ const Start = () => {
setInitialGameSettings, setInitialGameSettings,
settings, settings,
isPWA, isPWA,
setRandomizingPlayer,
version,
} = useGlobalSettings(); } = useGlobalSettings();
const [openInfoModal, setOpenInfoModal] = useState(false); const [openInfoModal, setOpenInfoModal] = useState(false);
const [openSettingsModal, setOpenSettingsModal] = useState(false); const [openSettingsModal, setOpenSettingsModal] = useState(false);
const [playerOptions, setPlayerOptions] = useState<InitialGameSettings>( const [playerOptions, setPlayerOptions] = useState<InitialGameSettings>(
initialGameSettings || { initialGameSettings || defaultInitialGameSettings
numberOfPlayers: 4,
startingLifeTotal: 40,
useCommanderDamage: true,
orientation: Orientation.Portrait,
gameFormat: GameFormat.Commander,
}
); );
let tracked = false;
// Check for new version on mount
useEffect(() => {
if (!tracked) {
console.log('checking version');
version.checkForNewVersion('start_menu');
// eslint-disable-next-line react-hooks/exhaustive-deps
tracked = true;
}
}, []);
useEffect(() => {
setInitialGameSettings(playerOptions);
}, [playerOptions, setInitialGameSettings]);
useEffect(() => {
setPlayerOptions({
...playerOptions,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playerOptions.numberOfPlayers]);
const doStartGame = () => { const doStartGame = () => {
if (!initialGameSettings) { if (!initialGameSettings) {
return; return;
} }
analytics.trackEvent('game_started', { ...initialGameSettings }); analytics.trackEvent('game_started', {
...initialGameSettings,
...settings,
isPWA,
});
try { try {
if (settings.goFullscreenOnStart) { if (settings.goFullscreenOnStart) {
@@ -126,25 +150,15 @@ const Start = () => {
setInitialGameSettings(initialGameSettings); setInitialGameSettings(initialGameSettings);
setPlayers(createInitialPlayers(initialGameSettings)); setPlayers(createInitialPlayers(initialGameSettings));
setShowPlay(true); setShowPlay(true);
setRandomizingPlayer(settings.preStartMode === PreStartMode.RandomKing);
localStorage.setItem('playing', 'false'); localStorage.setItem('playing', 'false');
localStorage.setItem('showPlay', 'true'); localStorage.setItem('showPlay', 'true');
}; };
useEffect(() => { const valueText = (value: number) => {
setInitialGameSettings(playerOptions);
}, [playerOptions, setInitialGameSettings]);
const valuetext = (value: number) => {
return `${value}`; return `${value}`;
}; };
useEffect(() => {
setPlayerOptions({
...playerOptions,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playerOptions.numberOfPlayers]);
return ( return (
<MainWrapper> <MainWrapper>
<Info <Info
@@ -172,8 +186,12 @@ const Start = () => {
<SupportMe /> <SupportMe />
<h1 className="text-3xl block font-bold mt-6 mb-5 text-text-primary"> <h1 className="relative flex flex-col text-3xl font-bold mt-6 mb-6 text-text-primary justify-center items-center">
Life Trinket Life Trinket
<div className="h-[1px] w-[120%] bg-common-white opacity-50" />
<div className="flex absolute text-xs font-medium -bottom-4">
v{version.installedVersion}
</div>
</h1> </h1>
<div className="overflow-hidden items-center flex flex-col max-w-[548px] w-full mb-8 px-4"> <div className="overflow-hidden items-center flex flex-col max-w-[548px] w-full mb-8 px-4">
@@ -186,7 +204,7 @@ const Start = () => {
min={1} min={1}
aria-label="Custom marks" aria-label="Custom marks"
value={playerOptions?.numberOfPlayers ?? 4} value={playerOptions?.numberOfPlayers ?? 4}
getAriaValueText={valuetext} getAriaValueText={valueText}
step={null} step={null}
marks={playerMarks} marks={playerMarks}
onChange={(_e, value) => { onChange={(_e, value) => {
@@ -207,7 +225,7 @@ const Start = () => {
min={20} min={20}
aria-label="Custom marks" aria-label="Custom marks"
value={playerOptions?.startingLifeTotal ?? 40} value={playerOptions?.startingLifeTotal ?? 40}
getAriaValueText={valuetext} getAriaValueText={valueText}
step={10} step={10}
marks={healthMarks} marks={healthMarks}
onChange={(_e, value) => onChange={(_e, value) =>
@@ -250,30 +268,35 @@ const Start = () => {
}} }}
/> />
</ToggleContainer> </ToggleContainer>
<Button <div className="flex flex-nowrap text-nowrap relative justify-center items-start">
variant="contained" <Button
style={{ height: '2rem' }} variant="contained"
onClick={() => { style={{ height: '2rem' }}
setOpenSettingsModal(true); onClick={() => {
}} setOpenSettingsModal(true);
> }}
<Cog /> &nbsp; Other settings >
</Button> <Cog /> &nbsp; Game Settings
</Button>
<div
data-not-latest-version={
!version.isLatest && !!version.remoteVersion
}
className="absolute flex justify-center text-text-primary text-xxs -bottom-5 bg-primary-dark px-2 rounded-md
opacity-0 transition-all duration-200 delay-500
data-[not-latest-version=true]:opacity-100
"
>
<div className="absolute bg-primary-dark rotate-45 size-2 -top-[2px] z-0" />
<span className="z-10">
v{version.remoteVersion} available!
</span>
</div>
</div>
</ToggleButtonsWrapper> </ToggleButtonsWrapper>
<FormLabel>Layout</FormLabel> <FormLabel>Layout</FormLabel>
{/* <LayoutOptions
numberOfPlayers={playerOptions.numberOfPlayers}
gridAreas={playerOptions.gridAreas}
onChange={(gridAreas) =>
setPlayerOptions({
...playerOptions,
gridAreas,
//TODO fix the layout selection
orientation: Orientation.Portrait,
})
}
/> */}
<LayoutOptions <LayoutOptions
numberOfPlayers={playerOptions.numberOfPlayers} numberOfPlayers={playerOptions.numberOfPlayers}
selectedOrientation={playerOptions.orientation} selectedOrientation={playerOptions.orientation}

View File

@@ -1,6 +1,13 @@
import { createContext } from 'react'; import { createContext } from 'react';
import { InitialGameSettings, Settings } from '../Types/Settings'; import { InitialGameSettings, Settings } from '../Types/Settings';
type Version = {
installedVersion: string;
isLatest: boolean;
checkForNewVersion: (source: 'settings' | 'start_menu') => Promise<void>;
remoteVersion?: string;
};
export type GlobalSettingsContextType = { export type GlobalSettingsContextType = {
fullscreen: { fullscreen: {
isFullscreen: boolean; isFullscreen: boolean;
@@ -24,9 +31,13 @@ export type GlobalSettingsContextType = {
setSettings: (settings: Settings) => void; setSettings: (settings: Settings) => void;
playing: boolean; playing: boolean;
setPlaying: (playing: boolean) => void; setPlaying: (playing: boolean) => void;
stopPlayerRandomization: boolean; randomizingPlayer: boolean;
setStopPlayerRandomization: (stopRandom: boolean) => void; setRandomizingPlayer: (stopRandom: boolean) => void;
isPWA: boolean; isPWA: boolean;
preStartCompleted: boolean;
setPreStartCompleted: (completed: boolean) => void;
version: Version;
}; };
export const GlobalSettingsContext = export const GlobalSettingsContext =

View File

@@ -1,7 +1,7 @@
import { Player, Rotation } from '../Types/Player'; import { Player, Rotation } from '../Types/Player';
import { InitialGameSettings, Orientation } from '../Types/Settings'; import { InitialGameSettings, Orientation } from '../Types/Settings';
const presetColors = [ export const presetColors = [
'#F06292', // Light Pink '#F06292', // Light Pink
'#4DB6AC', // Teal '#4DB6AC', // Teal
'#FFA726', // Orange '#FFA726', // Orange
@@ -127,15 +127,15 @@ const getOrientationRotations = (
case Orientation.Portrait: case Orientation.Portrait:
switch (index) { switch (index) {
case 0: case 0:
return Rotation.Side; return Rotation.Flipped;
case 1: case 1:
return Rotation.Side; return Rotation.Flipped;
case 2: case 2:
return Rotation.SideFlipped; return Rotation.Side;
case 3: case 3:
return Rotation.SideFlipped; return Rotation.Normal;
case 4: case 4:
return Rotation.SideFlipped; return Rotation.Normal;
default: default:
return Rotation.Normal; return Rotation.Normal;
} }
@@ -163,17 +163,17 @@ const getOrientationRotations = (
case Orientation.Portrait: case Orientation.Portrait:
switch (index) { switch (index) {
case 0: case 0:
return Rotation.Side; return Rotation.SideFlipped;
case 1: case 1:
return Rotation.Side; return Rotation.Flipped;
case 2: case 2:
return Rotation.Side; return Rotation.Flipped;
case 3: case 3:
return Rotation.SideFlipped; return Rotation.Side;
case 4: case 4:
return Rotation.SideFlipped; return Rotation.Normal;
case 5: case 5:
return Rotation.SideFlipped; return Rotation.Normal;
default: default:
return Rotation.Normal; return Rotation.Normal;
} }

View File

@@ -2,7 +2,7 @@ import { initializeApp } from 'firebase/app';
import { getAnalytics, logEvent } from 'firebase/analytics'; import { getAnalytics, logEvent } from 'firebase/analytics';
const firebaseConfig = { const firebaseConfig = {
apiKey: 'AIzaSyCZ1AHMb5zmWS4VoRnC-OBxTswUfrJ0mlY', apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: 'life-trinket.firebaseapp.com', authDomain: 'life-trinket.firebaseapp.com',
projectId: 'life-trinket', projectId: 'life-trinket',
storageBucket: 'life-trinket.appspot.com', storageBucket: 'life-trinket.appspot.com',
@@ -18,7 +18,17 @@ export const useAnalytics = () => {
eventName: string, eventName: string,
eventParams?: { [key: string]: unknown } eventParams?: { [key: string]: unknown }
) => { ) => {
logEvent(analytics, eventName, eventParams); if (process.env.NODE_ENV === 'development') {
console.info('Event not tracked:', { eventName, eventParams });
return;
}
const paramsWithVersion = {
...eventParams,
app_version: import.meta.env.VITE_APP_VERSION,
};
logEvent(analytics, eventName, paramsWithVersion);
}; };
return { trackEvent }; return { trackEvent };

View File

@@ -23,7 +23,6 @@ export default function useOrientation(
const onChange = () => { const onChange = () => {
if (mounted) { if (mounted) {
const { orientation } = screen; const { orientation } = screen;
console.log(orientation);
if (orientation) { if (orientation) {
const { angle, type } = orientation; const { angle, type } = orientation;

View File

@@ -7,8 +7,11 @@ import {
import { useAnalytics } from '../Hooks/useAnalytics'; import { useAnalytics } from '../Hooks/useAnalytics';
import { import {
InitialGameSettings, InitialGameSettings,
InitialGameSettingsSchema,
Settings, Settings,
defaultInitialGameSettings,
defaultSettings,
initialGameSettingsSchema,
settingsSchema,
} from '../Types/Settings'; } from '../Types/Settings';
export const GlobalSettingsProvider = ({ export const GlobalSettingsProvider = ({
@@ -22,6 +25,7 @@ export const GlobalSettingsProvider = ({
const savedGameSettings = localStorage.getItem('initialGameSettings'); const savedGameSettings = localStorage.getItem('initialGameSettings');
const savedSettings = localStorage.getItem('settings'); const savedSettings = localStorage.getItem('settings');
const savedPlaying = localStorage.getItem('playing'); const savedPlaying = localStorage.getItem('playing');
const savedPreStartComplete = localStorage.getItem('preStartComplete');
const [playing, setPlaying] = useState<boolean>( const [playing, setPlaying] = useState<boolean>(
savedPlaying ? savedPlaying === 'true' : false savedPlaying ? savedPlaying === 'true' : false
@@ -31,67 +35,98 @@ export const GlobalSettingsProvider = ({
localStorage.setItem('playing', String(playing)); localStorage.setItem('playing', String(playing));
}; };
const [preStartCompleted, setPreStartCompleted] = useState<boolean>(
savedPreStartComplete ? savedPreStartComplete === 'true' : false
);
const [showPlay, setShowPlay] = useState<boolean>( const [showPlay, setShowPlay] = useState<boolean>(
savedShowPlay ? savedShowPlay === 'true' : false savedShowPlay ? savedShowPlay === 'true' : false
); );
const [stopPlayerRandomization, setStopPlayerRandomization] = const [randomizingPlayer, setRandomizingPlayer] = useState<boolean>(
useState<boolean>(false); savedSettings
? Boolean(JSON.parse(savedSettings).preStartMode === 'random-king')
: true
);
const [initialGameSettings, setInitialGameSettings] = const [initialGameSettings, setInitialGameSettings] =
useState<InitialGameSettings | null>( useState<InitialGameSettings | null>(
savedGameSettings ? JSON.parse(savedGameSettings) : null savedGameSettings ? JSON.parse(savedGameSettings) : null
); );
const setInitialGameSettingsAndLocalStorage = (
initialGameSettings: InitialGameSettings
) => {
setInitialGameSettings(initialGameSettings);
localStorage.setItem(
'initialGameSettings',
JSON.stringify(initialGameSettings)
);
};
const [settings, setSettings] = useState<Settings>( const [settings, setSettings] = useState<Settings>(
savedSettings savedSettings ? JSON.parse(savedSettings) : defaultSettings
? JSON.parse(savedSettings)
: {
goFullscreenOnStart: true,
keepAwake: true,
showStartingPlayer: true,
showPlayerMenuCog: true,
useRandomStartingPlayerInterval: false,
}
); );
const setSettingsAndLocalStorage = (settings: Settings) => {
setSettings(settings);
localStorage.setItem('settings', JSON.stringify(settings));
};
const removeLocalStorage = async () => { const removeLocalStorage = async () => {
localStorage.removeItem('initialGameSettings'); localStorage.removeItem('initialGameSettings');
localStorage.removeItem('players'); localStorage.removeItem('players');
localStorage.removeItem('playing'); localStorage.removeItem('playing');
localStorage.removeItem('showPlay'); localStorage.removeItem('showPlay');
localStorage.removeItem('preStartComplete');
setPlaying(false); setPlaying(false);
setShowPlay(false); setShowPlay(false);
setPreStartCompleted(false);
}; };
// Set settings if they are not valid
useEffect(() => { useEffect(() => {
if (savedGameSettings && JSON.parse(savedGameSettings).gridAreas) { // If there are no saved settings, set default settings
removeLocalStorage(); if (!savedSettings) {
window.location.reload(); setSettingsAndLocalStorage(defaultSettings);
return;
}
const parsedSettings = settingsSchema.safeParse(JSON.parse(savedSettings));
// If saved settings are not valid, remove them
if (!parsedSettings.success) {
console.error('invalid settings, resetting to default settings');
setSettingsAndLocalStorage(defaultSettings);
return;
}
localStorage.setItem('settings', JSON.stringify(parsedSettings.data));
}, [settings, savedSettings]);
// Set initial game settings if they are not valid
useEffect(() => {
if (!savedGameSettings) {
setInitialGameSettingsAndLocalStorage(defaultInitialGameSettings);
return; return;
} }
//parse existing game settings with zod schema //parse existing game settings with zod schema
const parsedInitialGameSettings = const parsedInitialGameSettings =
InitialGameSettingsSchema.safeParse(initialGameSettings); initialGameSettingsSchema.safeParse(initialGameSettings);
if (!parsedInitialGameSettings.success) { if (!parsedInitialGameSettings.success) {
removeLocalStorage(); console.error('invalid game settings, resetting to default settings');
window.location.reload(); setInitialGameSettingsAndLocalStorage(defaultInitialGameSettings);
return; return;
} }
localStorage.setItem( localStorage.setItem(
'initialGameSettings', 'initialGameSettings',
JSON.stringify(initialGameSettings) JSON.stringify(parsedInitialGameSettings.data)
); );
}, [initialGameSettings, savedGameSettings]); }, [initialGameSettings, savedGameSettings]);
useEffect(() => {
localStorage.setItem('settings', JSON.stringify(settings));
}, [settings]);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => { useEffect(() => {
@@ -107,6 +142,11 @@ export const GlobalSettingsProvider = ({
}; };
}, []); }, []);
const [isLatestVersion, setIsLatestVersion] = useState(false);
const [remoteVersion, setRemoteVersion] = useState<string | undefined>(
undefined
);
const { isSupported, release, released, request, type } = useWakeLock(); const { isSupported, release, released, request, type } = useWakeLock();
const active = settings.keepAwake; const active = settings.keepAwake;
@@ -155,6 +195,51 @@ export const GlobalSettingsProvider = ({
} }
}; };
const setPreStartCompletedAndLocalStorage = (preStartComplete: boolean) => {
setPreStartCompleted(preStartComplete);
localStorage.setItem('playing', String(playing));
};
async function checkForNewVersion(source: 'settings' | 'start_menu') {
try {
const result = await fetch(
'https://api.github.com/repos/Vikeo/LifeTrinket/releases/latest',
{
headers: {
Authorization: `Bearer ${
import.meta.env.VITE_REPO_READ_ACCESS_TOKEN
}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
}
);
const data = await result.json();
if (!data.name) {
setRemoteVersion(undefined);
setIsLatestVersion(false);
return;
}
setRemoteVersion(data.name);
if (data.name === import.meta.env.VITE_APP_VERSION) {
setIsLatestVersion(true);
return;
}
analytics.trackEvent(`${source}_has_new_version`, {
remoteVersion: data.name,
installedVersion: import.meta.env.VITE_APP_VERSION,
});
setIsLatestVersion(false);
} catch (error) {
console.error('error getting latest version string', error);
}
}
return { return {
fullscreen: { isFullscreen, enableFullscreen, disableFullscreen }, fullscreen: { isFullscreen, enableFullscreen, disableFullscreen },
wakeLock: { wakeLock: {
@@ -173,24 +258,36 @@ export const GlobalSettingsProvider = ({
initialGameSettings, initialGameSettings,
setInitialGameSettings, setInitialGameSettings,
settings, settings,
setSettings, setSettings: setSettingsAndLocalStorage,
stopPlayerRandomization, randomizingPlayer,
setStopPlayerRandomization, setRandomizingPlayer,
isPWA: window?.matchMedia('(display-mode: standalone)').matches, isPWA: window?.matchMedia('(display-mode: standalone)').matches,
preStartCompleted,
setPreStartCompleted: setPreStartCompletedAndLocalStorage,
version: {
installedVersion: import.meta.env.VITE_APP_VERSION,
remoteVersion,
isLatest: isLatestVersion,
checkForNewVersion,
},
}; };
}, [ }, [
active,
analytics,
initialGameSettings,
isFullscreen, isFullscreen,
isSupported, isSupported,
playing,
release, release,
active,
request, request,
settings,
showPlay,
stopPlayerRandomization,
type, type,
showPlay,
playing,
initialGameSettings,
settings,
randomizingPlayer,
preStartCompleted,
remoteVersion,
isLatestVersion,
analytics,
]); ]);
return ( return (

View File

@@ -12,12 +12,19 @@ export enum GameFormat {
TwoHeadedGiant = 'two-headed-giant', TwoHeadedGiant = 'two-headed-giant',
} }
export enum PreStartMode {
None = 'none',
RandomKing = 'random-king',
FingerGame = 'finger-game',
Trivia = 'trivia',
}
export type Settings = { export type Settings = {
keepAwake: boolean; keepAwake: boolean;
showStartingPlayer: boolean; showStartingPlayer: boolean;
showPlayerMenuCog: boolean; showPlayerMenuCog: boolean;
goFullscreenOnStart: boolean; goFullscreenOnStart: boolean;
useRandomStartingPlayerInterval: boolean; preStartMode: PreStartMode;
}; };
export type InitialGameSettings = { export type InitialGameSettings = {
@@ -28,10 +35,34 @@ export type InitialGameSettings = {
orientation: Orientation; orientation: Orientation;
}; };
export const InitialGameSettingsSchema = z.object({ export const initialGameSettingsSchema = z.object({
startingLifeTotal: z.number().min(1).max(200).default(20), startingLifeTotal: z.number().min(1).max(200),
useCommanderDamage: z.boolean().default(false), useCommanderDamage: z.boolean(),
gameFormat: z.nativeEnum(GameFormat).optional(), gameFormat: z.nativeEnum(GameFormat),
numberOfPlayers: z.number().min(1).max(6).default(2), numberOfPlayers: z.number().min(1).max(6),
orientation: z.nativeEnum(Orientation).default(Orientation.Landscape), orientation: z.nativeEnum(Orientation),
}); });
export const defaultInitialGameSettings = {
numberOfPlayers: 4,
startingLifeTotal: 40,
useCommanderDamage: true,
orientation: Orientation.Landscape,
gameFormat: GameFormat.Commander,
};
export const settingsSchema = z.object({
keepAwake: z.boolean(),
showStartingPlayer: z.boolean(),
showPlayerMenuCog: z.boolean(),
goFullscreenOnStart: z.boolean(),
preStartMode: z.nativeEnum(PreStartMode),
});
export const defaultSettings: Settings = {
goFullscreenOnStart: true,
keepAwake: true,
showStartingPlayer: true,
showPlayerMenuCog: true,
preStartMode: PreStartMode.None,
};

10
src/vite-env.d.ts vendored
View File

@@ -1 +1,11 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_VERSION: string;
readonly VITE_REPO_READ_ACCESS_TOKEN: string;
readonly VITE_FIREBASE_ANALYTICS_API_KEY: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -46,6 +46,37 @@ export const baseColors = {
}, },
}; };
export const twGridTemplateAreas = {
onePlayerLandscape: ['player0 player0'],
onePlayerPortrait: ['player0', 'player0'],
twoPlayersOppositeLandscape: ['player0', 'player1'],
twoPlayersOppositePortrait: ['player0 player1', 'player0 player1'],
twoPlayersSameSideLandscape: ['player0 player1'],
threePlayers: ['player0 player0', 'player1 player2'],
threePlayersSide: [
'player0 player0 player0 player2',
'player1 player1 player1 player2',
],
fourPlayerPortrait: [
'player0 player1 player1 player1 player1 player3',
'player0 player2 player2 player2 player2 player3',
],
fourPlayer: ['player0 player1', 'player2 player3'],
fivePlayers: [
'player0 player0 player0 player1 player1 player1',
'player2 player2 player3 player3 player4 player4',
],
fivePlayersSide: [
'player0 player0 player0 player0 player0 player1 player1 player1 player1 player1 player2',
'player3 player3 player3 player3 player3 player4 player4 player4 player4 player4 player2',
],
sixPlayers: ['player0 player1 player2', 'player3 player4 player5'],
sixPlayersSide: [
'player0 player1 player1 player1 player1 player1 player1 player2 player2 player2 player2 player2 player2 player3',
'player0 player4 player4 player4 player4 player4 player4 player5 player5 player5 player5 player5 player5 player3',
],
};
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
@@ -54,36 +85,7 @@ export default {
modalSm: '548px', modalSm: '548px',
}, },
extend: { extend: {
gridTemplateAreas: { gridTemplateAreas: twGridTemplateAreas,
onePlayerLandscape: ['player0 player0'],
onePlayerPortrait: ['player0', 'player0'],
twoPlayersOppositeLandscape: ['player0', 'player1'],
twoPlayersOppositePortrait: ['player0 player1', 'player0 player1'],
twoPlayersSameSideLandscape: ['player0 player1'],
threePlayers: ['player0 player0', 'player1 player2'],
threePlayersSide: [
'player0 player0 player0 player2',
'player1 player1 player1 player2',
],
fourPlayerPortrait: [
'player0 player1 player1 player1 player1 player3',
'player0 player2 player2 player2 player2 player3',
],
fourPlayer: ['player0 player1', 'player2 player3'],
fivePlayers: [
'player0 player0 player0 player1 player1 player1',
'player2 player2 player3 player3 player4 player4',
],
fivePlayersSide: [
'player0 player0 player0 player0 player0 player1 player1 player1 player1 player1 player2',
'player3 player3 player3 player3 player3 player4 player4 player4 player4 player4 player2',
],
sixPlayers: ['player0 player1 player2', 'player3 player4 player5'],
sixPlayersSide: [
'player0 player1 player1 player1 player1 player2 player2 player2 player2 player3',
'player0 player4 player4 player4 player4 player5 player5 player5 player5 player3',
],
},
colors: baseColors, colors: baseColors,
keyframes: { keyframes: {
fadeOut: { fadeOut: {
@@ -101,6 +103,9 @@ export default {
animation: { animation: {
fadeOut: 'fadeOut 3s 1s ease-out forwards', fadeOut: 'fadeOut 3s 1s ease-out forwards',
}, },
fontSize: {
xxs: ['0.625rem', '1rem'],
},
}, },
}, },
plugins: [tailwindcssGridAreas], plugins: [tailwindcssGridAreas],

View File

@@ -21,7 +21,14 @@ export default defineConfig({
}, },
}, },
define: { define: {
APP_VERSION: JSON.stringify(process.env.npm_package_version), 'import.meta.env.VITE_APP_VERSION': JSON.stringify(
REPO_READ_ACCESS_TOKEN: JSON.stringify(process.env.REPO_READ_ACCESS_TOKEN), process.env.npm_package_version
),
VITE_REPO_READ_ACCESS_TOKEN: JSON.stringify(
process.env.VITE_REPO_READ_ACCESS_TOKEN
),
VITE_FIREBASE_ANALYTICS_API_KEY: JSON.stringify(
process.env.VITE_FIREBASE_ANALYTICS_API_KEY
),
}, },
}); });

8867
yarn.lock

File diff suppressed because it is too large Load Diff