Compare commits

...

5 Commits
0.8.1 ... 0.9.2

Author SHA1 Message Date
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
26 changed files with 10683 additions and 9299 deletions

View File

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

1
.npmrc Normal file
View File

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

1
env.d.ts vendored
View File

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

View File

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

9603
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useSwipeable } from 'react-swipeable'; import { useSwipeable } from 'react-swipeable';
import { twc } from 'react-twc'; import { twc } from 'react-twc';
import { baseColors } from '../../../tailwind.config';
import { useAnalytics } from '../../Hooks/useAnalytics'; import { useAnalytics } from '../../Hooks/useAnalytics';
import { useGlobalSettings } from '../../Hooks/useGlobalSettings'; import { useGlobalSettings } from '../../Hooks/useGlobalSettings';
import { usePlayers } from '../../Hooks/usePlayers'; import { usePlayers } from '../../Hooks/usePlayers';
@@ -15,8 +14,8 @@ import {
import { LoseGameButton } from '../Buttons/LoseButton'; import { LoseGameButton } from '../Buttons/LoseButton';
import CommanderDamageBar from '../Counters/CommanderDamageBar'; import CommanderDamageBar from '../Counters/CommanderDamageBar';
import ExtraCountersBar from '../Counters/ExtraCountersBar'; import ExtraCountersBar from '../Counters/ExtraCountersBar';
import { Paragraph } from '../Misc/TextComponents';
import PlayerMenu from '../Players/PlayerMenu'; import PlayerMenu from '../Players/PlayerMenu';
import { StartingPlayerCard } from '../PreStartGame/StartingPlayerCard';
import Health from './Health'; import Health from './Health';
const SettingsButtonTwc = twc.button<RotationButtonProps>((props) => [ const SettingsButtonTwc = twc.button<RotationButtonProps>((props) => [
@@ -77,8 +76,6 @@ const PlayerLostWrapper = twc.div<RotationDivProps>((props) => [
: '', : '',
]); ]);
const DynamicText = twc.div`text-[8vmin] whitespace-nowrap`;
const hasCommanderDamageReached21 = (player: Player) => { const hasCommanderDamageReached21 = (player: Player) => {
const commanderDamageTotals = player.commanderDamage.map( const commanderDamageTotals = player.commanderDamage.map(
(commanderDamage) => commanderDamage.damageTotal (commanderDamage) => commanderDamage.damageTotal
@@ -116,9 +113,7 @@ const RECENT_DIFFERENCE_TTL = 3_000;
const LifeCounter = ({ player, opponents }: LifeCounterProps) => { const LifeCounter = ({ player, opponents }: LifeCounterProps) => {
const { updatePlayer, updateLifeTotal } = usePlayers(); const { updatePlayer, updateLifeTotal } = usePlayers();
const { settings, playing, setPlaying, stopPlayerRandomization } = const { settings, playing } = useGlobalSettings();
useGlobalSettings();
const playingTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const recentDifferenceTimerRef = useRef<NodeJS.Timeout | undefined>( const recentDifferenceTimerRef = useRef<NodeJS.Timeout | undefined>(
undefined undefined
); );
@@ -138,10 +133,12 @@ const LifeCounter = ({ player, opponents }: LifeCounterProps) => {
trackMouse: true, trackMouse: true,
onSwipedDown: (e) => { onSwipedDown: (e) => {
e.event.stopPropagation(); e.event.stopPropagation();
analytics.trackEvent('open_player_menu_swipe');
setShowPlayerMenu(true); setShowPlayerMenu(true);
}, },
onSwipedUp: (e) => { onSwipedUp: (e) => {
e.event.stopPropagation(); e.event.stopPropagation();
analytics.trackEvent('close_player_menu_swipe');
setShowPlayerMenu(false); setShowPlayerMenu(false);
}, },
@@ -185,28 +182,6 @@ const LifeCounter = ({ player, opponents }: LifeCounterProps) => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [document.body.clientHeight, document.body.clientWidth]); }, [document.body.clientHeight, document.body.clientWidth]);
useEffect(() => {
if (
player.isStartingPlayer &&
((!playing &&
settings.useRandomStartingPlayerInterval &&
stopPlayerRandomization) ||
(!settings.useRandomStartingPlayerInterval && !playing))
) {
playingTimerRef.current = setTimeout(() => {
setPlaying(true);
}, 10_000);
}
return () => clearTimeout(playingTimerRef.current);
}, [
player.isStartingPlayer,
playing,
setPlaying,
settings.useRandomStartingPlayerInterval,
stopPlayerRandomization,
]);
player.settings.rotation === Rotation.SideFlipped || player.settings.rotation === Rotation.SideFlipped ||
player.settings.rotation === Rotation.Side; player.settings.rotation === Rotation.Side;
@@ -227,12 +202,6 @@ const LifeCounter = ({ player, opponents }: LifeCounterProps) => {
? player.settings.rotation - 90 ? player.settings.rotation - 90
: player.settings.rotation; : 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; const amountOfPlayers = opponents.length + 1;
return ( return (
@@ -245,54 +214,12 @@ const LifeCounter = ({ player, opponents }: LifeCounterProps) => {
{amountOfPlayers > 1 && {amountOfPlayers > 1 &&
!playing && !playing &&
settings.showStartingPlayer && settings.showStartingPlayer &&
player.isStartingPlayer && ( player.isStartingPlayer && <StartingPlayerCard player={player} />}
<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:
stopPlayerRandomization ||
!settings.useRandomStartingPlayerInterval
? `radial-gradient(circle at center, ${player.color}, ${baseColors.primary.main})`
: 'none',
}}
onClick={() => {
clearTimeout(playingTimerRef.current);
setPlaying(true);
}}
>
<DynamicText
style={{
rotate: `${calcTextRotation}deg`,
}}
>
<div className="flex flex-col justify-center items-center">
<Paragraph>👑</Paragraph>
{(stopPlayerRandomization ||
!settings.useRandomStartingPlayerInterval) && (
<>
<Paragraph>You start!</Paragraph>
<Paragraph className="text-xl">(Press to hide)</Paragraph>
</>
)}
</div>
</DynamicText>
</div>
)}
{player.hasLost && ( {player.hasLost && (
<PlayerLostWrapper $rotation={player.settings.rotation} /> <PlayerLostWrapper $rotation={player.settings.rotation} />
)} )}
{amountOfPlayers > 1 &&
settings.showStartingPlayer &&
settings.useRandomStartingPlayerInterval &&
!stopPlayerRandomization &&
!playing && (
<div
className="flex absolute w-full h-full justify-center items-center pointer-events-none select-none webkit-user-select-none z-10"
style={{ backgroundColor: player.color }}
/>
)}
<CommanderDamageBar <CommanderDamageBar
opponents={opponents} opponents={opponents}
player={player} player={player}
@@ -302,6 +229,7 @@ const LifeCounter = ({ player, opponents }: LifeCounterProps) => {
{settings.showPlayerMenuCog && ( {settings.showPlayerMenuCog && (
<SettingsButton <SettingsButton
onClick={() => { onClick={() => {
analytics.trackEvent('open_player_menu_button');
setShowPlayerMenu(!showPlayerMenu); setShowPlayerMenu(!showPlayerMenu);
}} }}
rotation={player.settings.rotation} rotation={player.settings.rotation}

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ import {
} from '../../Icons/generated'; } from '../../Icons/generated';
import { Player, Rotation } from '../../Types/Player'; import { Player, Rotation } from '../../Types/Player';
import { RotationDivProps } from '../Buttons/CommanderDamage'; import { RotationDivProps } from '../Buttons/CommanderDamage';
import { useAnalytics } from '../../Hooks/useAnalytics';
const PlayerMenuWrapper = twc.div` const PlayerMenuWrapper = twc.div`
flex flex
@@ -108,9 +109,11 @@ const PlayerMenu = ({
goToStart, goToStart,
settings, settings,
setPlaying, setPlaying,
setStopPlayerRandomization, setRandomizingPlayer,
} = useGlobalSettings(); } = useGlobalSettings();
const analytics = useAnalytics();
const { updatePlayer, resetCurrentGame } = usePlayers(); const { updatePlayer, resetCurrentGame } = usePlayers();
const handleColorChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleColorChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -129,12 +132,13 @@ const PlayerMenu = ({
resetCurrentGame(); resetCurrentGame();
setShowPlayerMenu(false); setShowPlayerMenu(false);
setPlaying(false); setPlaying(false);
setStopPlayerRandomization(false); setRandomizingPlayer(true);
analytics.trackEvent('reset_game');
}; };
const handleGoToStart = () => { const handleGoToStart = () => {
goToStart(); goToStart();
setStopPlayerRandomization(false); setRandomizingPlayer(true);
}; };
const toggleFullscreen = () => { const toggleFullscreen = () => {
@@ -173,7 +177,10 @@ const PlayerMenu = ({
ref={settingsContainerRef} ref={settingsContainerRef}
> >
<button <button
onClick={() => setShowPlayerMenu(false)} 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]" 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 " /> <Cross size={buttonFontSize} className="text-primary-main " />
@@ -187,6 +194,11 @@ const PlayerMenu = ({
type="color" type="color"
className="size-[200%] absolute -left-2 -top-2" className="size-[200%] absolute -left-2 -top-2"
value={player.color} value={player.color}
onClick={() => {
analytics.trackEvent('color_picker_opened', {
player: player.index,
});
}}
/> />
</ColorPickerButton> </ColorPickerButton>
{player.settings.useCommanderDamage && ( {player.settings.useCommanderDamage && (
@@ -210,7 +222,12 @@ const PlayerMenu = ({
strokeWidth="30" strokeWidth="30"
/> />
} }
onChange={handleSettingsChange} onChange={(e) => {
analytics.trackEvent('toggle_partner', {
checked: e.target.checked,
});
handleSettingsChange(e);
}}
role="checkbox" role="checkbox"
aria-checked={player.settings.usePartner} aria-checked={player.settings.usePartner}
aria-label="Partner" aria-label="Partner"
@@ -237,7 +254,12 @@ const PlayerMenu = ({
strokeWidth="30" strokeWidth="30"
/> />
} }
onChange={handleSettingsChange} onChange={(e) => {
analytics.trackEvent('toggle_poison', {
checked: e.target.checked,
});
handleSettingsChange(e);
}}
role="checkbox" role="checkbox"
aria-checked={player.settings.usePoison} aria-checked={player.settings.usePoison}
aria-label="Poison" aria-label="Poison"
@@ -263,7 +285,12 @@ const PlayerMenu = ({
strokeWidth="15" strokeWidth="15"
/> />
} }
onChange={handleSettingsChange} onChange={(e) => {
analytics.trackEvent('toggle_energy', {
checked: e.target.checked,
});
handleSettingsChange(e);
}}
role="checkbox" role="checkbox"
aria-checked={player.settings.useEnergy} aria-checked={player.settings.useEnergy}
aria-label="Energy" aria-label="Energy"
@@ -289,7 +316,12 @@ const PlayerMenu = ({
strokeWidth="15" strokeWidth="15"
/> />
} }
onChange={handleSettingsChange} onChange={(e) => {
analytics.trackEvent('toggle_experience', {
checked: e.target.checked,
});
handleSettingsChange(e);
}}
role="checkbox" role="checkbox"
aria-checked={player.settings.useExperience} aria-checked={player.settings.useExperience}
aria-label="Experience" aria-label="Experience"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
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';
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 />;
}
goToStart();
return null;
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { Player, Rotation } from '../Types/Player'; import { Player, Rotation } from '../Types/Player';
import { InitialGameSettings, Orientation } from '../Types/Settings'; import { InitialGameSettings, Orientation } from '../Types/Settings';
const presetColors = [ export const presetColors = [
'#F06292', // Light Pink '#F06292', // Light Pink
'#4DB6AC', // Teal '#4DB6AC', // Teal
'#FFA726', // Orange '#FFA726', // Orange

View File

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

View File

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

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

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

View File

@@ -46,15 +46,7 @@ export const baseColors = {
}, },
}; };
/** @type {import('tailwindcss').Config} */ export const twGridTemplateAreas = {
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
screens: {
modalSm: '548px',
},
extend: {
gridTemplateAreas: {
onePlayerLandscape: ['player0 player0'], onePlayerLandscape: ['player0 player0'],
onePlayerPortrait: ['player0', 'player0'], onePlayerPortrait: ['player0', 'player0'],
twoPlayersOppositeLandscape: ['player0', 'player1'], twoPlayersOppositeLandscape: ['player0', 'player1'],
@@ -83,7 +75,17 @@ export default {
'player0 player1 player1 player1 player1 player1 player1 player2 player2 player2 player2 player2 player2 player3', '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', '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}'],
theme: {
screens: {
modalSm: '548px',
}, },
extend: {
gridTemplateAreas: twGridTemplateAreas,
colors: baseColors, colors: baseColors,
keyframes: { keyframes: {
fadeOut: { fadeOut: {
@@ -101,6 +103,9 @@ export default {
animation: { animation: {
fadeOut: 'fadeOut 3s 1s ease-out forwards', fadeOut: 'fadeOut 3s 1s ease-out forwards',
}, },
fontSize: {
xxs: ['0.625rem', '1rem'],
},
}, },
}, },
plugins: [tailwindcssGridAreas], plugins: [tailwindcssGridAreas],

View File

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

8867
yarn.lock

File diff suppressed because it is too large Load Diff