Compare commits

...

2 Commits
0.8.1 ... 0.9.0

Author SHA1 Message Date
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
18 changed files with 757 additions and 323 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "life-trinket",
"private": true,
"version": "0.8.1",
"version": "0.9.0",
"type": "commonjs",
"engines": {
"node": ">=18",

View File

@@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useSwipeable } from 'react-swipeable';
import { twc } from 'react-twc';
import { baseColors } from '../../../tailwind.config';
import { useAnalytics } from '../../Hooks/useAnalytics';
import { useGlobalSettings } from '../../Hooks/useGlobalSettings';
import { usePlayers } from '../../Hooks/usePlayers';
@@ -15,8 +14,8 @@ import {
import { LoseGameButton } from '../Buttons/LoseButton';
import CommanderDamageBar from '../Counters/CommanderDamageBar';
import ExtraCountersBar from '../Counters/ExtraCountersBar';
import { Paragraph } from '../Misc/TextComponents';
import PlayerMenu from '../Players/PlayerMenu';
import { StartingPlayerCard } from '../PreStartGame/StartingPlayerCard';
import Health from './Health';
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 commanderDamageTotals = player.commanderDamage.map(
(commanderDamage) => commanderDamage.damageTotal
@@ -116,9 +113,7 @@ const RECENT_DIFFERENCE_TTL = 3_000;
const LifeCounter = ({ player, opponents }: LifeCounterProps) => {
const { updatePlayer, updateLifeTotal } = usePlayers();
const { settings, playing, setPlaying, stopPlayerRandomization } =
useGlobalSettings();
const playingTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const { settings, playing } = useGlobalSettings();
const recentDifferenceTimerRef = useRef<NodeJS.Timeout | undefined>(
undefined
);
@@ -185,28 +180,6 @@ const LifeCounter = ({ player, opponents }: LifeCounterProps) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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.Side;
@@ -227,12 +200,6 @@ 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 (
@@ -245,54 +212,12 @@ const LifeCounter = ({ player, opponents }: LifeCounterProps) => {
{amountOfPlayers > 1 &&
!playing &&
settings.showStartingPlayer &&
player.isStartingPlayer && (
<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.isStartingPlayer && <StartingPlayerCard player={player} />}
{player.hasLost && (
<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
opponents={opponents}
player={player}

View File

@@ -1,11 +1,12 @@
import { Button, FormLabel, Modal, Switch } from '@mui/material';
import { Button, Modal, Switch } from '@mui/material';
import { useEffect, useState } 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 { Cross } from '../../Icons/generated';
const SettingContainer = twc.div`w-full flex flex-col mb-2`;
@@ -84,28 +85,35 @@ export const SettingsModal = ({ isOpen, closeModal }: SettingsModalProps) => {
<ModalWrapper>
<Container>
<h2 className="text-center text-2xl mb-2"> Settings </h2>
<Separator height="1px" />
<SettingContainer>
<ToggleContainer>
<FormLabel>Show Start Player</FormLabel>
<Switch
checked={settings.showStartingPlayer}
onChange={() => {
setSettings({
...settings,
showStartingPlayer: !settings.showStartingPlayer,
});
}}
/>
</ToggleContainer>
<Description>
On start or reset of game, will pick a random player who will
start first if this is enabled.
</Description>
<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" />
<SettingContainer>
<ToggleContainer>
<FormLabel>Show Player Menu Cog</FormLabel>
<label>Show Player Menu Cog</label>
<Switch
checked={settings.showPlayerMenuCog}
onChange={() => {
@@ -123,27 +131,73 @@ export const SettingsModal = ({ isOpen, closeModal }: SettingsModalProps) => {
</SettingContainer>
<SettingContainer>
<ToggleContainer>
<FormLabel>Randomize starting player with interval</FormLabel>
<label>Show Start Player</label>
<Switch
checked={settings.useRandomStartingPlayerInterval}
checked={settings.showStartingPlayer}
onChange={() => {
setSettings({
...settings,
useRandomStartingPlayerInterval:
!settings.useRandomStartingPlayerInterval,
showStartingPlayer: !settings.showStartingPlayer,
});
}}
/>
</ToggleContainer>
<Description>
Will randomize between all players at when starting a game,
pressing the screen aborts the interval and chooses the player
that has the crown.
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">Pre-Start mode</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}>None</option>
<option value={PreStartMode.RandomKing}>Random King</option>
<option value={PreStartMode.FingerGame}>Finger Game</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">None:</span> The starting
player will simply be shown.
</div>
)}
{settings.preStartMode === PreStartMode.RandomKing && (
<div className="text-xs text-left text-text-secondary mt-1">
<span className="text-text-primary">Random King:</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">Finger Game:</span> All
players put a finger on the screen, one will be chosen at
random.
</div>
)}
</SettingContainer>
<SettingContainer>
<ToggleContainer>
<FormLabel>Keep Awake</FormLabel>
<label>Keep Awake</label>
<Switch
checked={settings.keepAwake}
onChange={() => {
@@ -161,7 +215,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={() => {
@@ -197,31 +254,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"

View File

@@ -108,7 +108,7 @@ const PlayerMenu = ({
goToStart,
settings,
setPlaying,
setStopPlayerRandomization,
setRandomizingPlayer,
} = useGlobalSettings();
const { updatePlayer, resetCurrentGame } = usePlayers();
@@ -129,12 +129,12 @@ const PlayerMenu = ({
resetCurrentGame();
setShowPlayerMenu(false);
setPlaying(false);
setStopPlayerRandomization(false);
setRandomizingPlayer(true);
};
const handleGoToStart = () => {
goToStart();
setStopPlayerRandomization(false);
setRandomizingPlayer(true);
};
const toggleFullscreen = () => {

View File

@@ -1,9 +1,8 @@
import { useEffect, useRef } from 'react';
import { twc } from 'react-twc';
import { useGlobalSettings } from '../../Hooks/useGlobalSettings';
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) {
@@ -26,118 +25,14 @@ const getGridArea = (player: PlayerType) => {
const PlayersWrapper = twc.div`w-full h-full bg-black`;
export const Players = (players: PlayerType[], gridClasses: string) => {
const randomIntervalRef = useRef<NodeJS.Timeout | null>(null);
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(', ');
export const Players = ({ gridLayout }: { gridLayout: GridLayout }) => {
const { players } = usePlayers();
return (
<PlayersWrapper>
{players.length > 1 &&
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} `}>
<div className={`grid w-full h-full gap-1 box-border ${gridLayout} `}>
{players.map((player) => {
const gridArea = getGridArea(player);
return (
<div
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 { usePlayers } from '../../Hooks/usePlayers';
import { Orientation } from '../../Types/Settings';
import { Orientation, PreStartMode } from '../../Types/Settings';
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 = () => {
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 = Players(players, 'grid-areas-onePlayerPortrait');
gridLayout = 'grid-areas-onePlayerPortrait';
}
Layout = Players(players, 'grid-areas-onePlayerLandscape');
gridLayout = 'grid-areas-onePlayerLandscape';
break;
case 2:
switch (initialGameSettings?.orientation) {
case Orientation.Portrait:
Layout = Players(players, 'grid-areas-twoPlayersOppositePortrait');
gridLayout = 'grid-areas-twoPlayersOppositePortrait';
break;
default:
case Orientation.Landscape:
Layout = Players(players, 'grid-areas-twoPlayersSameSideLandscape');
gridLayout = 'grid-areas-twoPlayersSameSideLandscape';
break;
case Orientation.OppositeLandscape:
Layout = Players(players, 'grid-areas-twoPlayersOppositeLandscape');
gridLayout = 'grid-areas-twoPlayersOppositeLandscape';
break;
}
break;
case 3:
if (initialGameSettings?.orientation === Orientation.Portrait) {
Layout = Players(players, 'grid-areas-threePlayersSide');
gridLayout = 'grid-areas-threePlayersSide';
break;
}
Layout = Players(players, 'grid-areas-threePlayers');
gridLayout = 'grid-areas-threePlayers';
break;
default:
case 4:
if (initialGameSettings?.orientation === Orientation.Portrait) {
Layout = Players(players, 'grid-areas-fourPlayerPortrait');
gridLayout = 'grid-areas-fourPlayerPortrait';
break;
}
Layout = Players(players, 'grid-areas-fourPlayer');
gridLayout = 'grid-areas-fourPlayer';
break;
case 5:
if (initialGameSettings?.orientation === Orientation.Portrait) {
Layout = Players(players, 'grid-areas-fivePlayersSide');
gridLayout = 'grid-areas-fivePlayersSide';
break;
}
Layout = Players(players, 'grid-areas-fivePlayers');
gridLayout = 'grid-areas-fivePlayers';
break;
case 6:
if (initialGameSettings?.orientation === Orientation.Portrait) {
Layout = Players(players, 'grid-areas-sixPlayersSide');
gridLayout = 'grid-areas-sixPlayersSide';
break;
}
Layout = Players(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 (
!preStartCompleted &&
settings.preStartMode !== PreStartMode.None &&
!playing &&
settings.showStartingPlayer
) {
return (
<MainWrapper>
<PreStart gridLayout={gridLayout} />
</MainWrapper>
);
}
return (
<MainWrapper>
<Players gridLayout={gridLayout} />
</MainWrapper>
);
};

View File

@@ -12,6 +12,7 @@ import {
GameFormat,
InitialGameSettings,
Orientation,
PreStartMode,
} from '../../../Types/Settings';
import { InfoModal } from '../../Misc/InfoModal';
import { SettingsModal } from '../../Misc/SettingsModal';
@@ -89,6 +90,7 @@ const Start = () => {
setInitialGameSettings,
settings,
isPWA,
setRandomizingPlayer,
} = useGlobalSettings();
const [openInfoModal, setOpenInfoModal] = useState(false);
@@ -126,6 +128,7 @@ const Start = () => {
setInitialGameSettings(initialGameSettings);
setPlayers(createInitialPlayers(initialGameSettings));
setShowPlay(true);
setRandomizingPlayer(settings.preStartMode === PreStartMode.RandomKing);
localStorage.setItem('playing', 'false');
localStorage.setItem('showPlay', 'true');
};

View File

@@ -24,9 +24,11 @@ export type GlobalSettingsContextType = {
setSettings: (settings: Settings) => void;
playing: boolean;
setPlaying: (playing: boolean) => void;
stopPlayerRandomization: boolean;
setStopPlayerRandomization: (stopRandom: boolean) => void;
randomizingPlayer: boolean;
setRandomizingPlayer: (stopRandom: boolean) => void;
isPWA: boolean;
preStartCompleted: boolean;
setPreStartCompleted: (completed: boolean) => void;
};
export const GlobalSettingsContext =

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

View File

@@ -7,8 +7,10 @@ import {
import { useAnalytics } from '../Hooks/useAnalytics';
import {
InitialGameSettings,
InitialGameSettingsSchema,
initialGameSettingsSchema,
PreStartMode,
Settings,
settingsSchema,
} from '../Types/Settings';
export const GlobalSettingsProvider = ({
@@ -22,6 +24,7 @@ export const GlobalSettingsProvider = ({
const savedGameSettings = localStorage.getItem('initialGameSettings');
const savedSettings = localStorage.getItem('settings');
const savedPlaying = localStorage.getItem('playing');
const savedPreStartComplete = localStorage.getItem('preStartComplete');
const [playing, setPlaying] = useState<boolean>(
savedPlaying ? savedPlaying === 'true' : false
@@ -31,38 +34,55 @@ export const GlobalSettingsProvider = ({
localStorage.setItem('playing', String(playing));
};
const [preStartCompleted, setPreStartCompleted] = useState<boolean>(
savedPreStartComplete ? savedPreStartComplete === 'true' : false
);
const [showPlay, setShowPlay] = useState<boolean>(
savedShowPlay ? savedShowPlay === 'true' : false
);
const [stopPlayerRandomization, setStopPlayerRandomization] =
useState<boolean>(false);
const [randomizingPlayer, setRandomizingPlayer] = useState<boolean>(
savedSettings
? Boolean(JSON.parse(savedSettings).preStartMode === 'random-king')
: true
);
const [initialGameSettings, setInitialGameSettings] =
useState<InitialGameSettings | null>(
savedGameSettings ? JSON.parse(savedGameSettings) : null
);
const parsedSettings = settingsSchema.safeParse(
JSON.parse(savedSettings ?? '')
);
const [settings, setSettings] = useState<Settings>(
savedSettings
? JSON.parse(savedSettings)
parsedSettings.success
? parsedSettings.data
: {
goFullscreenOnStart: true,
keepAwake: true,
showStartingPlayer: true,
showPlayerMenuCog: true,
useRandomStartingPlayerInterval: false,
preStartMode: PreStartMode.None,
}
);
const setSettingsAndLocalStorage = (settings: Settings) => {
setSettings(settings);
localStorage.setItem('settings', JSON.stringify(settings));
};
const removeLocalStorage = async () => {
localStorage.removeItem('initialGameSettings');
localStorage.removeItem('players');
localStorage.removeItem('playing');
localStorage.removeItem('showPlay');
localStorage.removeItem('preStartComplete');
setPlaying(false);
setShowPlay(false);
setPreStartCompleted(false);
};
useEffect(() => {
@@ -74,7 +94,7 @@ export const GlobalSettingsProvider = ({
//parse existing game settings with zod schema
const parsedInitialGameSettings =
InitialGameSettingsSchema.safeParse(initialGameSettings);
initialGameSettingsSchema.safeParse(initialGameSettings);
if (!parsedInitialGameSettings.success) {
removeLocalStorage();
@@ -88,10 +108,6 @@ export const GlobalSettingsProvider = ({
);
}, [initialGameSettings, savedGameSettings]);
useEffect(() => {
localStorage.setItem('settings', JSON.stringify(settings));
}, [settings]);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
@@ -155,6 +171,11 @@ export const GlobalSettingsProvider = ({
}
};
const setPreStartCompletedAndLocalStorage = (preStartComplete: boolean) => {
setPreStartCompleted(preStartComplete);
localStorage.setItem('playing', String(playing));
};
return {
fullscreen: { isFullscreen, enableFullscreen, disableFullscreen },
wakeLock: {
@@ -173,24 +194,27 @@ export const GlobalSettingsProvider = ({
initialGameSettings,
setInitialGameSettings,
settings,
setSettings,
stopPlayerRandomization,
setStopPlayerRandomization,
setSettings: setSettingsAndLocalStorage,
randomizingPlayer,
setRandomizingPlayer,
isPWA: window?.matchMedia('(display-mode: standalone)').matches,
preStartCompleted,
setPreStartCompleted: setPreStartCompletedAndLocalStorage,
};
}, [
active,
analytics,
initialGameSettings,
isFullscreen,
isSupported,
playing,
release,
active,
request,
settings,
showPlay,
stopPlayerRandomization,
type,
showPlay,
playing,
initialGameSettings,
settings,
randomizingPlayer,
preStartCompleted,
analytics,
]);
return (

View File

@@ -12,12 +12,18 @@ export enum GameFormat {
TwoHeadedGiant = 'two-headed-giant',
}
export enum PreStartMode {
None = 'none',
RandomKing = 'random-king',
FingerGame = 'finger-game',
}
export type Settings = {
keepAwake: boolean;
showStartingPlayer: boolean;
showPlayerMenuCog: boolean;
goFullscreenOnStart: boolean;
useRandomStartingPlayerInterval: boolean;
preStartMode: PreStartMode;
};
export type InitialGameSettings = {
@@ -28,10 +34,18 @@ export type InitialGameSettings = {
orientation: Orientation;
};
export const InitialGameSettingsSchema = z.object({
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 settingsSchema = z.object({
keepAwake: z.boolean().default(true),
showStartingPlayer: z.boolean().default(true),
showPlayerMenuCog: z.boolean().default(true),
goFullscreenOnStart: z.boolean().default(true),
preStartMode: z.nativeEnum(PreStartMode).default(PreStartMode.None),
});

View File

@@ -46,6 +46,37 @@ export const baseColors = {
},
};
export const twGridTemplateAreas = {
onePlayerLandscape: ['player0 player0'],
onePlayerPortrait: ['player0', 'player0'],
twoPlayersOppositeLandscape: ['player0', 'player1'],
twoPlayersOppositePortrait: ['player0 player1', 'player0 player1'],
twoPlayersSameSideLandscape: ['player0 player1'],
threePlayers: ['player0 player0', 'player1 player2'],
threePlayersSide: [
'player0 player0 player0 player2',
'player1 player1 player1 player2',
],
fourPlayerPortrait: [
'player0 player1 player1 player1 player1 player3',
'player0 player2 player2 player2 player2 player3',
],
fourPlayer: ['player0 player1', 'player2 player3'],
fivePlayers: [
'player0 player0 player0 player1 player1 player1',
'player2 player2 player3 player3 player4 player4',
],
fivePlayersSide: [
'player0 player0 player0 player0 player0 player1 player1 player1 player1 player1 player2',
'player3 player3 player3 player3 player3 player4 player4 player4 player4 player4 player2',
],
sixPlayers: ['player0 player1 player2', 'player3 player4 player5'],
sixPlayersSide: [
'player0 player1 player1 player1 player1 player1 player1 player2 player2 player2 player2 player2 player2 player3',
'player0 player4 player4 player4 player4 player4 player4 player5 player5 player5 player5 player5 player5 player3',
],
};
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
@@ -54,36 +85,7 @@ 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 player1 player1 player2 player2 player2 player2 player2 player2 player3',
'player0 player4 player4 player4 player4 player4 player4 player5 player5 player5 player5 player5 player5 player3',
],
},
gridTemplateAreas: twGridTemplateAreas,
colors: baseColors,
keyframes: {
fadeOut: {