Compare commits

...

51 Commits
0.6.4 ... 0.9.6

Author SHA1 Message Date
Vikeo
ba9ca354fc fix crown being visible after reset 2024-04-01 18:48:06 +02:00
Viktor Rådberg
e79c728e6a Save game (#35)
* save

* hide scrollbar on desktop

* start menu styling

* bump
2024-04-01 00:28:59 +02:00
Viktor Rådberg
97f9bb75e5 bump 2024-03-31 19:58:00 +02:00
Viktor Rådberg
341cb3cde0 revert new api key 2024-03-31 19:57:16 +02:00
Viktor Rådberg
ce9c9ca997 default api key 2024-03-31 19:31:16 +02:00
Viktor Rådberg
ad485f059d debug release 2024-03-31 19:25:44 +02:00
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
Viktor Rådberg
d4dc44076d fix lint 2024-03-16 22:28:37 +01:00
Viktor Rådberg
a1b5cfd871 fix tsc 2024-03-16 22:26:06 +01:00
Viktor Rådberg
f11eea5e53 better styling 2024-03-16 22:23:03 +01:00
Viktor Rådberg
905912a7fd fix random interval 2024-03-16 21:59:24 +01:00
Viktor Rådberg
a90dd7c9ea wip 2024-03-16 14:40:18 +01:00
Viktor Rådberg
ef1310d674 bump 2024-03-16 13:22:47 +01:00
Viktor Rådberg
fe3bb6c78c show starting player untill press 2024-03-16 13:22:03 +01:00
Viktor Rådberg
6d2b3b6a6f Add option to show player menu cog 2024-03-16 12:29:16 +01:00
Viktor Rådberg
0f86928cb3 Merge pull request #32 from Vikeo/better-colors
Better colors
2024-03-16 10:42:13 +01:00
Viktor Rådberg
efbfb7719c tsc 2024-03-16 10:40:18 +01:00
Viktor Rådberg
71e5614f52 bump to new version 2024-03-16 10:38:23 +01:00
Viktor Rådberg
677fd79bee fix long press down 2024-03-16 10:23:15 +01:00
Viktor Rådberg
1bff41bc10 remove colorful 2024-03-16 10:04:35 +01:00
Viktor Rådberg
7852520f8e minus plus icon color 2024-03-16 09:59:40 +01:00
Viktor Rådberg
04c3d60967 use normal picker again 2024-03-16 09:31:59 +01:00
Viktor Rådberg
664e2e5688 round color picker 2024-02-19 07:38:17 +01:00
Viktor Rådberg
6eb7ac9f50 Merge branch 'main' into better-colors 2024-02-18 16:08:09 +01:00
Viktor Rådberg
8b33a2a38a wip 2024-01-28 17:04:30 +01:00
Viktor Rådberg
cc915dff36 better color picker 2024-01-28 11:54:37 +01:00
41 changed files with 11622 additions and 9435 deletions

View File

@@ -1,8 +1,12 @@
index.html,1705225256081,6ef0d7e2de82bf64addbb9294fb28845fd06daaa544b010a47422c12ae3ad97f
robots.txt,1705225255906,391d14b3c2f8c9143a27a28c7399585142228d4d1bdbe2c87ac946de411fa9a2
manifest.json,1705225255906,91ce94afb71f33a477f5d8d48c3f98bd7de422279c74f17b6500eec72003ac1a
assets/index-08359bdb.css,1705225256081,d2766260d28230d960d75362810713efaddf40687205e697432b52869f162af7
logo192.png,1705225255905,3b0fcf91fe2128f493de0bce2f6e2d35520a4260a04e05b8d855181359b3d3fe
favicon.ico,1705225255905,75661e6187b524767554b4f28ec09a64bc72b0bb102a0b453aaead88519d9ed3
logo512.png,1705225255906,cf49739c9e6890bbfcd4157f299dde425df60759b7320ae9188d7ab9dc51e8ca
assets/index-20658f4b.js,1705225256081,742f2c10740beea3a23f269aa6266b3c288d1fd9c7e20b6829034e8a898bf1e1
index.html,1711905710499,4b604b23faec8d63a58e07b96d724a1aea56a7c86d57c9af791832ce87a552e7
manifest.webmanifest,1711905710499,f2bf253209f6e292a6b0dbfa06fb4ac188eb5f2dba568c3ad5511b9ed52c1f51
manifest.json,1711905710294,7ff5111aa04a42adff3b38924ec467b6f345ed0309dba1486dc9b783b60c2a9d
sw.js,1711905711506,1ef2f4f40ec8dca15cc42d547462ade1e84314ea9722276f5994ccee7bbdf80f
robots.txt,1711905710294,391d14b3c2f8c9143a27a28c7399585142228d4d1bdbe2c87ac946de411fa9a2
registerSW.js,1711905710499,5b6445b5215737c53ef0d379c151d57de165a056de2d1c5812ed614f158ebcbd
workbox-3e911b1d.js,1711905711507,d5dbc868a5c07af633d29de7ba3ffe37542aaaabdf33713b4298df31f92f11ff
assets/index-7m_Zw4yH.css,1711905710499,37997d06b32b3d0c724c054913e3c0583b86f786358121cb1615e6646ff46b56
favicon.ico,1711905710294,8cefe5adbf00d337d8633fb744b9f2c4914f769b319be4bb7e184b7a4aa17160
logo192.png,1711905710294,3d1e2e6f064d4fd325828f21bb6493ff0dbf2390b0e7d2aba9f2b6def4829799
logo512.png,1711905710294,892a4da1cc5434929a83a71fcbcb0d0d80aa82f44e3c21e9b20ffe9267197133
assets/index-CLJVONOc.js,1711905710499,22f3835412f82bb3f8a62e74a7f4602a9c43b447224790365dbcc6cbffb4e665

View File

@@ -7,7 +7,8 @@ jobs:
build_and_deploy:
runs-on: ubuntu-latest
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:
- name: Checkout repository
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",
"private": true,
"version": "0.6.4",
"version": "0.9.6",
"type": "commonjs",
"engines": {
"node": ">=18",
"npm": "please use bun or yarn :) "
"yarn": "use pnpm",
"npm": "please use pnpm"
},
"scripts": {
"dev": "vite",

9603
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -66,8 +66,7 @@ export const CommanderDamage = ({
}: CommanderDamageButtonComponentProps) => {
const { updatePlayer } = usePlayers();
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const [timeoutFinished, setTimeoutFinished] = useState(false);
const [hasPressedDown, setHasPressedDown] = useState(false);
const [downLongPressed, setDownLongPressed] = useState(false);
const downPositionRef = useRef({ x: 0, y: 0 });
const handleCommanderDamageChange = (
@@ -109,16 +108,16 @@ export const CommanderDamage = ({
const handleDownInput = ({ opponentIndex, isPartner, event }: InputProps) => {
downPositionRef.current = { x: event.clientX, y: event.clientY };
setTimeoutFinished(false);
setHasPressedDown(true);
setDownLongPressed(false);
timeoutRef.current = setTimeout(() => {
setTimeoutFinished(true);
setDownLongPressed(true);
handleCommanderDamageChange(opponentIndex, -1, isPartner);
}, decrementTimeoutMs);
};
const handleUpInput = ({ opponentIndex, isPartner, event }: InputProps) => {
if (!(hasPressedDown && !timeoutFinished)) {
if (downLongPressed) {
return;
}
@@ -134,14 +133,14 @@ export const CommanderDamage = ({
return;
}
clearTimeout(timeoutRef.current);
handleCommanderDamageChange(opponentIndex, 1, isPartner);
setHasPressedDown(false);
};
const handleLeaveInput = () => {
setTimeoutFinished(true);
setDownLongPressed(true);
clearTimeout(timeoutRef.current);
setHasPressedDown(false);
};
const opponentIndex = opponent.index;

View File

@@ -1,8 +1,9 @@
import { useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { TwcComponentProps, twc } from 'react-twc';
import { lifeLongPressMultiplier } from '../../Data/constants';
import { Rotation } from '../../Types/Player';
import { Player, Rotation } from '../../Types/Player';
import { MAX_TAP_MOVE_DISTANCE } from './CommanderDamage';
import { checkContrast } from '../../Utils/checkContrast';
type RotationButtonProps = TwcComponentProps<'div'> & {
$align?: string;
@@ -13,7 +14,6 @@ const LifeCounterButtonTwc = twc.button`
h-full
w-full
flex
text-lifeCounter-text
font-semibold
bg-transparent
border-none
@@ -40,17 +40,15 @@ const TextContainer = twc.div<RotationButtonProps>((props) => [
]);
type LifeCounterButtonProps = {
lifeTotal: number;
player: Player;
setLifeTotal: (lifeTotal: number) => void;
rotation: number;
operation: 'add' | 'subtract';
increment: number;
};
const LifeCounterButton = ({
lifeTotal,
player,
setLifeTotal,
rotation,
operation,
increment,
}: LifeCounterButtonProps) => {
@@ -59,8 +57,20 @@ const LifeCounterButton = ({
const [hasPressedDown, setHasPressedDown] = useState(false);
const downPositionRef = useRef({ x: 0, y: 0 });
const [iconColor, setIconColor] = useState<'dark' | 'light'>('dark');
useEffect(() => {
const contrast = checkContrast(player.color, '#00000080');
if (contrast === 'Fail') {
setIconColor('light');
} else {
setIconColor('dark');
}
}, [player.color]);
const handleLifeChange = (increment: number) => {
setLifeTotal(lifeTotal + increment);
setLifeTotal(player.lifeTotal + increment);
};
const handleDownInput = (event: React.PointerEvent<HTMLButtonElement>) => {
@@ -102,7 +112,8 @@ const LifeCounterButton = ({
};
const fontSize =
rotation === Rotation.SideFlipped || rotation === Rotation.Side
player.settings.rotation === Rotation.SideFlipped ||
player.settings.rotation === Rotation.Side
? '8vmax'
: '12vmin';
@@ -118,8 +129,11 @@ const LifeCounterButton = ({
aria-label={`${operation === 'add' ? 'Add' : 'Subtract'} life`}
>
<TextContainer
$rotation={rotation}
$rotation={player.settings.rotation}
$align={operation === 'add' ? 'right' : 'left'}
data-contrast={iconColor}
className="data-[contrast=dark]:text-icons-dark
data-[contrast=light]:text-icons-light"
>
{operation === 'add' ? '\u002B' : '\u2212'}
</TextContainer>

View File

@@ -7,7 +7,7 @@ const LoseButton = twc.div<RotationDivProps>((props) => [
'absolute flex-grow border-none outline-none cursor-pointer bg-interface-loseButton-background rounded-lg select-none z-[1] webkit-user-select-none py-2 px-4 ',
props.$rotation === Rotation.SideFlipped || props.$rotation === Rotation.Side
? `left-[19%]`
? `left-[21%]`
: 'top-[21%]',
]);

View File

@@ -10,6 +10,8 @@ import {
import { CounterType, Player, Rotation } from '../../Types/Player';
import { RotationDivProps } from '../Buttons/CommanderDamage';
import ExtraCounter from '../Buttons/ExtraCounter';
import { useEffect, useState } from 'react';
import { checkContrast } from '../../Utils/checkContrast';
const Container = twc.div<RotationDivProps>((props) => [
'flex',
@@ -19,9 +21,9 @@ const Container = 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
? 'flex-col-reverse h-full w-auto bottom-auto'
? 'flex-col-reverse h-full w-auto bottom-auto right-0'
: 'w-full bottom-0',
]);
@@ -31,6 +33,17 @@ type ExtraCountersBarProps = {
const ExtraCountersBar = ({ player }: ExtraCountersBarProps) => {
const { updatePlayer } = usePlayers();
const [iconColor, setIconColor] = useState<'dark' | 'light'>('dark');
useEffect(() => {
const contrast = checkContrast(player.color, '#00000080');
if (contrast === 'Fail') {
setIconColor('light');
} else {
setIconColor('dark');
}
}, [player.color]);
const handleCounterChange = (
updatedCounterTotal: number,
@@ -93,7 +106,13 @@ const ExtraCountersBar = ({ player }: ExtraCountersBarProps) => {
{useCommanderDamage && (
<ExtraCounter
rotation={player.settings.rotation}
Icon={<CommanderTax size={iconSize} opacity="0.5" color="black" />}
Icon={
<CommanderTax
size={iconSize}
data-contrast={iconColor}
className="data-[contrast=dark]:text-icons-dark data-[contrast=light]:text-icons-light"
/>
}
type={CounterType.CommanderTax}
counterTotal={
player.extraCounters?.find(
@@ -108,7 +127,13 @@ const ExtraCountersBar = ({ player }: ExtraCountersBarProps) => {
{Boolean(useCommanderDamage && usePartner) && (
<ExtraCounter
rotation={player.settings.rotation}
Icon={<PartnerTax size={iconSize} opacity="0.5" color="black" />}
Icon={
<PartnerTax
size={iconSize}
data-contrast={iconColor}
className="data-[contrast=dark]:text-icons-dark data-[contrast=light]:text-icons-light"
/>
}
type={CounterType.PartnerTax}
counterTotal={
player.extraCounters?.find(
@@ -123,7 +148,13 @@ const ExtraCountersBar = ({ player }: ExtraCountersBarProps) => {
{usePoison && (
<ExtraCounter
rotation={player.settings.rotation}
Icon={<Poison size={iconSize} opacity="0.5" color="black" />}
Icon={
<Poison
size={iconSize}
data-contrast={iconColor}
className="data-[contrast=dark]:text-icons-dark data-[contrast=light]:text-icons-light"
/>
}
type={CounterType.Poison}
counterTotal={
player.extraCounters?.find((counter) => counter.type === 'poison')
@@ -137,7 +168,13 @@ const ExtraCountersBar = ({ player }: ExtraCountersBarProps) => {
{useEnergy && (
<ExtraCounter
rotation={player.settings.rotation}
Icon={<Energy size={iconSize} opacity="0.5" color="black" />}
Icon={
<Energy
size={iconSize}
data-contrast={iconColor}
className="data-[contrast=dark]:text-icons-dark data-[contrast=light]:text-icons-light"
/>
}
type={CounterType.Energy}
counterTotal={
player.extraCounters?.find((counter) => counter.type === 'energy')
@@ -151,7 +188,13 @@ const ExtraCountersBar = ({ player }: ExtraCountersBarProps) => {
{useExperience && (
<ExtraCounter
rotation={player.settings.rotation}
Icon={<Experience size={iconSize} opacity="0.5" color="black" />}
Icon={
<Experience
size={iconSize}
data-contrast={iconColor}
className="data-[contrast=dark]:text-icons-dark data-[contrast=light]:text-icons-light"
/>
}
type={CounterType.Experience}
counterTotal={
player.extraCounters?.find(

View File

@@ -53,23 +53,9 @@ const Health = ({
differenceKey,
recentDifference,
}: HealthProps) => {
const [showStartingPlayer, setShowStartingPlayer] = useState(
localStorage.getItem('playing') === 'true'
);
const [fontSize, setFontSize] = useState(16);
const textContainerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!showStartingPlayer) {
const playingTimer = setTimeout(() => {
localStorage.setItem('playing', 'true');
setShowStartingPlayer(localStorage.getItem('playing') === 'true');
}, 3_000);
return () => clearTimeout(playingTimer);
}
}, [showStartingPlayer]);
useEffect(() => {
if (!textContainerRef.current) {
return;
@@ -118,9 +104,8 @@ const Health = ({
return (
<LifeContainer $rotation={player.settings.rotation}>
<LifeCounterButton
lifeTotal={player.lifeTotal}
player={player}
setLifeTotal={handleLifeChange}
rotation={player.settings.rotation}
operation="subtract"
increment={-1}
/>
@@ -148,9 +133,8 @@ const Health = ({
</LifeCounterTextContainer>
</TextWrapper>
<LifeCounterButton
lifeTotal={player.lifeTotal}
player={player}
setLifeTotal={handleLifeChange}
rotation={player.settings.rotation}
operation="add"
increment={1}
/>

View File

@@ -1,16 +1,64 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useSwipeable } from 'react-swipeable';
import { twc } from 'react-twc';
import { useAnalytics } from '../../Hooks/useAnalytics';
import { useGlobalSettings } from '../../Hooks/useGlobalSettings';
import { usePlayers } from '../../Hooks/usePlayers';
import { Cog } from '../../Icons/generated';
import { Player, Rotation } from '../../Types/Player';
import { RotationDivProps } from '../Buttons/CommanderDamage';
import { checkContrast } from '../../Utils/checkContrast';
import {
RotationButtonProps,
RotationDivProps,
} from '../Buttons/CommanderDamage';
import { LoseGameButton } from '../Buttons/LoseButton';
import CommanderDamageBar from '../Counters/CommanderDamageBar';
import ExtraCountersBar from '../Counters/ExtraCountersBar';
import PlayerMenu from '../Player/PlayerMenu';
import PlayerMenu from '../Players/PlayerMenu';
import { StartingPlayerCard } from '../PreStartGame/StartingPlayerCard';
import Health from './Health';
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',
props.$rotation === Rotation.Side || props.$rotation === Rotation.SideFlipped
? `right-auto top-[1vmax] left-[27%]`
: 'top-1/4 right-[1vmax]',
]);
type SettingsButtonProps = {
onClick: () => void;
rotation: Rotation;
color: string;
};
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 (
<SettingsButtonTwc
onClick={onClick}
$rotation={rotation}
aria-label={`Settings`}
>
<Cog
size="5vmin"
data-contrast={iconColor}
className="data-[contrast=dark]:text-icons-dark data-[contrast=light]:text-icons-light"
/>
</SettingsButtonTwc>
);
};
const LifeCounterContentWrapper = twc.div`
relative flex flex-grow flex-col items-center w-full h-full overflow-hidden`;
@@ -21,8 +69,6 @@ const LifeCounterWrapper = twc.div<RotationDivProps>((props) => [
: `flex-col`,
]);
const StartingPlayerNoticeWrapper = twc.div`z-[1] flex absolute w-full h-full justify-center items-center pointer-events-none select-none webkit-user-select-none bg-primary-main`;
const PlayerLostWrapper = twc.div<RotationDivProps>((props) => [
'z-[1] flex absolute w-full h-full justify-center items-center pointer-events-none select-none webkit-user-select-none bg-lifeCounter-lostWrapper opacity-75',
props.$rotation === Rotation.SideFlipped || props.$rotation === Rotation.Side
@@ -30,8 +76,6 @@ const PlayerLostWrapper = twc.div<RotationDivProps>((props) => [
: '',
]);
const DynamicText = twc.div`text-[8vmin] whitespace-nowrap`;
const hasCommanderDamageReached21 = (player: Player) => {
const commanderDamageTotals = player.commanderDamage.map(
(commanderDamage) => commanderDamage.damageTotal
@@ -62,13 +106,17 @@ const playerCanLose = (player: Player) => {
type LifeCounterProps = {
player: Player;
opponents: Player[];
isStartingPlayer?: boolean;
};
const RECENT_DIFFERENCE_TTL = 3_000;
const LifeCounter = ({ player, opponents }: LifeCounterProps) => {
const { updatePlayer, updateLifeTotal } = usePlayers();
const { settings } = useGlobalSettings();
const { settings, playing } = useGlobalSettings();
const recentDifferenceTimerRef = useRef<NodeJS.Timeout | undefined>(
undefined
);
const [showPlayerMenu, setShowPlayerMenu] = useState(false);
const [recentDifference, setRecentDifference] = useState(0);
@@ -85,12 +133,12 @@ const LifeCounter = ({ player, opponents }: LifeCounterProps) => {
trackMouse: true,
onSwipedDown: (e) => {
e.event.stopPropagation();
console.log(`User DOWN Swiped on player ${player.index}`);
analytics.trackEvent('open_player_menu_swipe');
setShowPlayerMenu(true);
},
onSwipedUp: (e) => {
e.event.stopPropagation();
console.log(`User UP Swiped on player ${player.index}`);
analytics.trackEvent('close_player_menu_swipe');
setShowPlayerMenu(false);
},
@@ -98,41 +146,41 @@ const LifeCounter = ({ player, opponents }: LifeCounterProps) => {
onSwiping: (e) => e.event.stopPropagation(),
rotationAngle,
});
const analytics = useAnalytics();
useEffect(() => {
const timer = setTimeout(() => {
if (recentDifference === 0) {
clearTimeout(recentDifferenceTimerRef.current);
return;
}
recentDifferenceTimerRef.current = setTimeout(() => {
analytics.trackEvent('life_changed', {
lifeChangedAmount: recentDifference,
});
setRecentDifference(0);
}, RECENT_DIFFERENCE_TTL);
return () => {
clearTimeout(recentDifferenceTimerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recentDifference]);
useEffect(() => {
const resizeObserver = new ResizeObserver(() => {
if (document.body.clientWidth > document.body.clientHeight)
setIsLandscape(true);
else setIsLandscape(false);
return;
return () => {
// Cleanup: disconnect the ResizeObserver when the component unmounts.
resizeObserver.disconnect();
};
});
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
}, [recentDifference, document.body.clientHeight, document.body.clientWidth]);
useEffect(() => {
if (player.showStartingPlayer) {
const playingTimer = setTimeout(() => {
localStorage.setItem('playing', 'true');
player.showStartingPlayer = false;
updatePlayer(player);
}, 3_000);
return () => clearTimeout(playingTimer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [player.showStartingPlayer]);
}, [document.body.clientHeight, document.body.clientWidth]);
player.settings.rotation === Rotation.SideFlipped ||
player.settings.rotation === Rotation.Side;
@@ -154,11 +202,7 @@ const LifeCounter = ({ player, opponents }: LifeCounterProps) => {
? player.settings.rotation - 90
: player.settings.rotation;
const calcTextRotation =
player.settings.rotation === Rotation.SideFlipped ||
player.settings.rotation === Rotation.Side
? player.settings.rotation - 180
: player.settings.rotation;
const amountOfPlayers = opponents.length + 1;
return (
<LifeCounterContentWrapper style={{ background: player.color }}>
@@ -167,31 +211,31 @@ const LifeCounter = ({ player, opponents }: LifeCounterProps) => {
style={{ rotate: `${calcRotation}deg` }}
{...handlers}
>
{settings.showStartingPlayer &&
player.isStartingPlayer &&
player.showStartingPlayer && (
<StartingPlayerNoticeWrapper
style={{ rotate: `${calcRotation}deg` }}
>
<DynamicText
style={{
rotate: `${calcTextRotation}deg`,
}}
>
You start!
</DynamicText>
</StartingPlayerNoticeWrapper>
)}
{amountOfPlayers > 1 &&
!playing &&
settings.showStartingPlayer &&
player.isStartingPlayer && <StartingPlayerCard player={player} />}
{player.hasLost && (
<PlayerLostWrapper $rotation={player.settings.rotation} />
)}
<CommanderDamageBar
opponents={opponents}
player={player}
key={player.index}
handleLifeChange={handleLifeChange}
/>
{settings.showPlayerMenuCog && (
<SettingsButton
onClick={() => {
analytics.trackEvent('open_player_menu_button');
setShowPlayerMenu(!showPlayerMenu);
}}
rotation={player.settings.rotation}
color={player.color}
/>
)}
{playerCanLose(player) && (
<LoseGameButton
rotation={player.settings.rotation}

View File

@@ -2,8 +2,11 @@ import { Modal } from '@mui/material';
import { twc } from 'react-twc';
import { Separator } from './Separator';
import { Paragraph } from './TextComponents';
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-[47.5%] h-[95%] bg-background-default p-4 overflow-scroll rounded-2xl border-none text-text-primary w-[95vw] max-w-[548px]`;
type InfoModalProps = {
isOpen: boolean;
@@ -11,6 +14,17 @@ type 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 (
<Modal
open={isOpen}
@@ -18,12 +32,12 @@ export const InfoModal = ({ isOpen, closeModal }: InfoModalProps) => {
style={{ display: 'flex', justifyContent: 'center' }}
>
<>
<div className="flex relative w-full max-w-[548px]">
<div className="flex justify-center items-center relative w-full max-w-[532px]">
<button
onClick={closeModal}
className="flex absolute top-10 right-0 z-10 w-10 h-10 text-common-white 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"
>
X
<Cross size="16px" className="text-text-primary " />
</button>
</div>
<ModalWrapper>
@@ -60,25 +74,34 @@ export const InfoModal = ({ isOpen, closeModal }: InfoModalProps) => {
</li>
</ul>
<h3 className="text-lg font-bold mb-2">Other</h3>
<Paragraph className="mb-4">
When a player is <strong>at or below 0 life</strong>, has taken{' '}
<strong>21 or more Commander Damage</strong> or has{' '}
<strong>10 or more poison counters</strong>, a button with a skull
will appear on that player's card. Tapping it will dim the
player's card.
</Paragraph>
<h3 className="text-lg font-bold mb-2">Other functionality</h3>
<ul className="list-disc ml-6">
<li>
<Paragraph className="mb-1">
When a player is <strong>at or below 0 life</strong>, has
taken <strong>21 or more Commander Damage</strong> or has{' '}
<strong>10 or more poison counters</strong>, a button with a
skull will appear on that player's card. Tapping it will dim
the player's card.
</Paragraph>
</li>
<li>
<Paragraph className="mb-4">
Swiping <strong>down</strong> on a player's card will show
that player's settings menu.
</Paragraph>
</li>
</ul>
</div>
<div className="text-center mt-4">
Visit my
Visit my{' '}
<a
href="https://github.com/Vikeo/LifeTrinket"
target="_blank"
className="text-text-secondary underline"
>
{' '}
GitHub{' '}
</a>
GitHub
</a>{' '}
for more info about this web app.
</div>
</ModalWrapper>

View File

@@ -1,88 +1,156 @@
import { Button, FormLabel, Modal, Switch } from '@mui/material';
import { Modal, Switch } from '@mui/material';
import { useEffect } from 'react';
import { twc } from 'react-twc';
import { useGlobalSettings } from '../../Hooks/useGlobalSettings';
import { Cross } from '../../Icons/generated';
import { PreStartMode } from '../../Types/Settings';
import { ModalWrapper } from './InfoModal';
import { Separator } from './Separator';
import { Paragraph } from './TextComponents';
import { useEffect, useState } from 'react';
import { useAnalytics } from '../../Hooks/useAnalytics';
const SettingContainer = twc.div`w-full flex flex-col`;
const SettingContainer = twc.div`w-full flex flex-col mb-2`;
const ToggleContainer = twc.div`flex flex-row justify-between items-center`;
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 baseGithubReleasesUrl =
'https://github.com/Vikeo/LifeTrinket/releases/tag/';
type SettingsModalProps = {
isOpen: boolean;
closeModal: () => void;
};
export const SettingsModal = ({ isOpen, closeModal }: SettingsModalProps) => {
const { settings, setSettings, isPWA } = useGlobalSettings();
const [isLatestVersion, setIsLatestVersion] = useState(false);
const [newVersion, setNewVersion] = useState<string | undefined>(undefined);
const { settings, setSettings, isPWA, version } = useGlobalSettings();
const analytics = useAnalytics();
useEffect(() => {
if (!isOpen) {
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) {
setNewVersion(undefined);
setIsLatestVersion(false);
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();
analytics.trackEvent('settings_opened');
version.checkForNewVersion('settings');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
return (
<Modal open={isOpen} onClose={closeModal}>
<Modal
open={isOpen}
onClose={() => {
analytics.trackEvent('settings_outside_clicked');
closeModal();
}}
className="w-full flex justify-center"
>
<>
<div className="flex relative w-full max-w-[548px]">
<div className="flex justify-center items-center relative w-full max-w-[532px]">
<button
onClick={closeModal}
className="flex absolute top-10 right-0 z-10 w-10 h-10 text-common-white bg-primary-main items-center justify-center rounded-full border-solid border-primary-dark border-2"
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"
>
X
<Cross size="16px" className="text-text-primary " />
</button>
</div>
<ModalWrapper>
<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" />
<SettingContainer>
<ToggleContainer>
<FormLabel>Show Start Player</FormLabel>
<label>Show Player Menu Cog</label>
<Switch
checked={settings.showPlayerMenuCog}
onChange={() => {
setSettings({
...settings,
showPlayerMenuCog: !settings.showPlayerMenuCog,
});
}}
/>
</ToggleContainer>
<Description>
A cog on the top right of each player's card will be shown if
this is enabled.
</Description>
</SettingContainer>
<SettingContainer>
<ToggleContainer>
<label>Show Start Player</label>
<Switch
checked={settings.showStartingPlayer}
onChange={() => {
@@ -94,13 +162,72 @@ export const SettingsModal = ({ isOpen, closeModal }: SettingsModalProps) => {
/>
</ToggleContainer>
<Description>
On start or reset of game, will pick a random player who will
start first if this is enabled.
On start or reset of game, will pick a random starting player,
according to the <b>Pre-Start mode</b>
</Description>
</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>
<ToggleContainer>
<FormLabel>Keep Awake</FormLabel>
<label>Keep Awake</label>
<Switch
checked={settings.keepAwake}
onChange={() => {
@@ -118,7 +245,10 @@ export const SettingsModal = ({ isOpen, closeModal }: SettingsModalProps) => {
</SettingContainer>
<SettingContainer>
<ToggleContainer>
<FormLabel>Go fullscreen on start (Android only)</FormLabel>
<label>
Fullscreen on start{' '}
<span className="text-xs">(Android only)</span>
</label>
<Switch
checked={settings.goFullscreenOnStart}
onChange={() => {
@@ -134,6 +264,16 @@ export const SettingsModal = ({ isOpen, closeModal }: SettingsModalProps) => {
enabled.
</Description>
</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 && (
<>
<Separator height="1px" />
@@ -154,39 +294,6 @@ export const SettingsModal = ({ isOpen, closeModal }: SettingsModalProps) => {
</>
)}
<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>
</ModalWrapper>
</>

View File

@@ -1,11 +1,13 @@
import { Button, Checkbox } from '@mui/material';
import { Checkbox } from '@mui/material';
import { useRef } from 'react';
import { twc } from 'react-twc';
import { theme } from '../../Data/theme';
import { useAnalytics } from '../../Hooks/useAnalytics';
import { useGlobalSettings } from '../../Hooks/useGlobalSettings';
import { usePlayers } from '../../Hooks/usePlayers';
import { useSafeRotate } from '../../Hooks/useSafeRotate';
import {
Cross,
Energy,
Exit,
Experience,
@@ -16,10 +18,9 @@ import {
ResetGame,
} from '../../Icons/generated';
import { Player, Rotation } from '../../Types/Player';
import { PreStartMode } from '../../Types/Settings';
import { RotationDivProps } from '../Buttons/CommanderDamage';
const CheckboxContainer = twc.div``;
const PlayerMenuWrapper = twc.div`
flex
flex-col
@@ -50,7 +51,6 @@ const TogglesSection = twc.div`
flex-row
flex-wrap
relative
gap-2
h-full
justify-evenly
items-center
@@ -59,28 +59,26 @@ const TogglesSection = twc.div`
const ButtonsSections = twc.div`
flex
max-w-full
gap-4
justify-between
p-[3%]
justify-evenly
items-center
flex-wrap
mt-0
px-2
`;
const ColorPicker = twc.input`
const ColorPickerButton = twc.div`
h-[8vmax]
w-[8vmax]
relative
max-h-12
max-w-11
border-none
outline-none
max-w-12
rounded-full
cursor-pointer
bg-transparent
user-select-none
text-common-white
overflow-hidden
`;
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
? 'flex-col'
: 'flex-row',
@@ -98,15 +96,29 @@ const PlayerMenu = ({
isShown,
}: PlayerMenuProps) => {
const settingsContainerRef = useRef<HTMLDivElement | null>(null);
const dialogRef = useRef<HTMLDialogElement | null>(null);
const resetGameDialogRef = useRef<HTMLDialogElement | null>(null);
const endGameDialogRef = useRef<HTMLDialogElement | null>(null);
const { isSide } = useSafeRotate({
rotation: player.settings.rotation,
containerRef: settingsContainerRef,
});
const { fullscreen, wakeLock, goToStart } = useGlobalSettings();
const { updatePlayer, resetCurrentGame } = usePlayers();
const {
fullscreen,
wakeLock,
goToStart,
settings,
setPlaying,
setRandomizingPlayer,
saveCurrentGame,
initialGameSettings,
setPreStartCompleted,
} = useGlobalSettings();
const analytics = useAnalytics();
const { updatePlayer, resetCurrentGame, players } = usePlayers();
const handleColorChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const updatedPlayer = { ...player, color: event.target.value };
@@ -123,6 +135,21 @@ const PlayerMenu = ({
const handleResetGame = () => {
resetCurrentGame();
setShowPlayerMenu(false);
setPlaying(false);
if (settings.preStartMode === PreStartMode.RandomKing) {
setRandomizingPlayer(true);
setPreStartCompleted(false);
}
analytics.trackEvent('reset_game');
};
const handleGoToStart = () => {
saveCurrentGame({ players, initialGameSettings });
goToStart();
setRandomizingPlayer(true);
};
const toggleFullscreen = () => {
@@ -160,17 +187,33 @@ const PlayerMenu = ({
}}
ref={settingsContainerRef}
>
<button
onClick={() => {
analytics.trackEvent('close_player_menu_button');
setShowPlayerMenu(false);
}}
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]"
>
<Cross size={buttonFontSize} className="text-primary-main " />
</button>
<BetterRowContainer>
<TogglesSection>
<ColorPicker
type="color"
value={player.color}
onChange={handleColorChange}
role="button"
aria-label="Color picker"
/>
<ColorPickerButton aria-label="Color picker">
<input
onChange={handleColorChange}
type="color"
className="size-[200%] absolute -left-2 -top-2"
value={player.color}
onClick={() => {
analytics.trackEvent('color_picker_opened', {
player: player.index,
});
}}
/>
</ColorPickerButton>
{player.settings.useCommanderDamage && (
<CheckboxContainer>
<div>
<Checkbox
name="usePartner"
checked={player.settings.usePartner}
@@ -190,15 +233,19 @@ const PlayerMenu = ({
strokeWidth="30"
/>
}
onChange={handleSettingsChange}
onChange={(e) => {
analytics.trackEvent('toggle_partner', {
checked: e.target.checked,
});
handleSettingsChange(e);
}}
role="checkbox"
aria-checked={player.settings.usePartner}
aria-label="Partner"
/>
</CheckboxContainer>
</div>
)}
<CheckboxContainer>
<div>
<Checkbox
name="usePoison"
checked={player.settings.usePoison}
@@ -218,14 +265,18 @@ const PlayerMenu = ({
strokeWidth="30"
/>
}
onChange={handleSettingsChange}
onChange={(e) => {
analytics.trackEvent('toggle_poison', {
checked: e.target.checked,
});
handleSettingsChange(e);
}}
role="checkbox"
aria-checked={player.settings.usePoison}
aria-label="Poison"
/>
</CheckboxContainer>
<CheckboxContainer>
</div>
<div>
<Checkbox
name="useEnergy"
checked={player.settings.useEnergy}
@@ -245,14 +296,18 @@ const PlayerMenu = ({
strokeWidth="15"
/>
}
onChange={handleSettingsChange}
onChange={(e) => {
analytics.trackEvent('toggle_energy', {
checked: e.target.checked,
});
handleSettingsChange(e);
}}
role="checkbox"
aria-checked={player.settings.useEnergy}
aria-label="Energy"
/>
</CheckboxContainer>
<CheckboxContainer>
</div>
<div>
<Checkbox
name="useExperience"
checked={player.settings.useExperience}
@@ -272,26 +327,32 @@ const PlayerMenu = ({
strokeWidth="15"
/>
}
onChange={handleSettingsChange}
onChange={(e) => {
analytics.trackEvent('toggle_experience', {
checked: e.target.checked,
});
handleSettingsChange(e);
}}
role="checkbox"
aria-checked={player.settings.useExperience}
aria-label="Experience"
/>
</CheckboxContainer>
</div>
</TogglesSection>
<ButtonsSections className="mt-4">
<Button
variant="text"
style={{
cursor: 'pointer',
userSelect: 'none',
}}
onClick={goToStart}
<ButtonsSections>
<button
className="text-primary-main cursor-pointer webkit-user-select-none"
onClick={() => endGameDialogRef.current?.show()}
aria-label="Back to start"
>
<Exit size={iconSize} style={{ rotate: '180deg' }} />
</Button>
<CheckboxContainer>
</button>
<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
name="fullscreen"
checked={document.fullscreenElement ? true : false}
@@ -306,63 +367,120 @@ const PlayerMenu = ({
role="checkbox"
aria-checked={document.fullscreenElement ? true : false}
aria-label="Fullscreen"
style={{ padding: '4px' }}
/>
</CheckboxContainer>
</div>
<Button
variant={wakeLock.active ? 'contained' : 'outlined'}
<button
data-wake-lock-active={settings.keepAwake}
style={{
cursor: 'pointer',
userSelect: 'none',
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"
aria-checked={wakeLock.active}
aria-checked={settings.keepAwake}
aria-label="Keep awake"
>
Keep Awake
</Button>
</button>
<Button
<button
style={{
cursor: 'pointer',
userSelect: 'none',
fontSize: buttonFontSize,
padding: '4px',
padding: '2px',
}}
onClick={() => dialogRef.current?.show()}
className="text-primary-main"
onClick={() => resetGameDialogRef.current?.show()}
role="checkbox"
aria-checked={wakeLock.active}
aria-label="Reset Game"
>
<ResetGame size={iconSize} />
</Button>
</button>
</ButtonsSections>
</BetterRowContainer>
<dialog
ref={dialogRef}
className="z-[9999] min-h-2/4 bg-background-default text-text-primary rounded-2xl border-none absolute bottom-[20%]"
ref={resetGameDialogRef}
className="z-[999] size-full bg-background-settings overflow-y-scroll"
onClick={() => resetGameDialogRef.current?.close()}
>
<div className="flex flex-col p-4 gap-2">
<h1 className="text-center">Reset Game?</h1>
<div className="flex justify-evenly gap-4">
<Button
variant="contained"
onClick={() => dialogRef.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 }}
>
No
</Button>
<Button
variant="contained"
onClick={() => {
handleResetGame();
dialogRef.current?.close();
}}
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()}
>
No
</button>
<button
className="bg-primary-main border border-primary-dark text-text-primary rounded-lg flex-grow"
onClick={() => {
handleResetGame();
resetGameDialogRef.current?.close();
}}
style={{ fontSize: iconSize }}
>
Yes
</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 }}
>
Yes
</Button>
Go to start?
</h1>
<div
style={{ fontSize: iconSize }}
className="text-center text-text-primary"
>
(Game will be saved)
</div>
<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>
</dialog>

View File

@@ -1,6 +1,8 @@
import LifeCounter from '../LifeCounter/LifeCounter';
import { Player as PlayerType } from '../../Types/Player';
import { twc } from 'react-twc';
import { usePlayers } from '../../Hooks/usePlayers';
import { Player as PlayerType } from '../../Types/Player';
import LifeCounter from '../LifeCounter/LifeCounter';
import { GridLayout } from '../Views/Play';
const getGridArea = (player: PlayerType) => {
switch (player.index) {
@@ -21,15 +23,16 @@ const getGridArea = (player: PlayerType) => {
}
};
const PlayerWrapper = twc.div`w-full h-full bg-black`;
const PlayersWrapper = twc.div`w-full h-full bg-black`;
export const Players = ({ gridLayout }: { gridLayout: GridLayout }) => {
const { players } = usePlayers();
export const Player = (players: PlayerType[], gridClasses: string) => {
return (
<PlayerWrapper>
<div className={`grid w-full h-full gap-1 box-border ${gridClasses} `}>
<PlayersWrapper>
<div className={`grid w-full h-full gap-1 box-border ${gridLayout} `}>
{players.map((player) => {
const gridArea = getGridArea(player);
return (
<div
key={player.index}
@@ -45,6 +48,6 @@ export const Player = (players: PlayerType[], gridClasses: string) => {
);
})}
</div>
</PlayerWrapper>
</PlayersWrapper>
);
};

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,116 @@
import { useEffect } from 'react';
import { twc } from 'react-twc';
import { twGridTemplateAreas } from '../../../tailwind.config';
import { useGlobalSettings } from '../../Hooks/useGlobalSettings';
import { usePlayers } from '../../Hooks/usePlayers';
import { Orientation } from '../../Types/Settings';
import { Player } from '../Player/Player';
import { twc } from 'react-twc';
import { Orientation, PreStartMode } from '../../Types/Settings';
import { Players } from '../Players/Players';
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 = () => {
const { players } = usePlayers();
const { initialGameSettings } = useGlobalSettings();
const { players, setPlayers } = usePlayers();
const { initialGameSettings, playing, settings, preStartCompleted } =
useGlobalSettings();
let Layout: JSX.Element;
let gridLayout: GridLayout;
switch (players.length) {
case 1:
if (initialGameSettings?.orientation === Orientation.Portrait) {
Layout = Player(players, 'grid-areas-onePlayerPortrait');
gridLayout = 'grid-areas-onePlayerPortrait';
}
Layout = Player(players, 'grid-areas-onePlayerLandscape');
gridLayout = 'grid-areas-onePlayerLandscape';
break;
case 2:
switch (initialGameSettings?.orientation) {
case Orientation.Portrait:
Layout = Player(players, 'grid-areas-twoPlayersOppositePortrait');
gridLayout = 'grid-areas-twoPlayersOppositePortrait';
break;
default:
case Orientation.Landscape:
Layout = Player(players, 'grid-areas-twoPlayersSameSideLandscape');
gridLayout = 'grid-areas-twoPlayersSameSideLandscape';
break;
case Orientation.OppositeLandscape:
Layout = Player(players, 'grid-areas-twoPlayersOppositeLandscape');
gridLayout = 'grid-areas-twoPlayersOppositeLandscape';
break;
}
break;
case 3:
if (initialGameSettings?.orientation === Orientation.Portrait) {
Layout = Player(players, 'grid-areas-threePlayersSide');
gridLayout = 'grid-areas-threePlayersSide';
break;
}
Layout = Player(players, 'grid-areas-threePlayers');
gridLayout = 'grid-areas-threePlayers';
break;
default:
case 4:
if (initialGameSettings?.orientation === Orientation.Portrait) {
Layout = Player(players, 'grid-areas-fourPlayerPortrait');
gridLayout = 'grid-areas-fourPlayerPortrait';
break;
}
Layout = Player(players, 'grid-areas-fourPlayer');
gridLayout = 'grid-areas-fourPlayer';
break;
case 5:
if (initialGameSettings?.orientation === Orientation.Portrait) {
Layout = Player(players, 'grid-areas-fivePlayersSide');
gridLayout = 'grid-areas-fivePlayersSide';
break;
}
Layout = Player(players, 'grid-areas-fivePlayers');
gridLayout = 'grid-areas-fivePlayers';
break;
case 6:
if (initialGameSettings?.orientation === Orientation.Portrait) {
Layout = Player(players, 'grid-areas-sixPlayersSide');
gridLayout = 'grid-areas-sixPlayersSide';
break;
}
Layout = Player(players, 'grid-areas-sixPlayers');
gridLayout = 'grid-areas-sixPlayers';
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 (
players.length > 1 &&
!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 {
FivePlayers,
FivePlayersSide,
FourPlayers,
FourPlayersSide,
OnePlayerPortrait,
SixPlayers,
SixPlayersSide,
ThreePlayers,
ThreePlayersSide,
TwoPlayersOppositeLandscape,
@@ -40,7 +42,7 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
switch (numberOfPlayers) {
case 1:
return (
<div>
<>
<FormControlLabel
value={Orientation.Landscape}
control={
@@ -89,7 +91,7 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
}
label=""
/>
</div>
</>
);
case 2:
return (
@@ -303,20 +305,21 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
}
label=""
/>
{/* <FormControlLabel
value={GridTemplateAreas.FivePlayersSide}
<FormControlLabel
value={Orientation.Portrait}
control={
<Radio
style={{ maxWidth: iconMaxWidth, maxHeight: iconMaxHeight }}
icon={
<FivePlayersSide
height={iconHeight}
height={iconHeight}
width={iconWidth}
fill={theme.palette.secondary.main}
/>
}
checkedIcon={
<FivePlayersSide
height={iconHeight}
height={iconHeight}
width={iconWidth}
fill={theme.palette.primary.main}
/>
@@ -325,7 +328,7 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
/>
}
label=""
/> */}
/>
</>
);
@@ -356,20 +359,21 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
}
label=""
/>
{/* <FormControlLabel
value={GridTemplateAreas.SixPlayersSide}
<FormControlLabel
value={Orientation.Portrait}
control={
<Radio
style={{ maxWidth: iconMaxWidth, maxHeight: iconMaxHeight }}
icon={
<SixPlayersSide
height={iconHeight}
height={iconHeight}
width={iconWidth}
fill={theme.palette.secondary.main}
/>
}
checkedIcon={
<SixPlayersSide
height={iconHeight}
height={iconHeight}
width={iconWidth}
fill={theme.palette.primary.main}
/>
@@ -378,7 +382,7 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
/>
}
label=""
/> */}
/>
</>
);

View File

@@ -9,18 +9,19 @@ import { useGlobalSettings } from '../../../Hooks/useGlobalSettings';
import { usePlayers } from '../../../Hooks/usePlayers';
import { Cog, Info } from '../../../Icons/generated';
import {
GameFormat,
InitialGameSettings,
Orientation,
PreStartMode,
defaultInitialGameSettings,
} from '../../../Types/Settings';
import { InfoModal } from '../../Misc/InfoModal';
import { SettingsModal } from '../../Misc/SettingsModal';
import { SupportMe } from '../../Misc/SupportMe';
import { LayoutOptions } from './LayoutOptions';
const MainWrapper = twc.div`w-[100dvw] h-fit pb-14 overflow-hidden items-center flex flex-col`;
const MainWrapper = twc.div`w-[100dvw] h-fit pb-24 overflow-hidden items-center flex flex-col min-[349px]:pb-10`;
const StartButtonFooter = twc.div`w-full max-w-[548px] fixed bottom-4 z-1 items-center flex flex-col px-4`;
const StartButtonFooter = twc.div`w-full max-w-[548px] fixed bottom-4 z-1 items-center flex flex-row flex-wrap px-4 z-10 gap-4`;
const SliderWrapper = twc.div`mx-8`;
@@ -89,27 +90,53 @@ const Start = () => {
setInitialGameSettings,
settings,
isPWA,
setRandomizingPlayer,
version,
setPlaying,
savedGame,
saveCurrentGame,
} = useGlobalSettings();
const [openInfoModal, setOpenInfoModal] = useState(false);
const [openSettingsModal, setOpenSettingsModal] = useState(false);
const [playerOptions, setPlayerOptions] = useState<InitialGameSettings>(
initialGameSettings || {
numberOfPlayers: 4,
startingLifeTotal: 40,
useCommanderDamage: true,
orientation: Orientation.Portrait,
gameFormat: GameFormat.Commander,
}
initialGameSettings || defaultInitialGameSettings
);
const doStartGame = () => {
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 doStartNewGame = () => {
if (!initialGameSettings) {
return;
}
analytics.trackEvent('game_started', { ...initialGameSettings });
analytics.trackEvent('game_started', {
...initialGameSettings,
...settings,
isPWA,
});
try {
if (settings.goFullscreenOnStart) {
@@ -125,26 +152,46 @@ const Start = () => {
setInitialGameSettings(initialGameSettings);
setPlayers(createInitialPlayers(initialGameSettings));
setRandomizingPlayer(settings.preStartMode === PreStartMode.RandomKing);
setShowPlay(true);
localStorage.setItem('playing', 'false');
localStorage.setItem('showPlay', 'true');
setPlaying(false);
};
useEffect(() => {
setInitialGameSettings(playerOptions);
}, [playerOptions, setInitialGameSettings]);
const doResumeGame = () => {
if (!savedGame) {
return;
}
const valuetext = (value: number) => {
analytics.trackEvent('game_resumed', {
...initialGameSettings,
...settings,
isPWA,
});
try {
if (settings.goFullscreenOnStart) {
fullscreen.enableFullscreen();
}
} catch (error) {
console.error(error);
}
if (settings.keepAwake && !wakeLock.active) {
wakeLock.request();
}
setInitialGameSettings(savedGame.initialGameSettings);
setPlayers(savedGame.players);
saveCurrentGame(null);
setRandomizingPlayer(false);
setShowPlay(true);
setPlaying(true);
};
const valueText = (value: number) => {
return `${value}`;
};
useEffect(() => {
setPlayerOptions({
...playerOptions,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playerOptions.numberOfPlayers]);
return (
<MainWrapper>
<Info
@@ -172,54 +219,16 @@ const Start = () => {
<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
<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>
<div className="overflow-hidden items-center flex flex-col max-w-[548px] w-full mb-8 px-4">
<FormControl focused={false} style={{ width: '100%' }}>
<FormLabel>Number of Players</FormLabel>
<SliderWrapper>
<Slider
title="Number of Players"
max={6}
min={1}
aria-label="Custom marks"
value={playerOptions?.numberOfPlayers ?? 4}
getAriaValueText={valuetext}
step={null}
marks={playerMarks}
onChange={(_e, value) => {
setPlayerOptions({
...playerOptions,
numberOfPlayers: value as number,
orientation: Orientation.Landscape,
});
}}
/>
</SliderWrapper>
<FormLabel className="mt-[0.7rem]">Starting Health</FormLabel>
<SliderWrapper>
<Slider
title="Starting Health"
max={60}
min={20}
aria-label="Custom marks"
value={playerOptions?.startingLifeTotal ?? 40}
getAriaValueText={valuetext}
step={10}
marks={healthMarks}
onChange={(_e, value) =>
setPlayerOptions({
...playerOptions,
startingLifeTotal: value as number,
orientation: Orientation.Landscape,
})
}
/>
</SliderWrapper>
<ToggleButtonsWrapper className="mt-4">
<ToggleContainer>
<FormLabel>Commander</FormLabel>
@@ -250,30 +259,76 @@ const Start = () => {
}}
/>
</ToggleContainer>
<Button
variant="contained"
style={{ height: '2rem' }}
onClick={() => {
setOpenSettingsModal(true);
}}
>
<Cog /> &nbsp; Other settings
</Button>
<div className="flex flex-nowrap text-nowrap relative justify-center items-start">
<Button
variant="contained"
style={{ height: '2rem' }}
onClick={() => {
setOpenSettingsModal(true);
}}
>
<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>
<FormLabel>Number of Players</FormLabel>
<SliderWrapper>
<Slider
title="Number of Players"
max={6}
min={1}
aria-label="Custom marks"
value={playerOptions?.numberOfPlayers ?? 4}
getAriaValueText={valueText}
step={null}
marks={playerMarks}
onChange={(_e, value) => {
setPlayerOptions({
...playerOptions,
numberOfPlayers: value as number,
orientation: Orientation.Landscape,
});
}}
/>
</SliderWrapper>
<FormLabel className="mt-[0.7rem]">Starting Health</FormLabel>
<SliderWrapper>
<Slider
title="Starting Health"
max={60}
min={20}
aria-label="Custom marks"
value={playerOptions?.startingLifeTotal ?? 40}
getAriaValueText={valueText}
step={10}
marks={healthMarks}
onChange={(_e, value) =>
setPlayerOptions({
...playerOptions,
startingLifeTotal: value as number,
orientation: Orientation.Landscape,
})
}
/>
</SliderWrapper>
<FormLabel>Layout</FormLabel>
{/* <LayoutOptions
numberOfPlayers={playerOptions.numberOfPlayers}
gridAreas={playerOptions.gridAreas}
onChange={(gridAreas) =>
setPlayerOptions({
...playerOptions,
gridAreas,
//TODO fix the layout selection
orientation: Orientation.Portrait,
})
}
/> */}
<LayoutOptions
numberOfPlayers={playerOptions.numberOfPlayers}
selectedOrientation={playerOptions.orientation}
@@ -295,14 +350,25 @@ const Start = () => {
</div>
<StartButtonFooter>
<Button
size="large"
variant="contained"
onClick={doStartGame}
fullWidth
<button
className="flex flex-grow basis-0 justify-center self-center items-center bg-primary-main px-3 py-2 rounded-md text-text-primary min-w-[150px]"
onClick={doStartNewGame}
>
START GAME
</Button>
NEW GAME
</button>
{savedGame && (
<button
className="flex flex-grow basis-0 justify-center self-center items-center bg-primary-dark px-3 py-2 rounded-md text-text-primary min-w-[150px]"
onClick={doResumeGame}
>
RESUME&nbsp;
<span className="text-xs">
({savedGame.players.length}&nbsp;
{savedGame.players.length > 1 ? 'players' : 'player'})
</span>
</button>
)}
</StartButtonFooter>
</MainWrapper>
);

View File

@@ -1,5 +1,18 @@
import { createContext } from 'react';
import { InitialGameSettings, Settings } from '../Types/Settings';
import { Player } from '../Types/Player';
type Version = {
installedVersion: string;
isLatest: boolean;
checkForNewVersion: (source: 'settings' | 'start_menu') => Promise<void>;
remoteVersion?: string;
};
export type SavedGame = {
initialGameSettings: InitialGameSettings;
players: Player[];
} | null;
export type GlobalSettingsContextType = {
fullscreen: {
@@ -18,11 +31,20 @@ export type GlobalSettingsContextType = {
goToStart: () => void;
showPlay: boolean;
setShowPlay: (showPlay: boolean) => void;
initialGameSettings: InitialGameSettings | null;
initialGameSettings: InitialGameSettings;
setInitialGameSettings: (initialGameSettings: InitialGameSettings) => void;
settings: Settings;
setSettings: (settings: Settings) => void;
playing: boolean;
setPlaying: (playing: boolean) => void;
randomizingPlayer: boolean;
setRandomizingPlayer: (stopRandom: boolean) => void;
isPWA: boolean;
preStartCompleted: boolean;
setPreStartCompleted: (completed: boolean) => void;
version: Version;
savedGame: SavedGame;
saveCurrentGame: (currentGame: SavedGame) => void;
};
export const GlobalSettingsContext =

View File

@@ -7,6 +7,8 @@ export type PlayersContextType = {
updatePlayer: (updatedPlayer: Player) => void;
updateLifeTotal: (player: Player, updatedLifeTotal: number) => number;
resetCurrentGame: () => void;
startingPlayerIndex: number;
setStartingPlayerIndex: (index: number) => void;
};
export const PlayersContext = createContext<PlayersContextType | null>(null);

View File

@@ -1,7 +1,7 @@
import { Player, Rotation } from '../Types/Player';
import { InitialGameSettings, Orientation } from '../Types/Settings';
const presetColors = [
export const presetColors = [
'#F06292', // Light Pink
'#4DB6AC', // Teal
'#FFA726', // Orange
@@ -127,15 +127,15 @@ const getOrientationRotations = (
case Orientation.Portrait:
switch (index) {
case 0:
return Rotation.Side;
return Rotation.Flipped;
case 1:
return Rotation.Side;
return Rotation.Flipped;
case 2:
return Rotation.SideFlipped;
return Rotation.Side;
case 3:
return Rotation.SideFlipped;
return Rotation.Normal;
case 4:
return Rotation.SideFlipped;
return Rotation.Normal;
default:
return Rotation.Normal;
}
@@ -163,17 +163,17 @@ const getOrientationRotations = (
case Orientation.Portrait:
switch (index) {
case 0:
return Rotation.Side;
return Rotation.SideFlipped;
case 1:
return Rotation.Side;
return Rotation.Flipped;
case 2:
return Rotation.Side;
return Rotation.Flipped;
case 3:
return Rotation.SideFlipped;
return Rotation.Side;
case 4:
return Rotation.SideFlipped;
return Rotation.Normal;
case 5:
return Rotation.SideFlipped;
return Rotation.Normal;
default:
return Rotation.Normal;
}
@@ -191,10 +191,8 @@ export const createInitialPlayers = ({
}: InitialGameSettings): Player[] => {
const players: Player[] = [];
const availableColors = [...presetColors]; // Create a copy of the colors array
const firstPlayerIndex = Math.floor(Math.random() * numberOfPlayers);
for (let i = 0; i <= numberOfPlayers - 1; i++) {
const isStartingPlayer = i === firstPlayerIndex;
const colorIndex = Math.floor(Math.random() * availableColors.length);
const color = availableColors[colorIndex];
@@ -224,11 +222,10 @@ export const createInitialPlayers = ({
usePoison: false,
rotation,
},
isStartingPlayer,
showStartingPlayer: isStartingPlayer,
extraCounters: [],
commanderDamage,
hasLost: false,
isStartingPlayer: false,
isSide: rotation === Rotation.Side || rotation === Rotation.SideFlipped,
};

View File

@@ -18,7 +18,17 @@ export const useAnalytics = () => {
eventName: string,
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 };

View File

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

View File

@@ -3,12 +3,16 @@ import { useWakeLock } from 'react-screen-wake-lock';
import {
GlobalSettingsContext,
GlobalSettingsContextType,
SavedGame,
} from '../Contexts/GlobalSettingsContext';
import { useAnalytics } from '../Hooks/useAnalytics';
import {
InitialGameSettings,
InitialGameSettingsSchema,
Settings,
defaultInitialGameSettings,
defaultSettings,
initialGameSettingsSchema,
settingsSchema,
} from '../Types/Settings';
export const GlobalSettingsProvider = ({
@@ -18,60 +22,131 @@ export const GlobalSettingsProvider = ({
}) => {
const analytics = useAnalytics();
const savedShowPlay = localStorage.getItem('showPlay');
const savedGameSettings = localStorage.getItem('initialGameSettings');
const savedSettings = localStorage.getItem('settings');
const localSavedGame = localStorage.getItem('savedGame');
const [savedGame, setCurrentGame] = useState<SavedGame>(
localSavedGame ? JSON.parse(localSavedGame) : null
);
const setCurrentGameAndLocalStorage = (savedGame: SavedGame) => {
if (!savedGame) {
setCurrentGame(savedGame);
localStorage.removeItem('savedGame');
return;
}
setCurrentGame(savedGame);
localStorage.setItem('savedGame', JSON.stringify(savedGame));
};
const savedPlaying = localStorage.getItem('playing');
const [playing, setPlaying] = useState<boolean>(
savedPlaying ? savedPlaying === 'true' : false
);
const setPlayingAndLocalStorage = (playing: boolean) => {
setPlaying(playing);
localStorage.setItem('playing', String(playing));
};
const savedPreStartComplete = localStorage.getItem('preStartComplete');
const [preStartCompleted, setPreStartCompleted] = useState<boolean>(
savedPreStartComplete ? savedPreStartComplete === 'true' : false
);
const savedShowPlay = localStorage.getItem('showPlay');
const [showPlay, setShowPlay] = useState<boolean>(
savedShowPlay ? savedShowPlay === 'true' : false
);
const setShowPlayAndLocalStorage = (showPlay: boolean) => {
setShowPlay(showPlay);
localStorage.setItem('showPlay', String(showPlay));
};
const savedSettings = localStorage.getItem('settings');
const [randomizingPlayer, setRandomizingPlayer] = useState<boolean>(
savedSettings
? Boolean(JSON.parse(savedSettings).preStartMode === 'random-king')
: true
);
const [settings, setSettings] = useState<Settings>(
savedSettings ? JSON.parse(savedSettings) : defaultSettings
);
const setSettingsAndLocalStorage = (settings: Settings) => {
setSettings(settings);
localStorage.setItem('settings', JSON.stringify(settings));
};
const savedGameSettings = localStorage.getItem('initialGameSettings');
const [initialGameSettings, setInitialGameSettings] =
useState<InitialGameSettings | null>(
savedGameSettings ? JSON.parse(savedGameSettings) : null
useState<InitialGameSettings>(
savedGameSettings
? JSON.parse(savedGameSettings)
: defaultInitialGameSettings
);
const [settings, setSettings] = useState<Settings>(
savedSettings
? JSON.parse(savedSettings)
: { goFullscreenOnStart: true, keepAwake: true, showStartingPlayer: true }
);
const setInitialGameSettingsAndLocalStorage = (
initialGameSettings: InitialGameSettings
) => {
setInitialGameSettings(initialGameSettings);
localStorage.setItem(
'initialGameSettings',
JSON.stringify(initialGameSettings)
);
};
const removeLocalStorage = async () => {
localStorage.removeItem('initialGameSettings');
localStorage.removeItem('players');
localStorage.removeItem('playing');
localStorage.removeItem('showPlay');
localStorage.removeItem('preStartComplete');
setPlaying(false);
setShowPlay(false);
setPreStartCompleted(false);
};
// Set settings if they are not valid
useEffect(() => {
if (savedGameSettings && JSON.parse(savedGameSettings).gridAreas) {
removeLocalStorage();
window.location.reload();
// If there are no saved settings, set default settings
if (!savedSettings) {
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;
}
//parse existing game settings with zod schema
const parsedInitialGameSettings =
InitialGameSettingsSchema.safeParse(initialGameSettings);
initialGameSettingsSchema.safeParse(initialGameSettings);
if (!parsedInitialGameSettings.success) {
removeLocalStorage();
window.location.reload();
console.error('invalid game settings, resetting to default settings');
setInitialGameSettingsAndLocalStorage(defaultInitialGameSettings);
return;
}
localStorage.setItem(
'initialGameSettings',
JSON.stringify(initialGameSettings)
JSON.stringify(parsedInitialGameSettings.data)
);
}, [initialGameSettings, savedGameSettings]);
useEffect(() => {
localStorage.setItem('settings', JSON.stringify(settings));
}, [settings]);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
@@ -87,6 +162,11 @@ export const GlobalSettingsProvider = ({
};
}, []);
const [isLatestVersion, setIsLatestVersion] = useState(false);
const [remoteVersion, setRemoteVersion] = useState<string | undefined>(
undefined
);
const { isSupported, release, released, request, type } = useWakeLock();
const active = settings.keepAwake;
@@ -135,6 +215,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 {
fullscreen: { isFullscreen, enableFullscreen, disableFullscreen },
wakeLock: {
@@ -147,24 +272,45 @@ export const GlobalSettingsProvider = ({
},
goToStart,
showPlay,
setShowPlay,
setShowPlay: setShowPlayAndLocalStorage,
playing,
setPlaying: setPlayingAndLocalStorage,
initialGameSettings,
setInitialGameSettings,
settings,
setSettings,
setSettings: setSettingsAndLocalStorage,
randomizingPlayer,
setRandomizingPlayer,
isPWA: window?.matchMedia('(display-mode: standalone)').matches,
preStartCompleted,
setPreStartCompleted: setPreStartCompletedAndLocalStorage,
savedGame,
saveCurrentGame: setCurrentGameAndLocalStorage,
version: {
installedVersion: import.meta.env.VITE_APP_VERSION,
remoteVersion,
isLatest: isLatestVersion,
checkForNewVersion,
},
};
}, [
active,
analytics,
initialGameSettings,
isFullscreen,
isSupported,
release,
active,
request,
settings,
showPlay,
type,
showPlay,
playing,
initialGameSettings,
settings,
randomizingPlayer,
preStartCompleted,
savedGame,
remoteVersion,
isLatestVersion,
analytics,
]);
return (

View File

@@ -7,6 +7,17 @@ import { InitialGameSettings } from '../Types/Settings';
export const PlayersProvider = ({ children }: { children: ReactNode }) => {
const savedPlayers = localStorage.getItem('players');
const savedStartingPlayerIndex = localStorage.getItem('startingPlayerIndex');
const [startingPlayerIndex, setStartingPlayerIndex] = useState<number>(
savedStartingPlayerIndex ? parseInt(savedStartingPlayerIndex) : -1
);
const setStartingPlayerIndexAndLocalStorage = (index: number) => {
setStartingPlayerIndex(index);
localStorage.setItem('startingPlayerIndex', String(index));
};
const [players, setPlayers] = useState<Player[]>(
savedPlayers ? JSON.parse(savedPlayers) : []
);
@@ -50,9 +61,7 @@ export const PlayersProvider = ({ children }: { children: ReactNode }) => {
return;
}
const startingPlayerIndex = Math.floor(
Math.random() * initialGameSettings.numberOfPlayers
);
const newStartingPlayerIndex = Math.floor(Math.random() * players.length);
players.forEach((player: Player) => {
player.commanderDamage.map((damage) => {
@@ -65,16 +74,9 @@ export const PlayersProvider = ({ children }: { children: ReactNode }) => {
});
player.lifeTotal = initialGameSettings.startingLifeTotal;
player.hasLost = false;
const isStartingPlayer = player.index === startingPlayerIndex;
player.isStartingPlayer = isStartingPlayer;
if (player.isStartingPlayer) {
player.showStartingPlayer = true;
}
player.isStartingPlayer = newStartingPlayerIndex === player.index;
updatePlayer(player);
});
@@ -87,8 +89,10 @@ export const PlayersProvider = ({ children }: { children: ReactNode }) => {
updatePlayer,
updateLifeTotal,
resetCurrentGame,
startingPlayerIndex,
setStartingPlayerIndex: setStartingPlayerIndexAndLocalStorage,
};
}, [players]);
}, [players, startingPlayerIndex]);
return (
<PlayersContext.Provider value={ctxValue}>

View File

@@ -6,7 +6,6 @@ export type Player = {
commanderDamage: CommanderDamage[];
extraCounters: ExtraCounter[];
isStartingPlayer: boolean;
showStartingPlayer: boolean;
hasLost: boolean;
isSide: boolean;
};

View File

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

View File

@@ -0,0 +1,87 @@
type RGBA = {
red: number;
green: number;
blue: number;
alpha: number;
};
export const hexToRgb = (hex: string): RGBA => {
hex = hex.replace(/^#/, '');
let alpha = 255;
if (hex.length === 8) {
alpha = parseInt(hex.slice(6, 8), 16);
hex = hex.substring(0, 6);
}
if (hex.length === 4) {
alpha = parseInt(hex.slice(3, 4).repeat(2), 16);
hex = hex.substring(0, 3);
}
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
const num = parseInt(hex, 16);
const red = num >> 16;
const green = (num >> 8) & 255;
const blue = num & 255;
return { red, green, blue, alpha };
};
export const luminance = (a: number, b: number) => {
const l1 = Math.max(a, b);
const l2 = Math.min(a, b);
return (l1 + 0.05) / (l2 + 0.05);
};
export const rgbContrast = (a: RGBA, b: RGBA) => {
return luminance(relativeLuminance(a), relativeLuminance(b));
};
// calculate the color contrast ratio
export const checkContrast = (hexC1: string, hexC2: string) => {
const color1rgb = hexToRgb(hexC1);
const color2rgb = hexToRgb(hexC2);
const contrast = rgbContrast(color1rgb, color2rgb);
if (contrast >= 7) {
return 'AAA';
}
if (contrast >= 4.5) {
return 'AA';
}
if (contrast >= 3) {
return 'AA Large';
}
return 'Fail';
};
// red, green, and blue coefficients
const rc = 0.2126;
const gc = 0.7152;
const bc = 0.0722;
// low-gamma adjust coefficient
const lowc = 1 / 12.92;
function adjustGamma(input: number) {
return Math.pow((input + 0.055) / 1.055, 2.4);
}
export const relativeLuminance = (rgb: RGBA) => {
const rsrgb = rgb.red / 255;
const gsrgb = rgb.green / 255;
const bsrgb = rgb.blue / 255;
const r = rsrgb <= 0.03928 ? rsrgb * lowc : adjustGamma(rsrgb);
const g = gsrgb <= 0.03928 ? gsrgb * lowc : adjustGamma(gsrgb);
const b = bsrgb <= 0.03928 ? bsrgb * lowc : adjustGamma(bsrgb);
return r * rc + g * gc + b * bc;
};

View File

@@ -31,6 +31,16 @@ code {
monospace;
}
// hide scrollbar globally
::-webkit-scrollbar {
display: none;
}
* {
scrollbar-width: none;
-ms-overflow-style: none;
}
@layer utilities {
.pointer-events-all {
pointer-events: all;

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

@@ -1 +1,11 @@
/// <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

@@ -2,6 +2,81 @@
import tailwindcssGridAreas from '@savvywombat/tailwindcss-grid-areas';
import type { Config } from 'tailwindcss';
export const baseColors = {
primary: {
main: '#3E7D78',
dark: '#2D5F5B',
},
secondary: {
main: '#284F4C',
dark: '#1B3B38',
},
background: {
default: '#08253B',
backdrop: 'rgba(0, 0, 0, 0.3)',
settings: 'rgba(20, 20, 0, 0.9)',
},
icons: {
dark: '#00000080',
light: '#ffffff4f',
},
text: {
primary: '#F5F5F5',
secondary: '#76A6A5',
},
action: {
disabled: '#234A47',
},
common: {
white: '#F9FFE3',
black: '#000000',
},
lifeCounter: {
text: 'rgba(0, 0, 0, 0.4)',
lostWrapper: '#000000',
},
interface: {
loseButton: {
background: '#43434380',
},
recentDifference: {
background: 'rgba(255, 255, 255, 0.6);',
text: '#333333',
},
},
};
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} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
@@ -10,75 +85,8 @@ export default {
modalSm: '548px',
},
extend: {
gridTemplateAreas: {
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: {
primary: {
main: '#3E7D78',
dark: '#2D5F5B',
},
secondary: {
main: '#284F4C',
dark: '#1B3B38',
},
background: {
default: '#08253B',
backdrop: 'rgba(0, 0, 0, 0.3)',
settings: 'rgba(20, 20, 0, 0.9)',
},
text: {
primary: '#F5F5F5',
secondary: '#76A6A5',
},
action: {
disabled: '#234A47',
},
common: {
white: '#F9FFE3',
black: '#000000',
},
lifeCounter: {
text: 'rgba(0, 0, 0, 0.4)',
lostWrapper: '#000000',
},
interface: {
loseButton: {
background: '#43434380',
},
recentDifference: {
background: 'rgba(255, 255, 255, 0.6);',
text: '#333333',
},
},
},
gridTemplateAreas: twGridTemplateAreas,
colors: baseColors,
keyframes: {
fadeOut: {
'0%': {
@@ -95,6 +103,9 @@ export default {
animation: {
fadeOut: 'fadeOut 3s 1s ease-out forwards',
},
fontSize: {
xxs: ['0.625rem', '1rem'],
},
},
},
plugins: [tailwindcssGridAreas],

View File

@@ -21,7 +21,14 @@ export default defineConfig({
},
},
define: {
APP_VERSION: JSON.stringify(process.env.npm_package_version),
REPO_READ_ACCESS_TOKEN: JSON.stringify(process.env.REPO_READ_ACCESS_TOKEN),
'import.meta.env.VITE_APP_VERSION': JSON.stringify(
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