Compare commits

..

1 Commits

Author SHA1 Message Date
Viktor Rådberg
23ab7c4e46 wip 2023-10-21 15:36:53 +02:00
47 changed files with 1926 additions and 11042 deletions

View File

@@ -1,8 +1,8 @@
index.html,1705225256081,6ef0d7e2de82bf64addbb9294fb28845fd06daaa544b010a47422c12ae3ad97f robots.txt,1693082171694,391d14b3c2f8c9143a27a28c7399585142228d4d1bdbe2c87ac946de411fa9a2
robots.txt,1705225255906,391d14b3c2f8c9143a27a28c7399585142228d4d1bdbe2c87ac946de411fa9a2 manifest.json,1693082171694,91ce94afb71f33a477f5d8d48c3f98bd7de422279c74f17b6500eec72003ac1a
manifest.json,1705225255906,91ce94afb71f33a477f5d8d48c3f98bd7de422279c74f17b6500eec72003ac1a assets/index-5265c558.css,1693082171837,08c4451946bbdf520fe337edb365417a8bbf91914c018b83866723ef52d57b43
assets/index-08359bdb.css,1705225256081,d2766260d28230d960d75362810713efaddf40687205e697432b52869f162af7 index.html,1693082171837,09e1919fbaaa3a0bf08f43eb46c29136d62a7747b41f8b5d0f4a7ed23337c344
logo192.png,1705225255905,3b0fcf91fe2128f493de0bce2f6e2d35520a4260a04e05b8d855181359b3d3fe logo192.png,1693082171693,4309255bccbdbb341b5ab88708677e3d43b9e171d2666528ff932295a8257e4e
favicon.ico,1705225255905,75661e6187b524767554b4f28ec09a64bc72b0bb102a0b453aaead88519d9ed3 favicon.ico,1693082171692,48d8c1b9714dbc9bcb012d9c9f04112d229f20e6c889bda588ac159f973e6a8d
logo512.png,1705225255906,cf49739c9e6890bbfcd4157f299dde425df60759b7320ae9188d7ab9dc51e8ca logo512.png,1693082171694,92c7c05dc98170596d04f48e5e60eaae9535f409bcaeff129fd98fef8aba9f4e
assets/index-20658f4b.js,1705225256081,742f2c10740beea3a23f269aa6266b3c288d1fd9c7e20b6829034e8a898bf1e1 assets/index-5023e89e.js,1693082171838,8a6177168e95e1ca90e5ad8774252a8a02a9a78765bd329b7deae729c01aedf3

View File

@@ -0,0 +1,20 @@
# This file was auto-generated by the Firebase CLI
# https://github.com/firebase/firebase-tools
name: Deploy to Firebase Hosting on merge
'on':
push:
branches:
- main
jobs:
build_and_deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: oven-sh/setup-bun@v1
- run: bun install && bun run build && bun run lint
- uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: '${{ secrets.GITHUB_TOKEN }}'
firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_LIFE_TRINKET }}'
channelId: live
projectId: life-trinket

View File

@@ -1,58 +0,0 @@
name: Deploy to Firebase Hosting
'on':
push:
tags:
- '*'
jobs:
build_and_deploy:
runs-on: ubuntu-latest
env:
REPO_READ_ACCESS_TOKEN: ${{ secrets.REPO_READ_ACCESS_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup bun
uses: oven-sh/setup-bun@v1
- name: Build, lint, and deploy
run: |
bun install
bun run build
bun run lint
- name: Deploy to Firebase Hosting
uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: '${{ secrets.GITHUB_TOKEN }}'
firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_LIFE_TRINKET }}'
channelId: live
projectId: life-trinket
release:
needs: build_and_deploy
runs-on: ubuntu-latest
env:
working-directory: ${{ github.workspace }}
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: get version
id: version
uses: notiz-dev/github-action-json-property@v0.2.0
with:
path: 'package.json'
prop_path: 'version'
- name: Create Release Note
id: create_release_note
run: echo "Release Note for version ${{ steps.version.outputs.prop }}" > release_note.txt
- name: Create Release
uses: ncipollo/release-action@v1.13.0
with:
bodyFile: release_note.txt
commit: ${{ github.sha }}
tag: '${{ steps.version.outputs.prop }}'
token: ${{ secrets.RELEASE_TOKEN }}

View File

@@ -1,12 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.5.0] - 2024-01-13
### Changed
- Styling with Tailwind CSS instead of styled-components

BIN
bun.lockb

Binary file not shown.

1
env.d.ts vendored
View File

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

View File

@@ -1,7 +1,7 @@
{ {
"name": "life-trinket", "name": "life-trinket",
"private": true, "private": true,
"version": "0.6.7", "version": "0.4.0",
"type": "commonjs", "type": "commonjs",
"engines": { "engines": {
"node": ">=18", "node": ">=18",
@@ -13,7 +13,7 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview", "preview": "vite preview",
"generate-icons": "npx @svgr/cli src/Icons/svgs", "generate-icons": "npx @svgr/cli src/Icons/svgs",
"deploy": "bun run build && firebase deploy --only hosting" "deploy": "bun build && firebase deploy --only hosting"
}, },
"dependencies": { "dependencies": {
"@mui/material": "^5.13.6", "@mui/material": "^5.13.6",
@@ -22,31 +22,23 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-screen-wake-lock": "^3.0.2", "react-screen-wake-lock": "^3.0.2",
"react-swipeable": "^7.0.1", "styled-components": "^6.0.7"
"react-twc": "^1.3.0",
"zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@savvywombat/tailwindcss-grid-areas": "^3.1.0",
"@svgr/cli": "^8.1.0", "@svgr/cli": "^8.1.0",
"@types/react": "^18.2.15", "@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0", "@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react-swc": "^3.3.2", "@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.16",
"eslint": "^8.45.0", "eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3", "eslint-plugin-react-refresh": "^0.4.3",
"firebase-tools": "^12.5.2", "firebase-tools": "^12.5.2",
"install": "^0.13.0",
"postcss": "^8.4.32",
"prettier": "2.8.8", "prettier": "2.8.8",
"tailwindcss": "^3.4.1", "typescript": "^5.0.2",
"typescript": "^5.3.3", "vite": "^4.4.5"
"vite": "^5.0.12",
"vite-plugin-pwa": "^0.17.4"
} }
} }

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,12 +1,24 @@
import { createGlobalStyle } from 'styled-components';
import { ThemeProvider } from '@mui/material'; import { ThemeProvider } from '@mui/material';
import { LifeTrinket } from './Components/LifeTrinket'; import { LifeTrinket } from './Components/LifeTrinket';
import { theme } from './Data/theme'; import { theme } from './Data/theme';
import { GlobalSettingsProvider } from './Providers/GlobalSettingsProvider'; import { GlobalSettingsProvider } from './Providers/GlobalSettingsProvider';
import { PlayersProvider } from './Providers/PlayersProvider'; import { PlayersProvider } from './Providers/PlayersProvider';
const GlobalStyles = createGlobalStyle`
html,
body {
background-color: ${theme.palette.background.default};
}
#root {
touch-action: manipulation;
}
`;
const App = () => { const App = () => {
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<GlobalStyles />
<PlayersProvider> <PlayersProvider>
<GlobalSettingsProvider> <GlobalSettingsProvider>
<LifeTrinket /> <LifeTrinket />

View File

@@ -1,51 +1,110 @@
import styled from 'styled-components';
import { css } from 'styled-components';
import { Player, Rotation } from '../../Types/Player';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { TwcComponentProps, twc } from 'react-twc'; import { OutlinedText } from '../Misc/OutlinedText';
import { decrementTimeoutMs } from '../../Data/constants'; import { decrementTimeoutMs } from '../../Data/constants';
import { usePlayers } from '../../Hooks/usePlayers'; import { usePlayers } from '../../Hooks/usePlayers';
import { Player, Rotation } from '../../Types/Player';
import { OutlinedText } from '../Misc/OutlinedText';
export type RotationDivProps = TwcComponentProps<'div'> & { const CommanderDamageContainer = styled.div<{
$rotation?: number; $rotation: number;
}; }>`
display: flex;
flex-direction: row;
flex-grow: 1;
width: 100%;
export type RotationSpanProps = TwcComponentProps<'span'> & { ${(props) => {
$rotation?: number; if (
}; props.$rotation === Rotation.SideFlipped ||
props.$rotation === Rotation.Side
) {
return css`
flex-direction: column;
`;
}
}}
`;
export type RotationButtonProps = TwcComponentProps<'button'> & { const CommanderDamageButton = styled.button<{
$rotation?: number; $backgroundColor?: string;
}; $rotation: number;
}>`
display: flex;
flex-grow: 1;
border: none;
height: 10vmin;
width: 50%;
outline: none;
cursor: pointer;
background-color: ${(props) => props.$backgroundColor || 'antiquewhite'};
margin: 0;
user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
-moz-user-select: -moz-none;
-webkit-user-select: none;
-ms-user-select: none;
padding: 0;
${(props) => {
if (
props.$rotation === Rotation.SideFlipped ||
props.$rotation === Rotation.Side
) {
return css`
width: 6vmax;
height: auto;
`;
}
}}
`;
export const MAX_TAP_MOVE_DISTANCE = 20; const CommanderDamageTextContainer = styled.div<{
$rotation: number;
}>`
position: relative;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-variant-numeric: tabular-nums;
pointer-events: none;
user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
-moz-user-select: -moz-none;
-webkit-user-select: none;
-ms-user-select: none;
const CommanderDamageContainer = twc.div<RotationDivProps>((props) => [ ${(props) => {
'flex flex-grow', if (
props.$rotation === Rotation.SideFlipped || props.$rotation === Rotation.Side props.$rotation === Rotation.SideFlipped ||
? 'flex-col' props.$rotation === Rotation.Side
: 'flex-row', ) {
]); return css`
rotate: 270deg;
`;
}
}}
`;
const CommanderDamageButton = twc.button<RotationButtonProps>((props) => [ const PartnerDamageSeperator = styled.div<{
'flex flex-grow border-none outline-none cursor-pointer m-0 p-0 webkit-user-select-none', $rotation: number;
props.$rotation === Rotation.SideFlipped || props.$rotation === Rotation.Side }>`
? 'w-[6vmax] h-auto' width: 1px;
: 'h-[10vmin] w-1/2', background-color: rgba(0, 0, 0, 1);
]);
const CommanderDamageTextContainer = twc.div<RotationDivProps>((props) => [ ${(props) => {
'relative top-1/2 left-1/2 tabular-nums pointer-events-none select-none webkit-user-select-none', if (
props.$rotation === Rotation.SideFlipped || props.$rotation === Rotation.Side props.$rotation === Rotation.SideFlipped ||
? 'rotate-[270deg]' props.$rotation === Rotation.Side
: '', ) {
]); return css`
width: auto;
const PartnerDamageSeparator = twc.div<RotationDivProps>((props) => [ height: 1px;
'bg-black', `;
props.$rotation === Rotation.SideFlipped || props.$rotation === Rotation.Side }
? 'w-full h-px' }}
: 'w-px', `;
]);
type CommanderDamageButtonComponentProps = { type CommanderDamageButtonComponentProps = {
player: Player; player: Player;
@@ -56,7 +115,6 @@ type CommanderDamageButtonComponentProps = {
type InputProps = { type InputProps = {
opponentIndex: number; opponentIndex: number;
isPartner: boolean; isPartner: boolean;
event: React.PointerEvent<HTMLButtonElement>;
}; };
export const CommanderDamage = ({ export const CommanderDamage = ({
@@ -66,8 +124,12 @@ export const CommanderDamage = ({
}: CommanderDamageButtonComponentProps) => { }: CommanderDamageButtonComponentProps) => {
const { updatePlayer } = usePlayers(); const { updatePlayer } = usePlayers();
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined); const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const [downLongPressed, setDownLongPressed] = useState(false); const [timeoutFinished, setTimeoutFinished] = useState(false);
const downPositionRef = useRef({ x: 0, y: 0 }); const [hasPressedDown, setHasPressedDown] = useState(false);
const isSide =
player.settings.rotation === Rotation.Side ||
player.settings.rotation === Rotation.SideFlipped;
const handleCommanderDamageChange = ( const handleCommanderDamageChange = (
index: number, index: number,
@@ -106,47 +168,34 @@ export const CommanderDamage = ({
handleLifeChange(player.lifeTotal - increment); handleLifeChange(player.lifeTotal - increment);
}; };
const handleDownInput = ({ opponentIndex, isPartner, event }: InputProps) => { const handleDownInput = ({ opponentIndex, isPartner }: InputProps) => {
downPositionRef.current = { x: event.clientX, y: event.clientY }; setTimeoutFinished(false);
setDownLongPressed(false); setHasPressedDown(true);
timeoutRef.current = setTimeout(() => { timeoutRef.current = setTimeout(() => {
setDownLongPressed(true); setTimeoutFinished(true);
handleCommanderDamageChange(opponentIndex, -1, isPartner); handleCommanderDamageChange(opponentIndex, -1, isPartner);
}, decrementTimeoutMs); }, decrementTimeoutMs);
}; };
const handleUpInput = ({ opponentIndex, isPartner, event }: InputProps) => { const handleUpInput = ({ opponentIndex, isPartner }: InputProps) => {
if (downLongPressed) { if (!(hasPressedDown && !timeoutFinished)) {
return; return;
} }
const upPosition = { x: event.clientX, y: event.clientY };
const hasMoved =
Math.abs(upPosition.x - downPositionRef.current.x) >
MAX_TAP_MOVE_DISTANCE ||
Math.abs(upPosition.y - downPositionRef.current.y) >
MAX_TAP_MOVE_DISTANCE;
if (hasMoved) {
return;
}
clearTimeout(timeoutRef.current); clearTimeout(timeoutRef.current);
handleCommanderDamageChange(opponentIndex, 1, isPartner); handleCommanderDamageChange(opponentIndex, 1, isPartner);
setHasPressedDown(false);
}; };
const handleLeaveInput = () => { const handleLeaveInput = () => {
setDownLongPressed(true); setTimeoutFinished(true);
clearTimeout(timeoutRef.current); clearTimeout(timeoutRef.current);
setHasPressedDown(false);
}; };
const opponentIndex = opponent.index; const opponentIndex = opponent.index;
const fontSize = player.isSide ? '4vmax' : '7vmin'; const fontSize = isSide ? '4vmax' : '7vmin';
const fontWeight = 'bold'; const fontWeight = 'bold';
const strokeWidth = player.isSide ? '0.4vmax' : '0.7vmin'; const strokeWidth = isSide ? '0.4vmax' : '0.7vmin';
return ( return (
<CommanderDamageContainer <CommanderDamageContainer
@@ -157,18 +206,16 @@ export const CommanderDamage = ({
<CommanderDamageButton <CommanderDamageButton
key={opponentIndex} key={opponentIndex}
$rotation={player.settings.rotation} $rotation={player.settings.rotation}
onPointerDown={(e) => onPointerDown={() =>
handleDownInput({ opponentIndex, isPartner: false, event: e }) handleDownInput({ opponentIndex, isPartner: false })
}
onPointerUp={(e) =>
handleUpInput({ opponentIndex, isPartner: false, event: e })
} }
onPointerUp={() => handleUpInput({ opponentIndex, isPartner: false })}
onPointerLeave={handleLeaveInput} onPointerLeave={handleLeaveInput}
onContextMenu={(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { onContextMenu={(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault(); e.preventDefault();
}} }}
$backgroundColor={opponent.color}
aria-label={`Commander damage. Player ${player.index}, opponent ${opponent.index}`} aria-label={`Commander damage. Player ${player.index}, opponent ${opponent.index}`}
style={{ background: opponent.color }}
> >
<CommanderDamageTextContainer $rotation={player.settings.rotation}> <CommanderDamageTextContainer $rotation={player.settings.rotation}>
<OutlinedText <OutlinedText
@@ -185,15 +232,15 @@ export const CommanderDamage = ({
{opponent.settings.usePartner && ( {opponent.settings.usePartner && (
<> <>
<PartnerDamageSeparator $rotation={player.settings.rotation} /> <PartnerDamageSeperator $rotation={player.settings.rotation} />
<CommanderDamageButton <CommanderDamageButton
key={opponentIndex} key={opponentIndex}
$rotation={player.settings.rotation} $rotation={player.settings.rotation}
onPointerDown={(e) => onPointerDown={() =>
handleDownInput({ opponentIndex, isPartner: true, event: e }) handleDownInput({ opponentIndex, isPartner: true })
} }
onPointerUp={(e) => onPointerUp={() =>
handleUpInput({ opponentIndex, isPartner: true, event: e }) handleUpInput({ opponentIndex, isPartner: true })
} }
onPointerLeave={handleLeaveInput} onPointerLeave={handleLeaveInput}
onContextMenu={( onContextMenu={(
@@ -201,8 +248,8 @@ export const CommanderDamage = ({
) => { ) => {
e.preventDefault(); e.preventDefault();
}} }}
$backgroundColor={opponent.color}
aria-label={`Partner Commander damage. Player ${player.index}, opponent ${opponent.index}`} aria-label={`Partner Commander damage. Player ${player.index}, opponent ${opponent.index}`}
style={{ background: opponent.color }}
> >
<CommanderDamageTextContainer $rotation={player.settings.rotation}> <CommanderDamageTextContainer $rotation={player.settings.rotation}>
<OutlinedText <OutlinedText

View File

@@ -1,45 +1,57 @@
import { ReactNode, useRef, useState } from 'react'; import { ReactNode, useRef, useState } from 'react';
import { twc } from 'react-twc'; import styled from 'styled-components';
import { css } from 'styled-components';
import { decrementTimeoutMs } from '../../Data/constants'; import { decrementTimeoutMs } from '../../Data/constants';
import { CounterType, Rotation } from '../../Types/Player'; import { CounterType, Rotation } from '../../Types/Player';
import { OutlinedText } from '../Misc/OutlinedText'; import { OutlinedText } from '../Misc/OutlinedText';
import { MAX_TAP_MOVE_DISTANCE, RotationDivProps } from './CommanderDamage';
const ExtraCounterContainer = twc.div` const ExtraCounterContainer = styled.div`
flex display: flex;
justify-center justify-content: center;
items-center align-items: center;
pointer-events-all pointer-events: all;
flex-grow flex-grow: 1;
`; `;
const ExtraCounterButton = twc.button` export const StyledExtraCounterButton = styled.button`
flex position: relative;
justify-center flex-grow: 1;
items-center border: none;
relative outline: none;
flex-grow cursor: pointer;
border-none background-color: transparent;
outline-none user-select: none;
cursor-pointer -webkit-touch-callout: none;
bg-transparent -webkit-tap-highlight-color: transparent;
select-none -moz-user-select: -moz-none;
h-full -webkit-user-select: none;
webkit-user-select-none -ms-user-select: none;
`; height: 100%;
`;
const IconContainer = twc.div<RotationDivProps>((props) => [ const IconContainer = styled.div<{
'w-auto', $rotation: number;
props.$rotation === Rotation.SideFlipped || props.$rotation === Rotation.Side }>`
? 'rotate-[-90deg]' width: auto;
: '',
]);
const TextContainer = twc.div` ${(props) => {
absolute if (
top-1/2 props.$rotation === Rotation.SideFlipped ||
left-1/2 props.$rotation === Rotation.Side
`; ) {
return css`
rotate: -90deg;
`;
}
}}
`;
const TextContainer = styled.div`
position: absolute;
translate: -50%;
top: 50%;
left: 50%;
`;
type ExtraCounterProps = { type ExtraCounterProps = {
Icon: ReactNode; Icon: ReactNode;
@@ -47,7 +59,6 @@ type ExtraCounterProps = {
type: CounterType; type: CounterType;
setCounterTotal: (updatedCounterTotal: number, type: CounterType) => void; setCounterTotal: (updatedCounterTotal: number, type: CounterType) => void;
rotation: number; rotation: number;
isSide: boolean;
playerIndex: number; playerIndex: number;
}; };
@@ -57,13 +68,14 @@ const ExtraCounter = ({
setCounterTotal, setCounterTotal,
type, type,
rotation, rotation,
isSide,
playerIndex, playerIndex,
}: ExtraCounterProps) => { }: ExtraCounterProps) => {
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined); const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const [timeoutFinished, setTimeoutFinished] = useState(false); const [timeoutFinished, setTimeoutFinished] = useState(false);
const [hasPressedDown, setHasPressedDown] = useState(false); const [hasPressedDown, setHasPressedDown] = useState(false);
const downPositionRef = useRef({ x: 0, y: 0 });
const isSide =
rotation === Rotation.Side || rotation === Rotation.SideFlipped;
const handleCountChange = (increment: number) => { const handleCountChange = (increment: number) => {
if (!counterTotal) { if (!counterTotal) {
@@ -73,8 +85,7 @@ const ExtraCounter = ({
setCounterTotal(counterTotal + increment, type); setCounterTotal(counterTotal + increment, type);
}; };
const handleDownInput = (event: React.PointerEvent<HTMLButtonElement>) => { const handleDownInput = () => {
downPositionRef.current = { x: event.clientX, y: event.clientY };
setTimeoutFinished(false); setTimeoutFinished(false);
setHasPressedDown(true); setHasPressedDown(true);
timeoutRef.current = setTimeout(() => { timeoutRef.current = setTimeout(() => {
@@ -83,23 +94,10 @@ const ExtraCounter = ({
}, decrementTimeoutMs); }, decrementTimeoutMs);
}; };
const handleUpInput = (event: React.PointerEvent<HTMLButtonElement>) => { const handleUpInput = () => {
if (!(hasPressedDown && !timeoutFinished)) { if (!(hasPressedDown && !timeoutFinished)) {
return; return;
} }
const upPosition = { x: event.clientX, y: event.clientY };
const hasMoved =
Math.abs(upPosition.x - downPositionRef.current.x) >
MAX_TAP_MOVE_DISTANCE ||
Math.abs(upPosition.y - downPositionRef.current.y) >
MAX_TAP_MOVE_DISTANCE;
if (hasMoved) {
return;
}
clearTimeout(timeoutRef.current); clearTimeout(timeoutRef.current);
handleCountChange(1); handleCountChange(1);
setHasPressedDown(false); setHasPressedDown(false);
@@ -117,7 +115,7 @@ const ExtraCounter = ({
return ( return (
<ExtraCounterContainer> <ExtraCounterContainer>
<ExtraCounterButton <StyledExtraCounterButton
onPointerDown={handleDownInput} onPointerDown={handleDownInput}
onPointerUp={handleUpInput} onPointerUp={handleUpInput}
onPointerLeave={handleLeaveInput} onPointerLeave={handleLeaveInput}
@@ -138,7 +136,7 @@ const ExtraCounter = ({
</OutlinedText> </OutlinedText>
</TextContainer> </TextContainer>
</IconContainer> </IconContainer>
</ExtraCounterButton> </StyledExtraCounterButton>
</ExtraCounterContainer> </ExtraCounterContainer>
); );
}; };

View File

@@ -1,80 +1,89 @@
import { useEffect, useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { TwcComponentProps, twc } from 'react-twc'; import styled from 'styled-components';
import { css } from 'styled-components';
import { lifeLongPressMultiplier } from '../../Data/constants'; import { lifeLongPressMultiplier } from '../../Data/constants';
import { Player, Rotation } from '../../Types/Player';
import { MAX_TAP_MOVE_DISTANCE } from './CommanderDamage';
import { checkContrast } from '../../Utils/checkContrast';
type RotationButtonProps = TwcComponentProps<'div'> & { import { Rotation } from '../../Types/Player';
export const StyledLifeCounterButton = styled.button`
width: 100%;
height: 100%;
color: rgba(0, 0, 0, 0.4);
font-weight: 600;
background-color: transparent;
border: none;
outline: none;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
-moz-user-select: -moz-none;
-webkit-user-select: none;
-ms-user-select: none;
`;
const TextContainer = styled.div<{
$align?: string; $align?: string;
$rotation?: number; $rotation: number;
}; }>`
position: relative;
const LifeCounterButtonTwc = twc.button` ${(props) => {
h-full if (
w-full props.$rotation === Rotation.SideFlipped ||
flex props.$rotation === Rotation.Side
font-semibold ) {
bg-transparent if (props.$align === 'right') {
border-none return css`
outline-none rotate: -90deg;
cursor-pointer bottom: 25%;
justify-center top: auto;
items-center `;
select-none }
webkit-user-select-none return css`
`; rotate: -90deg;
top: 25%;
`;
}
const TextContainer = twc.div<RotationButtonProps>((props) => [ if (props.$align === 'right') {
'relative', return css`
props.$rotation === Rotation.SideFlipped || props.$rotation === Rotation.Side left: 25%;
? props.$align === 'right' `;
? '-rotate-90 bottom-1/4 top-auto' }
: '-rotate-90 top-1/4' return css`
: 'top-auto', right: 25%;
props.$rotation === Rotation.Flipped || props.$rotation === Rotation.Normal `;
? props.$align === 'right' }}
? 'left-1/4' `;
: 'right-1/4'
: '',
]);
type LifeCounterButtonProps = { type LifeCounterButtonProps = {
player: Player; lifeTotal: number;
setLifeTotal: (lifeTotal: number) => void; setLifeTotal: (lifeTotal: number) => void;
rotation: number;
operation: 'add' | 'subtract'; operation: 'add' | 'subtract';
increment: number; increment: number;
}; };
const LifeCounterButton = ({ const LifeCounterButton = ({
player, lifeTotal,
setLifeTotal, setLifeTotal,
rotation,
operation, operation,
increment, increment,
}: LifeCounterButtonProps) => { }: LifeCounterButtonProps) => {
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined); const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const [timeoutFinished, setTimeoutFinished] = useState(false); const [timeoutFinished, setTimeoutFinished] = useState(false);
const [hasPressedDown, setHasPressedDown] = useState(false); const [hasPressedDown, setHasPressedDown] = useState(false);
const downPositionRef = useRef({ x: 0, y: 0 });
const [iconColor, setIconColor] = useState<'dark' | 'light'>('dark');
useEffect(() => {
const contrast = checkContrast(player.color, '#00000080');
if (contrast === 'Fail') {
setIconColor('light');
} else {
setIconColor('dark');
}
}, [player.color]);
const handleLifeChange = (increment: number) => { const handleLifeChange = (increment: number) => {
setLifeTotal(player.lifeTotal + increment); setLifeTotal(lifeTotal + increment);
}; };
const handleDownInput = (event: React.PointerEvent<HTMLButtonElement>) => { const handleDownInput = () => {
downPositionRef.current = { x: event.clientX, y: event.clientY };
setTimeoutFinished(false); setTimeoutFinished(false);
setHasPressedDown(true); setHasPressedDown(true);
timeoutRef.current = setTimeout(() => { timeoutRef.current = setTimeout(() => {
@@ -83,23 +92,10 @@ const LifeCounterButton = ({
}, 500); }, 500);
}; };
const handleUpInput = (event: React.PointerEvent<HTMLButtonElement>) => { const handleUpInput = () => {
if (!(hasPressedDown && !timeoutFinished)) { if (!(hasPressedDown && !timeoutFinished)) {
return; return;
} }
const upPosition = { x: event.clientX, y: event.clientY };
const hasMoved =
Math.abs(upPosition.x - downPositionRef.current.x) >
MAX_TAP_MOVE_DISTANCE ||
Math.abs(upPosition.y - downPositionRef.current.y) >
MAX_TAP_MOVE_DISTANCE;
if (hasMoved) {
return;
}
clearTimeout(timeoutRef.current); clearTimeout(timeoutRef.current);
handleLifeChange(operation === 'add' ? 1 : -1); handleLifeChange(operation === 'add' ? 1 : -1);
setHasPressedDown(false); setHasPressedDown(false);
@@ -112,13 +108,12 @@ const LifeCounterButton = ({
}; };
const fontSize = const fontSize =
player.settings.rotation === Rotation.SideFlipped || rotation === Rotation.SideFlipped || rotation === Rotation.Side
player.settings.rotation === Rotation.Side
? '8vmax' ? '8vmax'
: '12vmin'; : '12vmin';
return ( return (
<LifeCounterButtonTwc <StyledLifeCounterButton
onPointerDown={handleDownInput} onPointerDown={handleDownInput}
onPointerUp={handleUpInput} onPointerUp={handleUpInput}
onPointerLeave={handleLeaveInput} onPointerLeave={handleLeaveInput}
@@ -129,15 +124,12 @@ const LifeCounterButton = ({
aria-label={`${operation === 'add' ? 'Add' : 'Subtract'} life`} aria-label={`${operation === 'add' ? 'Add' : 'Subtract'} life`}
> >
<TextContainer <TextContainer
$rotation={player.settings.rotation} $rotation={rotation}
$align={operation === 'add' ? 'right' : 'left'} $align={operation === 'add' ? 'right' : 'left'}
data-contrast={iconColor}
className="data-[contrast=dark]:text-icons-dark
data-[contrast=light]:text-icons-light"
> >
{operation === 'add' ? '\u002B' : '\u2212'} {operation === 'add' ? '\u002B' : '\u2212'}
</TextContainer> </TextContainer>
</LifeCounterButtonTwc> </StyledLifeCounterButton>
); );
}; };

View File

@@ -1,15 +1,43 @@
import { twc } from 'react-twc'; import styled, { css } from 'styled-components';
import { Skull } from '../../Icons/generated'; import { Skull } from '../../Icons/generated';
import { Rotation } from '../../Types/Player'; import { Rotation } from '../../Types/Player';
import { RotationDivProps } from './CommanderDamage';
const LoseButton = twc.div<RotationDivProps>((props) => [ export const LoseButton = styled.button<{ $rotation: Rotation }>`
'absolute flex-grow border-none outline-none cursor-pointer bg-interface-loseButton-background rounded-lg select-none z-[1] webkit-user-select-none py-2 px-4 ', position: absolute;
flex-grow: 1;
border: none;
outline: none;
cursor: pointer;
top: 25%;
right: 15%;
background-color: #43434380;
border-radius: 8px;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
user-select: none;
-moz-user-select: -moz-none;
-webkit-user-select: none;
-ms-user-select: none;
z-index: 1;
props.$rotation === Rotation.SideFlipped || props.$rotation === Rotation.Side ${(props) => {
? `left-[21%]` if (props.$rotation === Rotation.SideFlipped) {
: 'top-[21%]', return css`
]); right: auto;
top: 15%;
left: 27%;
rotate: ${props.$rotation}deg;
`;
} else if (props.$rotation === Rotation.Side) {
return css`
right: auto;
top: 15%;
left: 27%;
rotate: ${props.$rotation - 180}deg;
`;
}
}}
`;
type LoseButtonProps = { type LoseButtonProps = {
onClick: () => void; onClick: () => void;
@@ -17,23 +45,9 @@ type LoseButtonProps = {
}; };
export const LoseGameButton = ({ rotation, onClick }: LoseButtonProps) => { export const LoseGameButton = ({ rotation, onClick }: LoseButtonProps) => {
const calcRotation =
rotation === Rotation.SideFlipped
? rotation
: rotation === Rotation.Side
? rotation - 180
: rotation === Rotation.Flipped
? rotation - 180
: rotation;
return ( return (
<LoseButton <LoseButton $rotation={rotation} onClick={onClick} aria-label={`Lose Game`}>
$rotation={rotation} <Skull size="5vmin" color="black" opacity={0.5} />
onClick={onClick}
aria-label={`Lose Game`}
style={{ rotate: `${calcRotation}deg` }}
>
<Skull size="8vmin" color="black" opacity={0.5} />
</LoseButton> </LoseButton>
); );
}; };

View File

@@ -0,0 +1,53 @@
import styled from 'styled-components';
import { css } from 'styled-components';
import { Rotation } from '../../Types/Player';
import { Cog } from '../../Icons/generated';
export const StyledSettingsButton = styled.button<{ $rotation: Rotation }>`
position: absolute;
flex-grow: 1;
border: none;
outline: none;
cursor: pointer;
top: 25%;
right: 1vmax;
background-color: transparent;
user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
-moz-user-select: -moz-none;
-webkit-user-select: none;
-ms-user-select: none;
z-index: 1;
${(props) => {
if (
props.$rotation === Rotation.Side ||
props.$rotation === Rotation.SideFlipped
) {
return css`
right: auto;
top: 1vmax;
left: 27%;
`;
}
}}
`;
type SettingsButtonProps = {
onClick: () => void;
rotation: Rotation;
};
const SettingsButton = ({ onClick, rotation }: SettingsButtonProps) => {
return (
<StyledSettingsButton
onClick={onClick}
$rotation={rotation}
aria-label={`Settings`}
>
<Cog size="5vmin" color="black" opacity="0.3" />
</StyledSettingsButton>
);
};
export default SettingsButton;

View File

@@ -1,13 +1,27 @@
import { twc } from 'react-twc';
import { Player, Rotation } from '../../Types/Player'; import { Player, Rotation } from '../../Types/Player';
import { CommanderDamage, RotationDivProps } from '../Buttons/CommanderDamage'; import styled from 'styled-components';
import { css } from 'styled-components';
import { CommanderDamage } from '../Buttons/CommanderDamage';
const CommanderDamageGrid = twc.div<RotationDivProps>((props) => [ const CommanderDamageGrid = styled.div<{ $rotation: number }>`
'flex flex-grow', display: flex;
props.$rotation === Rotation.SideFlipped || props.$rotation === Rotation.Side flex-direction: row;
? 'flex-col h-full w-auto' flex-grow: 1;
: 'flex-row w-full', width: 100%;
]);
${(props) => {
if (
props.$rotation === Rotation.SideFlipped ||
props.$rotation === Rotation.Side
) {
return css`
flex-direction: column;
height: 100%;
width: auto;
`;
}
}}
`;
type CommanderDamageBarProps = { type CommanderDamageBarProps = {
opponents: Player[]; opponents: Player[];

View File

@@ -0,0 +1,59 @@
import styled from 'styled-components';
import { usePlayers } from '../../Hooks/usePlayers';
import LifeCounter from '../LifeCounter/LifeCounter';
export const CountersWrapper = styled.div`
width: 100%;
height: 100%;
background-color: black;
`;
export const CountersGrid = styled.div<{ $gridTemplateAreas: string }>`
display: grid;
gap: 4px;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
height: 100%;
grid-template-areas: ${({ $gridTemplateAreas }) => $gridTemplateAreas};
`;
export const GridItemContainer = styled.div<{
$gridArea: string;
}>`
display: flex;
justify-content: center;
align-items: center;
grid-area: ${(props) => props.$gridArea};
`;
type CountersProps = {
gridAreas: string;
};
const Counters = ({ gridAreas }: CountersProps) => {
const { players } = usePlayers();
return (
<CountersWrapper>
<CountersGrid $gridTemplateAreas={gridAreas}>
{players.map((player) => {
return (
<GridItemContainer
key={player.index}
$gridArea={`player${player.index}`}
>
<LifeCounter
player={player}
opponents={players.filter(
(opponent) => opponent.index !== player.index
)}
/>
</GridItemContainer>
);
})}
</CountersGrid>
</CountersWrapper>
);
};
export default Counters;

View File

@@ -1,5 +1,8 @@
import { twc } from 'react-twc'; import { CounterType, Player } from '../../Types/Player';
import { usePlayers } from '../../Hooks/usePlayers'; import ExtraCounter from '../Buttons/ExtraCounter';
import styled from 'styled-components';
import { css } from 'styled-components';
import { Rotation } from '../../Types/Player';
import { import {
CommanderTax, CommanderTax,
Energy, Energy,
@@ -7,25 +10,52 @@ import {
PartnerTax, PartnerTax,
Poison, Poison,
} from '../../Icons/generated'; } from '../../Icons/generated';
import { CounterType, Player, Rotation } from '../../Types/Player'; import { usePlayers } from '../../Hooks/usePlayers';
import { RotationDivProps } from '../Buttons/CommanderDamage'; import { useGlobalSettings } from '../../Hooks/useGlobalSettings';
import ExtraCounter from '../Buttons/ExtraCounter'; import { GameFormat } from '../../Types/Settings';
import { useEffect, useState } from 'react';
import { checkContrast } from '../../Utils/checkContrast';
const Container = twc.div<RotationDivProps>((props) => [ const Container = styled.div<{ $rotation: Rotation }>`
'flex', width: 100%;
props.$rotation === Rotation.SideFlipped || props.$rotation === Rotation.Side height: 20vmin;
? 'h-full w-[8vmax]' display: flex;
: 'h-[20vmin] w-full',
]);
export const ExtraCountersGrid = twc.div<RotationDivProps>((props) => [ ${(props) => {
'flex absolute flex-row flex-grow pointer-events-none', if (
props.$rotation === Rotation.SideFlipped || props.$rotation === Rotation.Side props.$rotation === Rotation.SideFlipped ||
? 'flex-col-reverse h-full w-auto bottom-auto' props.$rotation === Rotation.Side
: 'w-full bottom-0', ) {
]); return css`
height: 100%;
width: 9.3vmax;
`;
}
}}
`;
export const ExtraCountersGrid = styled.div<{ $rotation: Rotation }>`
display: flex;
position: absolute;
width: 100%;
flex-direction: row;
flex-grow: 1;
bottom: 0;
pointer-events: none;
${(props) => {
if (
props.$rotation === Rotation.SideFlipped ||
props.$rotation === Rotation.Side
) {
return css`
flex-direction: column-reverse;
height: 100%;
width: auto;
bottom: auto;
right: -6px;
`;
}
}}
`;
type ExtraCountersBarProps = { type ExtraCountersBarProps = {
player: Player; player: Player;
@@ -33,17 +63,7 @@ type ExtraCountersBarProps = {
const ExtraCountersBar = ({ player }: ExtraCountersBarProps) => { const ExtraCountersBar = ({ player }: ExtraCountersBarProps) => {
const { updatePlayer } = usePlayers(); const { updatePlayer } = usePlayers();
const [iconColor, setIconColor] = useState<'dark' | 'light'>('dark'); const { initialGameSettings } = useGlobalSettings();
useEffect(() => {
const contrast = checkContrast(player.color, '#00000080');
if (contrast === 'Fail') {
setIconColor('light');
} else {
setIconColor('dark');
}
}, [player.color]);
const handleCounterChange = ( const handleCounterChange = (
updatedCounterTotal: number, updatedCounterTotal: number,
@@ -100,26 +120,22 @@ const ExtraCountersBar = ({ player }: ExtraCountersBarProps) => {
return null; return null;
} }
const isTwoHeadedGiant =
initialGameSettings?.gameFormat === GameFormat.TwoHeadedGiant;
return ( return (
<Container $rotation={player.settings.rotation}> <Container $rotation={player.settings.rotation}>
<ExtraCountersGrid $rotation={player.settings.rotation}> <ExtraCountersGrid $rotation={player.settings.rotation}>
{useCommanderDamage && ( {useCommanderDamage && (
<ExtraCounter <ExtraCounter
rotation={player.settings.rotation} rotation={player.settings.rotation}
Icon={ Icon={<CommanderTax size={iconSize} opacity="0.5" color="black" />}
<CommanderTax
size={iconSize}
data-contrast={iconColor}
className="data-[contrast=dark]:text-icons-dark data-[contrast=light]:text-icons-light"
/>
}
type={CounterType.CommanderTax} type={CounterType.CommanderTax}
counterTotal={ counterTotal={
player.extraCounters?.find( player.extraCounters?.find(
(counter) => counter.type === 'commanderTax' (counter) => counter.type === 'commanderTax'
)?.value )?.value
} }
isSide={player.isSide}
setCounterTotal={handleCounterChange} setCounterTotal={handleCounterChange}
playerIndex={player.index} playerIndex={player.index}
/> />
@@ -127,20 +143,13 @@ const ExtraCountersBar = ({ player }: ExtraCountersBarProps) => {
{Boolean(useCommanderDamage && usePartner) && ( {Boolean(useCommanderDamage && usePartner) && (
<ExtraCounter <ExtraCounter
rotation={player.settings.rotation} rotation={player.settings.rotation}
Icon={ Icon={<PartnerTax size={iconSize} opacity="0.5" color="black" />}
<PartnerTax
size={iconSize}
data-contrast={iconColor}
className="data-[contrast=dark]:text-icons-dark data-[contrast=light]:text-icons-light"
/>
}
type={CounterType.PartnerTax} type={CounterType.PartnerTax}
counterTotal={ counterTotal={
player.extraCounters?.find( player.extraCounters?.find(
(counter) => counter.type === 'partnerTax' (counter) => counter.type === 'partnerTax'
)?.value )?.value
} }
isSide={player.isSide}
setCounterTotal={handleCounterChange} setCounterTotal={handleCounterChange}
playerIndex={player.index} playerIndex={player.index}
/> />
@@ -148,19 +157,12 @@ const ExtraCountersBar = ({ player }: ExtraCountersBarProps) => {
{usePoison && ( {usePoison && (
<ExtraCounter <ExtraCounter
rotation={player.settings.rotation} rotation={player.settings.rotation}
Icon={ Icon={<Poison size={iconSize} opacity="0.5" color="black" />}
<Poison
size={iconSize}
data-contrast={iconColor}
className="data-[contrast=dark]:text-icons-dark data-[contrast=light]:text-icons-light"
/>
}
type={CounterType.Poison} type={CounterType.Poison}
counterTotal={ counterTotal={
player.extraCounters?.find((counter) => counter.type === 'poison') player.extraCounters?.find((counter) => counter.type === 'poison')
?.value ?.value
} }
isSide={player.isSide}
setCounterTotal={handleCounterChange} setCounterTotal={handleCounterChange}
playerIndex={player.index} playerIndex={player.index}
/> />
@@ -168,19 +170,12 @@ const ExtraCountersBar = ({ player }: ExtraCountersBarProps) => {
{useEnergy && ( {useEnergy && (
<ExtraCounter <ExtraCounter
rotation={player.settings.rotation} rotation={player.settings.rotation}
Icon={ Icon={<Energy size={iconSize} opacity="0.5" color="black" />}
<Energy
size={iconSize}
data-contrast={iconColor}
className="data-[contrast=dark]:text-icons-dark data-[contrast=light]:text-icons-light"
/>
}
type={CounterType.Energy} type={CounterType.Energy}
counterTotal={ counterTotal={
player.extraCounters?.find((counter) => counter.type === 'energy') player.extraCounters?.find((counter) => counter.type === 'energy')
?.value ?.value
} }
isSide={player.isSide}
setCounterTotal={handleCounterChange} setCounterTotal={handleCounterChange}
playerIndex={player.index} playerIndex={player.index}
/> />
@@ -188,20 +183,13 @@ const ExtraCountersBar = ({ player }: ExtraCountersBarProps) => {
{useExperience && ( {useExperience && (
<ExtraCounter <ExtraCounter
rotation={player.settings.rotation} rotation={player.settings.rotation}
Icon={ Icon={<Experience size={iconSize} opacity="0.5" color="black" />}
<Experience
size={iconSize}
data-contrast={iconColor}
className="data-[contrast=dark]:text-icons-dark data-[contrast=light]:text-icons-light"
/>
}
type={CounterType.Experience} type={CounterType.Experience}
counterTotal={ counterTotal={
player.extraCounters?.find( player.extraCounters?.find(
(counter) => counter.type === 'experience' (counter) => counter.type === 'experience'
)?.value )?.value
} }
isSide={player.isSide}
setCounterTotal={handleCounterChange} setCounterTotal={handleCounterChange}
playerIndex={player.index} playerIndex={player.index}
/> />

View File

@@ -1,43 +1,101 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { twc } from 'react-twc'; import styled, { css, keyframes } from 'styled-components';
import { Player, Rotation } from '../../Types/Player'; import { Player, Rotation } from '../../Types/Player';
import {
RotationDivProps,
RotationSpanProps,
} from '../Buttons/CommanderDamage';
import LifeCounterButton from '../Buttons/LifeCounterButton'; import LifeCounterButton from '../Buttons/LifeCounterButton';
import { OutlinedText } from '../Misc/OutlinedText'; import { OutlinedText } from '../Misc/OutlinedText';
const LifeContainer = twc.div<RotationDivProps>((props) => [ const LifeCountainer = styled.div<{
'flex flex-grow relative w-full h-full justify-between items-center', $rotation: Rotation;
props.$rotation === Rotation.SideFlipped || props.$rotation === Rotation.Side }>`
? 'flex-col-reverse' position: relative;
: 'flex-row', display: flex;
]); flex-direction: row;
flex-grow: 1;
width: 100%;
height: 100%;
justify-content: space-between;
align-items: center;
const LifeCounterTextContainer = twc.div<RotationDivProps>((props) => [ ${(props) => {
'absolute m-0 p-0 pointer-events-none select-none webkit-user-select-none', if (
props.$rotation === Rotation.SideFlipped || props.$rotation === Rotation.Side props.$rotation === Rotation.SideFlipped ||
? 'w-full h-2/3' props.$rotation === Rotation.Side
: 'w-2/3 h-full', ) {
]); return css`
flex-direction: column-reverse;
const TextWrapper = twc.div` `;
flex }
absolute }}
justify-center
items-center
w-full
h-full
z-[-1]
`; `;
const RecentDifference = twc.div<RotationSpanProps>((props) => [ const LifeCounterTextContainer = styled.div<{
'absolute min-w-[20vmin] drop-shadow-none text-center bg-interface-recentDifference-background tabular-nums rounded-full p-[6px 12px] text-[8vmin] text-interface-recentDifference-text animate-fadeOut', $rotation: Rotation;
props.$rotation === Rotation.SideFlipped || props.$rotation === Rotation.Side }>`
? 'top-1/3 translate-x-1/4 translate-y-1/2 rotate-[270deg]' position: absolute;
: 'top-1/4 left-[50%] -translate-x-1/2', width: 60%;
]); height: 100%;
margin: 0;
padding: 0;
pointer-events: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
user-select: none;
-moz-user-select: -moz-none;
-webkit-user-select: none;
-ms-user-select: none;
${(props) => {
if (
props.$rotation === Rotation.SideFlipped ||
props.$rotation === Rotation.Side
) {
return css`
width: 100%;
height: 60%;
`;
}
}}
`;
const TextWrapper = styled.div`
display: flex;
position: absolute;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
z-index: -1;
`;
const fadeOut = keyframes`
0% {
opacity: 1;
}
33% {
opacity: 0.6;
}
100% {
opacity: 0;
}
`;
export const RecentDifference = styled.span`
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
min-width: 15vmin;
text-shadow: none;
text-align: center;
background-color: rgba(255, 255, 255, 0.6);
font-variant-numeric: tabular-nums;
border-radius: 10vmin;
padding: 5px 10px;
font-size: 8vmin;
color: #333333;
animation: ${fadeOut} 3s 1s ease-out forwards;
`;
type HealthProps = { type HealthProps = {
player: Player; player: Player;
@@ -49,13 +107,28 @@ type HealthProps = {
const Health = ({ const Health = ({
player, player,
rotation,
handleLifeChange, handleLifeChange,
differenceKey, differenceKey,
recentDifference, recentDifference,
}: HealthProps) => { }: HealthProps) => {
const [showStartingPlayer, setShowStartingPlayer] = useState(
localStorage.getItem('playing') === 'true'
);
const [fontSize, setFontSize] = useState(16); const [fontSize, setFontSize] = useState(16);
const textContainerRef = useRef<HTMLDivElement | null>(null); const textContainerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!showStartingPlayer) {
const playingTimer = setTimeout(() => {
localStorage.setItem('playing', 'true');
setShowStartingPlayer(localStorage.getItem('playing') === 'true');
}, 3_000);
return () => clearTimeout(playingTimer);
}
}, [showStartingPlayer]);
useEffect(() => { useEffect(() => {
if (!textContainerRef.current) { if (!textContainerRef.current) {
return; return;
@@ -84,28 +157,28 @@ const Health = ({
}, [textContainerRef]); }, [textContainerRef]);
const calculateFontSize = (container: HTMLDivElement) => { const calculateFontSize = (container: HTMLDivElement) => {
const widthRatio = player.isSide const isSide =
? container.clientHeight rotation === Rotation.SideFlipped || rotation === Rotation.Side;
: container.clientWidth;
const heightRatio = player.isSide const widthRatio = isSide ? container.clientHeight : container.clientWidth;
? container.clientWidth
: container.clientHeight; const heightRatio = isSide ? container.clientWidth : container.clientHeight;
const minRatio = Math.min(widthRatio, heightRatio); const minRatio = Math.min(widthRatio, heightRatio);
const heightIs40PercentSmaller = heightRatio > widthRatio * 0.6; const heightIsLarger = heightRatio > widthRatio;
const scaleFactor = heightIs40PercentSmaller ? 0.8 : 1; const scaleFactor = heightIsLarger ? 0.8 : 1;
return minRatio * scaleFactor * 1; return minRatio * scaleFactor * 1;
}; };
return ( return (
<LifeContainer $rotation={player.settings.rotation}> <LifeCountainer $rotation={player.settings.rotation}>
<LifeCounterButton <LifeCounterButton
player={player} lifeTotal={player.lifeTotal}
setLifeTotal={handleLifeChange} setLifeTotal={handleLifeChange}
rotation={player.settings.rotation}
operation="subtract" operation="subtract"
increment={-1} increment={-1}
/> />
@@ -122,10 +195,7 @@ const Health = ({
{player.lifeTotal} {player.lifeTotal}
</OutlinedText> </OutlinedText>
{recentDifference !== 0 && ( {recentDifference !== 0 && (
<RecentDifference <RecentDifference key={differenceKey}>
key={differenceKey}
$rotation={player.settings.rotation}
>
{recentDifference > 0 ? '+' : ''} {recentDifference > 0 ? '+' : ''}
{recentDifference} {recentDifference}
</RecentDifference> </RecentDifference>
@@ -133,12 +203,13 @@ const Health = ({
</LifeCounterTextContainer> </LifeCounterTextContainer>
</TextWrapper> </TextWrapper>
<LifeCounterButton <LifeCounterButton
player={player} lifeTotal={player.lifeTotal}
setLifeTotal={handleLifeChange} setLifeTotal={handleLifeChange}
rotation={player.settings.rotation}
operation="add" operation="add"
increment={1} increment={1}
/> />
</LifeContainer> </LifeCountainer>
); );
}; };

View File

@@ -1,64 +1,137 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSwipeable } from 'react-swipeable'; import styled, { css, keyframes } from 'styled-components';
import { twc } from 'react-twc'; import { theme } from '../../Data/theme';
import { useGlobalSettings } from '../../Hooks/useGlobalSettings';
import { usePlayers } from '../../Hooks/usePlayers';
import { Cog } from '../../Icons/generated';
import { Player, Rotation } from '../../Types/Player'; import { Player, Rotation } from '../../Types/Player';
import {
RotationButtonProps,
RotationDivProps,
} from '../Buttons/CommanderDamage';
import { LoseGameButton } from '../Buttons/LoseButton'; import { LoseGameButton } from '../Buttons/LoseButton';
import SettingsButton from '../Buttons/SettingsButton';
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 '../PlayerMenu/PlayerMenu';
import PlayerMenu from '../Players/PlayerMenu';
import Health from './Health'; import Health from './Health';
import { baseColors } from '../../../tailwind.config'; import { usePlayers } from '../../Hooks/usePlayers';
import { useGlobalSettings } from '../../Hooks/useGlobalSettings';
const SettingsButtonTwc = twc.button<RotationButtonProps>((props) => [ const LifeCounterContentWrapper = styled.div<{
'absolute flex-grow border-none outline-none cursor-pointer bg-transparent z-[1] select-none webkit-user-select-none', $backgroundColor: string;
props.$rotation === Rotation.Side || props.$rotation === Rotation.SideFlipped }>`
? `right-auto top-[1vmax] left-[27%]` position: relative;
: 'top-1/4 right-[1vmax]', display: flex;
]); flex-grow: 1;
flex-direction: column;
align-items: center;
height: 100%;
width: 100%;
background-color: ${(props) => props.$backgroundColor || 'antiquewhite'};
@media (orientation: landscape) {
max-width: 100vmax;
max-height: 100vmin;
}
type SettingsButtonProps = { overflow: hidden;
onClick: () => void; `;
rotation: Rotation;
};
const SettingsButton = ({ onClick, rotation }: SettingsButtonProps) => { const LifeCounterWrapper = styled.div<{
return ( $rotation: Rotation;
<SettingsButtonTwc }>`
onClick={onClick} position: relative;
$rotation={rotation} display: flex;
aria-label={`Settings`} flex-direction: column;
> align-items: center;
<Cog size="5vmin" color="black" opacity="0.3" /> width: 100%;
</SettingsButtonTwc> height: 100%;
);
};
const LifeCounterContentWrapper = twc.div` z-index: 1;
relative flex flex-grow flex-col items-center w-full h-full overflow-hidden`;
const LifeCounterWrapper = twc.div<RotationDivProps>((props) => [ ${(props) => {
'relative flex items-center w-full h-full z-[1]', if (
props.$rotation === Rotation.SideFlipped || props.$rotation === Rotation.Side props.$rotation === Rotation.SideFlipped ||
? `flex-row` props.$rotation === Rotation.Side
: `flex-col`, ) {
]); return css`
flex-direction: row;
rotate: ${props.$rotation - 90}deg;
`;
}
const PlayerLostWrapper = twc.div<RotationDivProps>((props) => [ return css`
'z-[1] flex absolute w-full h-full justify-center items-center pointer-events-none select-none webkit-user-select-none bg-lifeCounter-lostWrapper opacity-75', flex-direction: column;
props.$rotation === Rotation.SideFlipped || props.$rotation === Rotation.Side rotate: ${props.$rotation}deg;
? `rotate-[${props.$rotation - 90}deg]` `;
: '', }}
]); `;
const DynamicText = twc.div`text-[8vmin] whitespace-nowrap`; const PlayerNoticeWrapper = styled.div<{
$rotation: Rotation;
$backgroundColor: string;
}>`
z-index: 1;
display: flex;
position: absolute;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
background: ${(props) => props.$backgroundColor};
pointer-events: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
user-select: none;
-moz-user-select: -moz-none;
-webkit-user-select: none;
-ms-user-select: none;
${(props) => {
if (
props.$rotation === Rotation.SideFlipped ||
props.$rotation === Rotation.Side
) {
return css`
rotate: ${props.$rotation - 90}deg;
`;
}
}}
`;
const DynamicText = styled.div<{ $rotation: Rotation }>`
font-size: 8vmin;
${(props) => {
if (
props.$rotation === Rotation.SideFlipped ||
props.$rotation === Rotation.Side
) {
return css`
rotate: ${props.$rotation - 180}deg;
`;
}
}}
`;
const fadeOut = keyframes`
0% {
opacity: 1;
}
33% {
opacity: 0.6;
}
100% {
opacity: 0;
}
`;
export const RecentDifference = styled.span`
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
text-shadow: none;
background-color: rgba(255, 255, 255, 0.6);
font-variant-numeric: tabular-nums;
border-radius: 50%;
padding: 5px 10px;
font-size: 8vmin;
color: #333333;
animation: ${fadeOut} 3s 1s ease-out forwards;
`;
const hasCommanderDamageReached21 = (player: Player) => { const hasCommanderDamageReached21 = (player: Player) => {
const commanderDamageTotals = player.commanderDamage.map( const commanderDamageTotals = player.commanderDamage.map(
@@ -90,87 +163,36 @@ const playerCanLose = (player: Player) => {
type LifeCounterProps = { type LifeCounterProps = {
player: Player; player: Player;
opponents: Player[]; opponents: Player[];
isStartingPlayer?: boolean;
}; };
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 } = useGlobalSettings();
useGlobalSettings();
const playingTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const [showPlayerMenu, setShowPlayerMenu] = useState(false); const [showPlayerMenu, setShowPlayerMenu] = useState(false);
const [recentDifference, setRecentDifference] = useState(0); const [recentDifference, setRecentDifference] = useState(0);
const [differenceKey, setDifferenceKey] = useState(Date.now()); const [differenceKey, setDifferenceKey] = useState(Date.now());
const [isLandscape, setIsLandscape] = useState(false);
const calcRot = player.isSide
? player.settings.rotation - 180
: player.settings.rotation;
const rotationAngle = isLandscape ? calcRot : calcRot + 90;
const handlers = useSwipeable({
trackMouse: true,
onSwipedDown: (e) => {
e.event.stopPropagation();
setShowPlayerMenu(true);
},
onSwipedUp: (e) => {
e.event.stopPropagation();
setShowPlayerMenu(false);
},
swipeDuration: 500,
onSwiping: (e) => e.event.stopPropagation(),
rotationAngle,
});
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setRecentDifference(0); setRecentDifference(0);
}, RECENT_DIFFERENCE_TTL); }, 3_000);
const resizeObserver = new ResizeObserver(() => { return () => clearTimeout(timer);
if (document.body.clientWidth > document.body.clientHeight) }, [recentDifference]);
setIsLandscape(true);
else setIsLandscape(false);
return;
});
resizeObserver.observe(document.body);
return () => {
clearTimeout(timer);
// Cleanup: disconnect the ResizeObserver when the component unmounts.
resizeObserver.disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recentDifference, document.body.clientHeight, document.body.clientWidth]);
useEffect(() => { useEffect(() => {
if ( if (player.showStartingPlayer) {
player.isStartingPlayer && const playingTimer = setTimeout(() => {
((!playing && localStorage.setItem('playing', 'true');
settings.useRandomStartingPlayerInterval && player.showStartingPlayer = false;
stopPlayerRandomization) || updatePlayer(player);
(!settings.useRandomStartingPlayerInterval && !playing)) }, 3_000);
) {
playingTimerRef.current = setTimeout(() => {
setPlaying(true);
}, 10_000);
}
return () => clearTimeout(playingTimerRef.current); return () => clearTimeout(playingTimer);
}, [ }
player.isStartingPlayer, // eslint-disable-next-line react-hooks/exhaustive-deps
playing, }, [player.showStartingPlayer]);
setPlaying,
settings.useRandomStartingPlayerInterval,
stopPlayerRandomization,
]);
player.settings.rotation === Rotation.SideFlipped || player.settings.rotation === Rotation.SideFlipped ||
player.settings.rotation === Rotation.Side; player.settings.rotation === Rotation.Side;
@@ -186,85 +208,40 @@ const LifeCounter = ({ player, opponents }: LifeCounterProps) => {
updatePlayer(updatedPlayer); updatePlayer(updatedPlayer);
}; };
const calcRotation =
player.settings.rotation === Rotation.SideFlipped ||
player.settings.rotation === Rotation.Side
? 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;
return ( return (
<LifeCounterContentWrapper style={{ background: player.color }}> <LifeCounterContentWrapper $backgroundColor={player.color}>
<LifeCounterWrapper <LifeCounterWrapper $rotation={player.settings.rotation}>
$rotation={player.settings.rotation} {settings.showStartingPlayer &&
style={{ rotate: `${calcRotation}deg` }} player.isStartingPlayer &&
{...handlers} player.showStartingPlayer && (
> <PlayerNoticeWrapper
{!playing && settings.showStartingPlayer && player.isStartingPlayer && ( $rotation={player.settings.rotation}
<div $backgroundColor={theme.palette.primary.main}
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"> <DynamicText $rotation={player.settings.rotation}>
<Paragraph>👑</Paragraph> You start!
{(stopPlayerRandomization || </DynamicText>
!settings.useRandomStartingPlayerInterval) && ( </PlayerNoticeWrapper>
<> )}
<Paragraph>You start!</Paragraph>
<Paragraph className="text-xl">(Press to hide)</Paragraph>
</>
)}
</div>
</DynamicText>
</div>
)}
{player.hasLost && ( {player.hasLost && (
<PlayerLostWrapper $rotation={player.settings.rotation} /> <PlayerNoticeWrapper
$rotation={player.settings.rotation}
$backgroundColor={'#00000070'}
/>
)} )}
{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}
key={player.index} key={player.index}
handleLifeChange={handleLifeChange} handleLifeChange={handleLifeChange}
/> />
{settings.showPlayerMenuCog && ( <SettingsButton
<SettingsButton onClick={() => {
onClick={() => { setShowPlayerMenu(!showPlayerMenu);
setShowPlayerMenu(!showPlayerMenu); }}
}} rotation={player.settings.rotation}
rotation={player.settings.rotation} />
/>
)}
{playerCanLose(player) && ( {playerCanLose(player) && (
<LoseGameButton <LoseGameButton
rotation={player.settings.rotation} rotation={player.settings.rotation}
@@ -279,13 +256,11 @@ const LifeCounter = ({ player, opponents }: LifeCounterProps) => {
handleLifeChange={handleLifeChange} handleLifeChange={handleLifeChange}
/> />
<ExtraCountersBar player={player} /> <ExtraCountersBar player={player} />
<PlayerMenu
isShown={showPlayerMenu}
player={player}
setShowPlayerMenu={setShowPlayerMenu}
/>
</LifeCounterWrapper> </LifeCounterWrapper>
{showPlayerMenu && (
<PlayerMenu player={player} setShowPlayerMenu={setShowPlayerMenu} />
)}
</LifeCounterContentWrapper> </LifeCounterContentWrapper>
); );
}; };

View File

@@ -1,37 +1,47 @@
import { twc } from 'react-twc'; import styled from 'styled-components';
import { useGlobalSettings } from '../Hooks/useGlobalSettings'; import Play from './Views/Play';
import { Play } from './Views/Play';
import StartMenu from './Views/StartMenu/StartMenu'; import StartMenu from './Views/StartMenu/StartMenu';
import { useGlobalSettings } from '../Hooks/useGlobalSettings';
const StartWrapper = twc.div`max-w-fit max-h-fit`; const StartWrapper = styled.div`
max-width: fit-content;
max-height: fit-content;
`;
const PlayWrapper = twc.div`relative z-0 max-w-fit max-h-fit portrait:rotate-90`; const PlayWrapper = styled.div`
position: relative;
z-index: 0;
max-width: fit-content;
max-height: fit-content;
@media (orientation: portrait) {
rotate: 90deg;
}
`;
const EmergencyResetButton = () => { const EmergencyResetButton = styled.button`
const { goToStart } = useGlobalSettings(); width: 100vmax;
height: 100vmin;
const EmergencyResetButton = twc.button`w-[100dvmax] h-[100dvmin] absolute top-0 z-[-1] bg-background-default`; font-size: 4vmax;
const Paragraph = twc.p`text-[4vmax] text-text-secondary`; position: absolute;
top: 0;
return ( z-index: -1;
<EmergencyResetButton onClick={goToStart}> background-color: #4e6815;
<Paragraph>If you can see this, something is wrong.</Paragraph> `;
<Paragraph>Press screen to go to start.</Paragraph>
<br />
<Paragraph>If the issue persists, please inform me.</Paragraph>
</EmergencyResetButton>
);
};
export const LifeTrinket = () => { export const LifeTrinket = () => {
const { showPlay, initialGameSettings } = useGlobalSettings(); const { showPlay, goToStart, initialGameSettings } = useGlobalSettings();
return ( return (
<> <>
{showPlay && initialGameSettings ? ( {showPlay && initialGameSettings ? (
<PlayWrapper> <PlayWrapper>
<Play /> <Play gridAreas={initialGameSettings?.gridAreas} />
<EmergencyResetButton /> <EmergencyResetButton onClick={goToStart}>
<p>If you can see this, something is wrong.</p>
<p>Press screen to go to start.</p>
<br />
<p>If the issue persists, please inform me.</p>
</EmergencyResetButton>
</PlayWrapper> </PlayWrapper>
) : ( ) : (
<StartWrapper> <StartWrapper>

View File

@@ -1,10 +1,21 @@
import { Modal } from '@mui/material'; import { Modal } from '@mui/material';
import { twc } from 'react-twc'; import { theme } from '../../Data/theme';
import { Separator } from './Separator'; import styled from 'styled-components';
import { Paragraph } from './TextComponents';
import { Cross } from '../../Icons/generated';
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 = styled.div`
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80vw;
height: 85vh;
background-color: ${theme.palette.background.default};
padding: 1rem;
overflow: scroll;
border-radius: 1rem;
color: ${theme.palette.text.primary};
border: none;
`;
type InfoModalProps = { type InfoModalProps = {
isOpen: boolean; isOpen: boolean;
@@ -13,86 +24,73 @@ type InfoModalProps = {
export const InfoModal = ({ isOpen, closeModal }: InfoModalProps) => { export const InfoModal = ({ isOpen, closeModal }: InfoModalProps) => {
return ( return (
<Modal <Modal open={isOpen} onClose={closeModal}>
open={isOpen} <ModalWrapper>
onClose={closeModal} <div>
style={{ display: 'flex', justifyContent: 'center' }} <h2 style={{ textAlign: 'center' }}>📋 Usage Guide</h2>
> <p>
<> There are some controls that you might not know about, so here's a
<div className="flex justify-center items-center relative w-full max-w-[532px]"> short list of them.
<button </p>
onClick={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" <h3>Life counter</h3>
> <ul>
<Cross size="16px" className="text-text-primary " /> <li>
</button> <strong>Tap</strong> on a player's + or - button to add or
subtract <strong>1 life</strong>.
</li>
<li>
<strong>Long press</strong> on a player's + or - button to add or
subtract <strong>10 life</strong>.
</li>
</ul>
<h3>Commander damage and other counters</h3>
<ul>
<li>
<strong>Tap</strong> on the counter to add{' '}
<strong>1 counter</strong>.
</li>
<li>
<strong>Long press</strong> on the counter to subtract{' '}
<strong>1 counter</strong>.
</li>
</ul>
<h3>Other</h3>
<p>
When a player is <strong>at or below 0 life</strong>, has taken{' '}
<strong>21 or more Commander Damage</strong> or has{' '}
<strong>10 or more poison counters</strong>, a button with a skull
will appear on that player's card.
</p>
<p>
Tap on the button to mark that player as lost, dimming their player
card.
</p>
</div> </div>
<ModalWrapper> <br />
<div> <div
<h2 className="text-2xl text-center mb-4">📋 Usage Guide</h2> style={{
<Separator height="1px" /> textAlign: 'center',
<Paragraph className="my-4"> marginTop: '1rem',
There are some controls that you might not know about, so here's a }}
short list of them. >
</Paragraph> Visit my
<h3 className="text-lg font-bold mb-2">Life counter</h3> <a
<ul className="list-disc ml-6 mb-4"> href="https://github.com/Vikeo/LifeTrinket"
<li> target="_blank"
<strong>Tap</strong> on a player's + or - button to add or style={{
subtract <strong>1 life</strong>. textDecoration: 'none',
</li> color: theme.palette.primary.light,
<li> }}
<strong>Long press</strong> on a player's + or - button to add >
or subtract <strong>10 life</strong>. {' '}
</li> GitHub{' '}
</ul> </a>
for more info about this web app.
<h3 className="text-lg font-bold mb-2"> </div>
Commander damage and other counters </ModalWrapper>
</h3>
<ul className="list-disc ml-6 mb-4">
<li>
<strong>Tap</strong> on the counter to add{' '}
<strong>1 counter</strong>.
</li>
<li>
<strong>Long press</strong> on the counter to subtract{' '}
<strong>1 counter</strong>.
</li>
</ul>
<h3 className="text-lg font-bold mb-2">Other functionality</h3>
<ul className="list-disc ml-6">
<li>
<Paragraph className="mb-1">
When a player is <strong>at or below 0 life</strong>, has
taken <strong>21 or more Commander Damage</strong> or has{' '}
<strong>10 or more poison counters</strong>, a button with a
skull will appear on that player's card. Tapping it will dim
the player's card.
</Paragraph>
</li>
<li>
<Paragraph className="mb-4">
Swiping <strong>down</strong> on a player's card will show
that player's settings menu.
</Paragraph>
</li>
</ul>
</div>
<div className="text-center mt-4">
Visit my{' '}
<a
href="https://github.com/Vikeo/LifeTrinket"
target="_blank"
className="text-text-secondary underline"
>
GitHub
</a>{' '}
for more info about this web app.
</div>
</ModalWrapper>
</>
</Modal> </Modal>
); );
}; };

View File

@@ -1,30 +1,58 @@
import styled, { css } from 'styled-components';
import { theme } from '../../Data/theme';
import { Rotation } from '../../Types/Player'; import { Rotation } from '../../Types/Player';
import { twc } from 'react-twc'; const Container = styled.div`
//TODO Create provider for this display: flex;
import tailwindConfig from './../../../tailwind.config'; position: relative;
import resolveConfig from 'tailwindcss/resolveConfig'; width: 100%;
height: 100%;
align-items: center;
justify-content: center;
`;
const fullConfig = resolveConfig(tailwindConfig); const CenteredText = styled.div<{
strokeWidth?: string;
strokeColor?: string;
fillColor?: string;
fontSize?: string;
fontWeight?: string;
$rotation?: Rotation;
}>`
position: absolute;
font-weight: ${(props) => props.fontWeight || ''};
font-variant-numeric: tabular-nums;
user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
-moz-user-select: -moz-none;
-webkit-user-select: none;
-ms-user-select: none;
const Container = twc.div` color: ${(props) => props.fillColor || theme.palette.common.black};
flex font-size: ${(props) => props.fontSize || '6vmin'};
relative -webkit-text-stroke: ${(props) => props.strokeWidth || '1vmin'}${(props) => props.strokeColor || theme.palette.common.white};
w-full -webkit-text-fill-color: ${(props) =>
h-full props.fillColor || theme.palette.common.black};
items-center
justify-center
`;
const CenteredText = twc.div`absolute select-none text-common-black text-[6vmin] stroke-common-white ${(props) => {
webkit-user-select-none tabular-nums`; if (
props.$rotation === Rotation.SideFlipped ||
props.$rotation === Rotation.Side
) {
return css`
rotate: 270deg;
`;
}
}}
`;
const CenteredTextOutline = twc.span` const CenteredTextOutline = styled.span`
absolute position: absolute;
left-0 left: 0;
stroke-none -webkit-text-stroke: 0;
pointer-events-none pointer-events: none;
`; `;
type OutlinedTextProps = { type OutlinedTextProps = {
children?: React.ReactNode; children?: React.ReactNode;
@@ -45,33 +73,18 @@ export const OutlinedText: React.FC<OutlinedTextProps> = ({
fillColor, fillColor,
rotation, rotation,
}) => { }) => {
const calcRotation =
rotation === Rotation.Side
? rotation - 180
: rotation === Rotation.SideFlipped
? rotation
: 0;
return ( return (
<Container> <Container>
<CenteredText <CenteredText
style={{ fontSize={fontSize}
fontSize, fontWeight={fontWeight}
fontWeight, strokeWidth={strokeWidth}
strokeWidth: strokeWidth || '1vmin', strokeColor={strokeColor}
color: fillColor || fullConfig.theme.colors.common.black, fillColor={fillColor}
WebkitTextStroke: `${strokeWidth || '1vmin'} ${ $rotation={rotation}
strokeColor || fullConfig.theme.colors.common.white
}`,
WebkitTextFillColor:
fillColor || fullConfig.theme.colors.common.black,
rotate: `${calcRotation}deg`,
}}
> >
{children} {children}
<CenteredTextOutline aria-hidden style={{ WebkitTextStroke: 0 }}> <CenteredTextOutline aria-hidden>{children}</CenteredTextOutline>
{children}
</CenteredTextOutline>
</CenteredText> </CenteredText>
</Container> </Container>
); );

View File

@@ -1,3 +1,13 @@
import styled from 'styled-components';
import { Spacer } from './Spacer';
const SeparatorContainer = styled.div<{ width?: string; height?: string }>`
width: ${(props) => props.width};
height: ${(props) => props.height};
background-color: #00000025;
border-radius: 50px;
`;
export const Separator = ({ export const Separator = ({
width = '100%', width = '100%',
height = '100%', height = '100%',
@@ -6,9 +16,10 @@ export const Separator = ({
height?: string; height?: string;
}) => { }) => {
return ( return (
<div <>
className={`bg-common-white bg-opacity-30 rounded-full mt-2 mb-2`} <Spacer height="0.5rem" />
style={{ width, height }} <SeparatorContainer width={width} height={height} />
/> <Spacer height="0.5rem" />
</>
); );
}; };

View File

@@ -1,19 +1,38 @@
import { Button, FormLabel, Modal, Switch } from '@mui/material'; import { Button, FormLabel, Modal, Switch } from '@mui/material';
import { twc } from 'react-twc';
import { useGlobalSettings } from '../../Hooks/useGlobalSettings';
import { ModalWrapper } from './InfoModal'; import { ModalWrapper } from './InfoModal';
import styled from 'styled-components';
import { useGlobalSettings } from '../../Hooks/useGlobalSettings';
import { theme } from '../../Data/theme';
import { Separator } from './Separator'; import { Separator } from './Separator';
import { Paragraph } from './TextComponents'; 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`; const SettingContainer = styled.div`
width: 100%;
display: flex;
flex-direction: column;
`;
const ToggleContainer = twc.div`flex flex-row justify-between items-center -mb-1`; const ToggleContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
`;
const Container = twc.div`flex flex-col items-center w-full`; const Container = styled.div`
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
`;
const Description = twc.p`mr-16 text-xs text-left text-text-secondary`; const Description = styled.p`
margin-top: -0.25rem;
margin-right: 3.5rem;
font-size: 0.8rem;
text-align: left;
color: ${theme.palette.text.secondary};
`;
type SettingsModalProps = { type SettingsModalProps = {
isOpen: boolean; isOpen: boolean;
@@ -22,217 +41,93 @@ type SettingsModalProps = {
export const SettingsModal = ({ isOpen, closeModal }: SettingsModalProps) => { export const SettingsModal = ({ isOpen, closeModal }: SettingsModalProps) => {
const { settings, setSettings, isPWA } = useGlobalSettings(); const { settings, setSettings, isPWA } = useGlobalSettings();
const [isLatestVersion, setIsLatestVersion] = useState(false);
const [newVersion, setNewVersion] = useState<string | undefined>(undefined);
useEffect(() => {
if (!isOpen) {
return;
}
async function checkIfLatestVersion() {
try {
const result = await fetch(
'https://api.github.com/repos/Vikeo/LifeTrinket/releases/latest',
{
headers: {
/* @ts-expect-error is defined in vite.config.ts*/
Authorization: `Bearer ${REPO_READ_ACCESS_TOKEN}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
}
);
const data = await result.json();
if (!data.name) {
setNewVersion(undefined);
setIsLatestVersion(false);
return;
}
setNewVersion(data.name);
/* @ts-expect-error is defined in vite.config.ts*/
if (data.name === APP_VERSION) {
setIsLatestVersion(true);
return;
}
setIsLatestVersion(false);
} catch (error) {
console.error('error getting latest version string', error);
}
}
checkIfLatestVersion();
}, [isOpen]);
return ( return (
<Modal <Modal open={isOpen} onClose={closeModal}>
open={isOpen} <ModalWrapper>
onClose={closeModal} <Container>
className="w-full flex justify-center" <h2 style={{ textAlign: 'center' }}> Settings </h2>
> <SettingContainer>
<> <ToggleContainer>
<div className="flex justify-center items-center relative w-full max-w-[532px]"> <FormLabel>Show Start Player</FormLabel>
<button <Switch
onClick={closeModal} checked={settings.showStartingPlayer}
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" onChange={() => {
> setSettings({
<Cross size="16px" className="text-text-primary " /> ...settings,
</button> showStartingPlayer: !settings.showStartingPlayer,
</div> });
<ModalWrapper> }}
<Container> />
<h2 className="text-center text-2xl mb-2"> Settings </h2> </ToggleContainer>
<Separator height="1px" /> <Description>
<SettingContainer> On start or reset of game, will pick a random player who will
<ToggleContainer> start first if this is enabled.
<FormLabel>Show Start Player</FormLabel> </Description>
<Switch </SettingContainer>
checked={settings.showStartingPlayer} <SettingContainer>
onChange={() => { <ToggleContainer>
setSettings({ <FormLabel>Keep Awake</FormLabel>
...settings, <Switch
showStartingPlayer: !settings.showStartingPlayer, checked={settings.keepAwake}
}); onChange={() => {
}} setSettings({ ...settings, keepAwake: !settings.keepAwake });
/> }}
</ToggleContainer> />
<Description> </ToggleContainer>
On start or reset of game, will pick a random player who will <Description>
start first if this is enabled. Will prevent device from going to sleep while this app is open if
</Description> this is enabled.
</SettingContainer> </Description>
<SettingContainer> </SettingContainer>
<ToggleContainer> <SettingContainer>
<FormLabel>Show Player Menu Cog</FormLabel> <ToggleContainer>
<Switch <FormLabel>Go fullscreen on start (Android only)</FormLabel>
checked={settings.showPlayerMenuCog} <Switch
onChange={() => { checked={settings.goFullscreenOnStart}
setSettings({ onChange={() => {
...settings, setSettings({
showPlayerMenuCog: !settings.showPlayerMenuCog, ...settings,
}); goFullscreenOnStart: !settings.goFullscreenOnStart,
}} });
/> }}
</ToggleContainer> />
<Description> </ToggleContainer>
A cog on the top right of each player's card will be shown if <Description>
this is enabled. Will enter fullscreen mode when starting a game if this is
</Description> enabled.
</SettingContainer> </Description>
<SettingContainer> </SettingContainer>
<ToggleContainer> {!isPWA && (
<FormLabel>Randomize starting player with interval</FormLabel> <>
<Switch <Separator height="2px" />
checked={settings.useRandomStartingPlayerInterval} <SettingContainer>
onChange={() => { <ToggleContainer>
setSettings({ <Paragraph>
...settings, <b>Tip:</b> You can{' '}
useRandomStartingPlayerInterval: <b>add this webapp to your home page on iOS</b> or{' '}
!settings.useRandomStartingPlayerInterval, <b>install it on Android</b> to have it act just like a
}); normal app!
}} </Paragraph>
/> </ToggleContainer>
</ToggleContainer> <Description>
<Description> If you do, this app will work offline and the toolbar will be
Will randomize between all players at when starting a game, automatically hidden.
pressing the screen aborts the interval and chooses the player </Description>
that has the crown. </SettingContainer>
</Description> </>
</SettingContainer> )}
<SettingContainer> <Separator height="2px" />
<ToggleContainer> <SettingContainer>
<FormLabel>Keep Awake</FormLabel> <Paragraph>Version: 0.4.0</Paragraph>
<Switch </SettingContainer>
checked={settings.keepAwake} <Separator height="2px" />
onChange={() => {
setSettings({
...settings,
keepAwake: !settings.keepAwake,
});
}}
/>
</ToggleContainer>
<Description>
Will prevent device from going to sleep while this app is open
if this is enabled.
</Description>
</SettingContainer>
<SettingContainer>
<ToggleContainer>
<FormLabel>Go fullscreen on start (Android only)</FormLabel>
<Switch
checked={settings.goFullscreenOnStart}
onChange={() => {
setSettings({
...settings,
goFullscreenOnStart: !settings.goFullscreenOnStart,
});
}}
/>
</ToggleContainer>
<Description>
Will enter fullscreen mode when starting a game if this is
enabled.
</Description>
</SettingContainer>
{!isPWA && (
<>
<Separator height="1px" />
<SettingContainer>
<ToggleContainer>
<Paragraph>
<b>Tip:</b> You can{' '}
<b>add this webapp to your home page on iOS</b> or{' '}
<b>install it on Android</b> to have it act just like a
normal app!
</Paragraph>
</ToggleContainer>
<Description className="mt-1">
If you do, this app will work offline and the toolbar will
be automatically hidden.
</Description>
</SettingContainer>
</>
)}
<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 <Button variant="contained" onClick={closeModal}>
variant="contained" Save and Close
onClick={closeModal} </Button>
style={{ marginTop: '0.25rem' }} </Container>
> </ModalWrapper>
Save and Close
</Button>
</Container>
</ModalWrapper>
</>
</Modal> </Modal>
); );
}; };

View File

@@ -0,0 +1,6 @@
import styled from 'styled-components';
export const Spacer = styled.div<{ width?: string; height?: string }>`
width: ${(props) => props.width};
height: ${(props) => props.height};
`;

View File

@@ -1,30 +1,43 @@
import { Button, Drawer } from '@mui/material'; import { Button, Drawer } from '@mui/material';
import { useState } from 'react'; import { useState } from 'react';
import styled from 'styled-components';
import { theme } from '../../Data/theme';
import { BuyMeCoffee, KoFi } from '../../Icons/generated/Support'; import { BuyMeCoffee, KoFi } from '../../Icons/generated/Support';
import { Paragraph } from './TextComponents'; import { Paragraph } from './TextComponents';
import LittleGuy from '../../Icons/generated/LittleGuy'; import LittleGuy from '../../Icons/generated/LittleGuy';
import { useAnalytics } from '../../Hooks/useAnalytics'; import { useAnalytics } from '../../Hooks/useAnalytics';
import { twc } from 'react-twc';
const SupportContainer = twc.div`flex flex-col items-center justify-center gap-4 mt-4 mb-4`; // import { ButtonBase } from '@mui/material';
const SupportButton = twc.button` const SupportContainer = styled.div`
flex display: flex;
flex-row flex-direction: column;
items-center align-items: center;
justify-left justify-content: center;
border-none gap: 1rem;
cursor-pointer margin: 16px 0;
bg-primary-main `;
rounded-md
w-10/12 const SupportButton = styled.button`
mx-4 display: flex;
px-4 flex-direction: row;
py-2 align-items: center;
transition-colors duration-200 ease-in-out justify-content: center;
shadow-[1px_2px_4px_0px_rgba(0,0,0,0.3)] border: none;
hover:bg-primary-dark background-color: transparent;
`; cursor: pointer;
padding: 0;
margin: 0;
background-color: ${theme.palette.primary.main};
border-radius: 4px;
margin: 0 1rem;
padding: 0 1rem;
transition: background-color 0.2s ease-in-out;
box-shadow: 1px 2px 4px 0px rgba(0, 0, 0, 0.3);
&:hover {
background-color: ${theme.palette.primary.dark};
}
`;
export const SupportMe = () => { export const SupportMe = () => {
const analytics = useAnalytics(); const analytics = useAnalytics();
@@ -74,7 +87,13 @@ export const SupportMe = () => {
<LittleGuy <LittleGuy
height={'4rem'} height={'4rem'}
width={'2.5rem'} width={'2.5rem'}
className="pointer-events-none absolute top-10 right-0 text-text-primary" style={{
pointerEvents: 'none',
position: 'absolute',
top: '2.5rem',
right: '0',
color: theme.palette.text.primary,
}}
/> />
<Drawer <Drawer
@@ -85,12 +104,22 @@ export const SupportMe = () => {
> >
<SupportContainer> <SupportContainer>
<SupportButton onClick={handleOpenBuyMeCoffee}> <SupportButton onClick={handleOpenBuyMeCoffee}>
<BuyMeCoffee height="1.5rem" width="1.5rem" className="mr-2" /> <BuyMeCoffee
<Paragraph className="text-xs">Buy him a tea</Paragraph> height={'1.5rem'}
width={'1.5rem'}
style={{ marginRight: '0.5rem' }}
/>
<Paragraph style={{ fontSize: '0.7rem' }}>Buy him a tea</Paragraph>
</SupportButton> </SupportButton>
<SupportButton onClick={handleOpenKoFi}> <SupportButton onClick={handleOpenKoFi}>
<KoFi height="1.5rem" width="1.5rem" className="mr-2" /> <KoFi
<Paragraph className="text-xs">Buy him a ko-fi</Paragraph> height={'1.5rem'}
width={'1.5rem'}
style={{ marginRight: '0.5rem' }}
/>
<Paragraph style={{ fontSize: '0.7rem' }}>
Buy him a ko-fi
</Paragraph>
</SupportButton> </SupportButton>
</SupportContainer> </SupportContainer>
</Drawer> </Drawer>

View File

@@ -1,6 +1,11 @@
import { twc } from 'react-twc'; import styled from 'styled-components';
import { theme } from '../../Data/theme';
export const Paragraph = twc.p`text-text-primary;`; export const Paragraph = styled.p`
color: ${theme.palette.text.primary};
`;
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export const H1 = twc.h1`text-text-primary;`; export const H1 = styled.h1`
color: ${theme.palette.text.primary};
`;

View File

@@ -1,117 +1,187 @@
import { Button, Checkbox } from '@mui/material'; import { Button, Checkbox } from '@mui/material';
import { useRef } from 'react'; import styled, { css } from 'styled-components';
import { twc } from 'react-twc'; import { Player, Rotation } from '../../Types/Player';
import { theme } from '../../Data/theme'; import { theme } from '../../Data/theme';
import { useGlobalSettings } from '../../Hooks/useGlobalSettings'; import { useGlobalSettings } from '../../Hooks/useGlobalSettings';
import { usePlayers } from '../../Hooks/usePlayers'; import { usePlayers } from '../../Hooks/usePlayers';
import { useSafeRotate } from '../../Hooks/useSafeRotate';
import { import {
Cross,
Energy,
Exit,
Experience,
FullscreenOff,
FullscreenOn,
PartnerTax, PartnerTax,
Poison, Poison,
Energy,
Experience,
Exit,
FullscreenOff,
FullscreenOn,
Cross,
ResetGame, ResetGame,
} from '../../Icons/generated'; } from '../../Icons/generated';
import { Player, Rotation } from '../../Types/Player'; import { useRef } from 'react';
import { RotationDivProps } from '../Buttons/CommanderDamage'; import { Spacer } from '../Misc/Spacer';
import { useSafeRotate } from '../../Hooks/useSafeRotate';
const CheckboxContainer = twc.div``; const SettingsContainer = styled.div<{
$rotation: Rotation;
}>`
display: flex;
flex-direction: row;
flex-wrap: wrap;
height: 100%;
width: 100%;
const PlayerMenuWrapper = twc.div` ${(props) => {
flex if (
flex-col props.$rotation === Rotation.SideFlipped ||
absolute props.$rotation === Rotation.Side
w-full ) {
h-full return css`
bg-background-settings flex-direction: column-reverse;
backdrop-blur-[3px] height: 100%;
items-center width: 100%;
justify-center `;
z-[2] }
webkit-user-select-none }}
transition-all ${(props) => {
if (props.$rotation === Rotation.Side) {
return css`
rotate: ${props.$rotation - 180}deg;
`;
} else if (props.$rotation === Rotation.SideFlipped) {
return css`
rotate: ${props.$rotation - 180}deg;
`;
}
}}
`; `;
const BetterRowContainer = twc.div` const BetterRowContainer = styled.div`
flex display: flex;
flex-col flex-direction: column;
flex-grow flex-grow: 1;
w-full width: 100%;
h-full height: 100%;
justify-between justify-content: end;
items-stretch align-items: stretch;
`; `;
const TogglesSection = twc.div` const TogglesSection = styled.div`
flex display: flex;
flex-row position: relative;
flex-wrap flex-direction: row;
relative gap: 0.5rem;
gap-2 justify-content: space-evenly;
h-full
justify-evenly
items-center
`; `;
const ButtonsSections = twc.div` const ButtonsSections = styled.div`
flex display: flex;
max-w-full max-width: 100%;
gap-4 gap: 1rem;
justify-between justify-content: space-between;
p-[3%] padding: 3% 3%;
items-center align-items: center;
flex-wrap
`; `;
const ColorPickerButton = twc.div` const ColorPicker = styled.input`
h-[8vmax] position: absolute;
w-[8vmax] top: 5%;
relative left: 5%;
max-h-12 height: 8vmax;
max-w-12 width: 8vmax;
rounded-full border: none;
cursor-pointer outline: none;
overflow-hidden cursor: pointer;
background-color: transparent;
user-select: none;
color: #ffffff;
`; `;
const SettingsContainer = twc.div<RotationDivProps>((props) => [ const CheckboxContainer = styled.div<{ $rotation: Rotation }>`
'flex flex-wrap h-full w-full', ${(props) => {
props.$rotation === Rotation.SideFlipped || props.$rotation === Rotation.Side if (
? 'flex-col' props.$rotation === Rotation.SideFlipped ||
: 'flex-row', props.$rotation === Rotation.Side
]); ) {
return css`
/* rotate: ${props.$rotation - 180}deg; */
`;
}
}}
`;
const PlayerMenuWrapper = styled.div<{
$rotation: Rotation;
}>`
display: flex;
flex-direction: column;
position: absolute;
width: 100%;
height: 100%;
background-color: rgba(20, 20, 20, 0.9);
align-items: center;
justify-content: center;
z-index: 2;
${(props) => {
if (
props.$rotation === Rotation.SideFlipped ||
props.$rotation === Rotation.Side
) {
return;
}
return css`
rotate: ${props.$rotation}deg;
`;
}};
`;
const CloseButton = styled.div<{
$rotation: Rotation;
}>`
position: absolute;
top: 15%;
right: 5%;
z-index: 9999;
border: none;
outline: none;
cursor: pointer;
background-color: transparent;
${(props) => {
if (props.$rotation === Rotation.Side) {
return css`
rotate: ${props.$rotation - 180}deg;
top: 5%;
right: auto;
left: 5%;
`;
} else if (props.$rotation === Rotation.SideFlipped) {
return css`
rotate: ${props.$rotation - 180}deg;
top: auto;
left: auto;
bottom: 5%;
right: 5%;
`;
}
}}
`;
type PlayerMenuProps = { type PlayerMenuProps = {
player: Player; player: Player;
setShowPlayerMenu: (showPlayerMenu: boolean) => void; setShowPlayerMenu: (showPlayerMenu: boolean) => void;
isShown: boolean;
}; };
const PlayerMenu = ({ const PlayerMenu = ({ player, setShowPlayerMenu }: PlayerMenuProps) => {
player,
setShowPlayerMenu,
isShown,
}: PlayerMenuProps) => {
const settingsContainerRef = useRef<HTMLDivElement | null>(null); const settingsContainerRef = useRef<HTMLDivElement | null>(null);
const resetGameDialogRef = useRef<HTMLDialogElement | null>(null); const dialogRef = useRef<HTMLDialogElement | null>(null);
const { isSide } = useSafeRotate({ const { isSide } = useSafeRotate({
rotation: player.settings.rotation, rotation: player.settings.rotation,
containerRef: settingsContainerRef, containerRef: settingsContainerRef,
}); });
const { const handleOnClick = () => {
fullscreen, setShowPlayerMenu(false);
wakeLock, };
goToStart, const { fullscreen, wakeLock, goToStart } = useGlobalSettings();
settings,
setPlaying,
setStopPlayerRandomization,
} = useGlobalSettings();
const { updatePlayer, resetCurrentGame } = usePlayers(); const { updatePlayer, resetCurrentGame } = usePlayers();
const handleColorChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleColorChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -129,13 +199,6 @@ const PlayerMenu = ({
const handleResetGame = () => { const handleResetGame = () => {
resetCurrentGame(); resetCurrentGame();
setShowPlayerMenu(false); setShowPlayerMenu(false);
setPlaying(false);
setStopPlayerRandomization(false);
};
const handleGoToStart = () => {
goToStart();
setStopPlayerRandomization(false);
}; };
const toggleFullscreen = () => { const toggleFullscreen = () => {
@@ -149,50 +212,39 @@ const PlayerMenu = ({
const buttonFontSize = isSide ? '1.5vmax' : '3vmin'; const buttonFontSize = isSide ? '1.5vmax' : '3vmin';
const iconSize = isSide ? '6vmin' : '3vmax'; const iconSize = isSide ? '6vmin' : '3vmax';
const extraCountersSize = isSide ? '8vmin' : '4vmax'; const extraCountersSize = isSide ? '8vmin' : '4vmax';
const closeButtonSize = isSide ? '6vmin' : '3vmax';
const calcRotation =
player.settings.rotation === Rotation.Side
? `${player.settings.rotation - 180}deg`
: player.settings.rotation === Rotation.SideFlipped
? `${player.settings.rotation - 180}deg`
: '';
return ( return (
<PlayerMenuWrapper <PlayerMenuWrapper $rotation={player.settings.rotation}>
//TODO: Fix hacky solution to rotation for SideFlipped <CloseButton $rotation={player.settings.rotation}>
style={{ <Button
rotate: variant="text"
player.settings.rotation === Rotation.SideFlipped ? `180deg` : '', onClick={handleOnClick}
translate: isShown ? '' : player.isSide ? `-100%` : `0 -100%`, style={{
}} margin: 0,
> padding: 0,
height: closeButtonSize,
width: closeButtonSize,
}}
>
<Cross size={closeButtonSize} />
</Button>
</CloseButton>
<SettingsContainer <SettingsContainer
$rotation={player.settings.rotation} $rotation={player.settings.rotation}
style={{
rotate: calcRotation,
}}
ref={settingsContainerRef} ref={settingsContainerRef}
> >
{settings.showPlayerMenuCog && ( <ColorPicker
<button type="color"
onClick={() => setShowPlayerMenu(false)} value={player.color}
className="flex absolute top-0 right-2 z-10 w-8 h-8 bg-transparent items-center justify-center rounded-full border-solid border-primary-main border-2" onChange={handleColorChange}
> role="button"
<Cross size="16px" className="text-primary-main " /> aria-label="Color picker"
</button> />
)}
<BetterRowContainer> <BetterRowContainer>
<TogglesSection> <TogglesSection>
<ColorPickerButton aria-label="Color picker">
<input
onChange={handleColorChange}
type="color"
className="size-[200%] absolute -left-2 -top-2"
value={player.color}
/>
</ColorPickerButton>
{player.settings.useCommanderDamage && ( {player.settings.useCommanderDamage && (
<CheckboxContainer> <CheckboxContainer $rotation={player.settings.rotation}>
<Checkbox <Checkbox
name="usePartner" name="usePartner"
checked={player.settings.usePartner} checked={player.settings.usePartner}
@@ -219,7 +271,8 @@ const PlayerMenu = ({
/> />
</CheckboxContainer> </CheckboxContainer>
)} )}
<CheckboxContainer>
<CheckboxContainer $rotation={player.settings.rotation}>
<Checkbox <Checkbox
name="usePoison" name="usePoison"
checked={player.settings.usePoison} checked={player.settings.usePoison}
@@ -245,7 +298,8 @@ const PlayerMenu = ({
aria-label="Poison" aria-label="Poison"
/> />
</CheckboxContainer> </CheckboxContainer>
<CheckboxContainer>
<CheckboxContainer $rotation={player.settings.rotation}>
<Checkbox <Checkbox
name="useEnergy" name="useEnergy"
checked={player.settings.useEnergy} checked={player.settings.useEnergy}
@@ -271,7 +325,8 @@ const PlayerMenu = ({
aria-label="Energy" aria-label="Energy"
/> />
</CheckboxContainer> </CheckboxContainer>
<CheckboxContainer>
<CheckboxContainer $rotation={player.settings.rotation}>
<Checkbox <Checkbox
name="useExperience" name="useExperience"
checked={player.settings.useExperience} checked={player.settings.useExperience}
@@ -298,19 +353,20 @@ const PlayerMenu = ({
/> />
</CheckboxContainer> </CheckboxContainer>
</TogglesSection> </TogglesSection>
<ButtonsSections className="mt-4"> <Spacer height="1rem" />
<ButtonsSections>
<Button <Button
variant="text" variant="text"
style={{ style={{
cursor: 'pointer', cursor: 'pointer',
userSelect: 'none', userSelect: 'none',
}} }}
onClick={handleGoToStart} onClick={goToStart}
aria-label="Back to start" aria-label="Back to start"
> >
<Exit size={iconSize} style={{ rotate: '180deg' }} /> <Exit size={iconSize} style={{ rotate: '180deg' }} />
</Button> </Button>
<CheckboxContainer> <CheckboxContainer $rotation={player.settings.rotation}>
<Checkbox <Checkbox
name="fullscreen" name="fullscreen"
checked={document.fullscreenElement ? true : false} checked={document.fullscreenElement ? true : false}
@@ -351,7 +407,7 @@ const PlayerMenu = ({
fontSize: buttonFontSize, fontSize: buttonFontSize,
padding: '4px', padding: '4px',
}} }}
onClick={() => resetGameDialogRef.current?.show()} onClick={() => dialogRef.current?.show()}
role="checkbox" role="checkbox"
aria-checked={wakeLock.active} aria-checked={wakeLock.active}
aria-label="Reset Game" aria-label="Reset Game"
@@ -361,31 +417,34 @@ const PlayerMenu = ({
</ButtonsSections> </ButtonsSections>
</BetterRowContainer> </BetterRowContainer>
<dialog <dialog
ref={resetGameDialogRef} ref={dialogRef}
className="z-[999] size-full bg-background-settings" style={{
onClick={() => resetGameDialogRef.current?.close()} zIndex: 9999,
background: theme.palette.background.default,
color: theme.palette.text.primary,
borderRadius: '1rem',
border: 'none',
position: 'absolute',
top: '10%',
}}
> >
<div className="flex size-full items-center justify-center"> <h1>Reset Game?</h1>
<div className="flex flex-col justify-center p-4 gap-2 bg-background-default rounded-2xl border-none"> <div style={{ display: 'flex', justifyContent: 'space-evenly' }}>
<h1 className="text-center text-text-primary">Reset Game?</h1> <Button
<div className="flex justify-evenly gap-4"> variant="contained"
<Button onClick={() => dialogRef.current?.close()}
variant="contained" >
onClick={() => resetGameDialogRef.current?.close()} No
> </Button>
No <Button
</Button> variant="contained"
<Button onClick={() => {
variant="contained" handleResetGame();
onClick={() => { dialogRef.current?.close();
handleResetGame(); }}
resetGameDialogRef.current?.close(); >
}} Yes
> </Button>
Yes
</Button>
</div>
</div>
</div> </div>
</dialog> </dialog>
</SettingsContainer> </SettingsContainer>

View File

@@ -1,144 +0,0 @@
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';
const getGridArea = (player: PlayerType) => {
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');
}
};
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 (
settings.useRandomStartingPlayerInterval &&
!stopPlayerRandomization &&
!playing
) {
randomIntervalRef.current = setInterval(() => {
let randomIndex: number;
do {
randomIndex = Math.floor(Math.random() * players.length);
} while (randomIndex === prevRandomIndexRef.current);
prevRandomIndexRef.current = randomIndex;
setPlayers(
players.map((p) =>
p.index === prevRandomIndexRef.current
? {
...p,
isStartingPlayer: true,
}
: {
...p,
isStartingPlayer: false,
}
)
);
}, 100);
}
if (!settings.useRandomStartingPlayerInterval) {
const randomPlayerIndex = Math.floor(Math.random() * players.length);
setPlayers(
players.map((p) =>
p.index === randomPlayerIndex
? {
...p,
isStartingPlayer: true,
}
: {
...p,
isStartingPlayer: false,
}
)
);
}
return () => {
if (randomIntervalRef.current) {
clearInterval(randomIntervalRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
players.length,
playing,
setPlayers,
settings.useRandomStartingPlayerInterval,
stopPlayerRandomization,
]);
return (
<PlayersWrapper>
{settings.useRandomStartingPlayerInterval &&
!stopPlayerRandomization &&
!playing && (
<div
className="absolute flex justify-center items-center bg-black bg-opacity-40 h-screen w-screen portrait:h-[100vw] portrait:w-[100vh] z-50 cursor-pointer text-5xl"
onClick={() => {
if (randomIntervalRef.current) {
clearInterval(randomIntervalRef.current);
randomIntervalRef.current = null;
}
setStopPlayerRandomization(true);
}}
>
<div className="bg-primary-main px-8 py-2 rounded-2xl opacity-70 text-[5vmax]">
PRESS TO SELECT PLAYER
</div>
</div>
)}
<div className={`grid w-full h-full gap-1 box-border ${gridClasses} `}>
{players.map((player) => {
const gridArea = getGridArea(player);
return (
<div
key={player.index}
className={`flex justify-center items-center align-middle ${gridArea}`}
>
<LifeCounter
player={player}
opponents={players.filter(
(opponent) => opponent.index !== player.index
)}
/>
</div>
);
})}
</div>
</PlayersWrapper>
);
};

View File

@@ -1,67 +1,24 @@
import { useGlobalSettings } from '../../Hooks/useGlobalSettings'; import styled from 'styled-components';
import { usePlayers } from '../../Hooks/usePlayers'; import Counters from '../Counters/Counters';
import { Orientation } from '../../Types/Settings';
import { Players } from '../Players/Players';
import { twc } from 'react-twc';
const MainWrapper = twc.div`w-[100dvmax] h-[100dvmin] overflow-hidden`; const MainWrapper = styled.div`
width: 100vmax;
height: 100vmin;
width: 100dvmax;
height: 100dvmin;
overflow: hidden;
`;
export const Play = () => { type PlayProps = {
const { players } = usePlayers(); gridAreas: string;
const { initialGameSettings } = useGlobalSettings();
let Layout: JSX.Element;
switch (players.length) {
case 1:
if (initialGameSettings?.orientation === Orientation.Portrait) {
Layout = Players(players, 'grid-areas-onePlayerPortrait');
}
Layout = Players(players, 'grid-areas-onePlayerLandscape');
break;
case 2:
switch (initialGameSettings?.orientation) {
case Orientation.Portrait:
Layout = Players(players, 'grid-areas-twoPlayersOppositePortrait');
break;
default:
case Orientation.Landscape:
Layout = Players(players, 'grid-areas-twoPlayersSameSideLandscape');
break;
case Orientation.OppositeLandscape:
Layout = Players(players, 'grid-areas-twoPlayersOppositeLandscape');
break;
}
break;
case 3:
if (initialGameSettings?.orientation === Orientation.Portrait) {
Layout = Players(players, 'grid-areas-threePlayersSide');
break;
}
Layout = Players(players, 'grid-areas-threePlayers');
break;
default:
case 4:
if (initialGameSettings?.orientation === Orientation.Portrait) {
Layout = Players(players, 'grid-areas-fourPlayerPortrait');
break;
}
Layout = Players(players, 'grid-areas-fourPlayer');
break;
case 5:
if (initialGameSettings?.orientation === Orientation.Portrait) {
Layout = Players(players, 'grid-areas-fivePlayersSide');
break;
}
Layout = Players(players, 'grid-areas-fivePlayers');
break;
case 6:
if (initialGameSettings?.orientation === Orientation.Portrait) {
Layout = Players(players, 'grid-areas-sixPlayersSide');
break;
}
Layout = Players(players, 'grid-areas-sixPlayers');
break;
}
return <MainWrapper>{Layout}</MainWrapper>;
}; };
const Play = ({ gridAreas }: PlayProps) => {
return (
<MainWrapper>
<Counters gridAreas={gridAreas} />
</MainWrapper>
);
};
export default Play;

View File

@@ -1,48 +1,49 @@
import { FormControlLabel, Radio, RadioGroup } from '@mui/material';
import React from 'react'; import React from 'react';
import styled from 'styled-components';
import { GridTemplateAreas } from '../../../Data/GridTemplateAreas';
import { FormControlLabel, Radio, RadioGroup } from '@mui/material';
import { theme } from '../../../Data/theme'; import { theme } from '../../../Data/theme';
import { import {
FivePlayers,
FourPlayers,
FourPlayersSide,
OnePlayerPortrait, OnePlayerPortrait,
SixPlayers,
ThreePlayers,
ThreePlayersSide,
TwoPlayersOppositeLandscape, TwoPlayersOppositeLandscape,
TwoPlayersOppositePortrait, TwoPlayersOppositePortrait,
ThreePlayers,
ThreePlayersSide,
FourPlayers,
FourPlayersSide,
FivePlayers,
SixPlayers,
TwoPlayersSameSide, TwoPlayersSameSide,
} from '../../../Icons/generated/Layouts'; } from '../../../Icons/generated/Layouts';
import { twc } from 'react-twc';
import OnePlayerLandscape from '../../../Icons/generated/Layouts/OnePlayerLandscape'; import OnePlayerLandscape from '../../../Icons/generated/Layouts/OnePlayerLandscape';
import { Orientation } from '../../../Types/Settings';
const LayoutWrapper = twc.div`flex flex-row justify-center items-center self-center w-full`; const LayoutWrapper = styled.div`
flex-direction: row;
display: flex;
justify-content: space-evenly;
`;
type LayoutOptionsProps = { type LayoutOptionsProps = {
numberOfPlayers: number; numberOfPlayers: number;
selectedOrientation: Orientation; gridAreas: GridTemplateAreas;
onChange: (orientation: Orientation) => void; onChange: (gridAreas: GridTemplateAreas) => void;
}; };
export const LayoutOptions: React.FC<LayoutOptionsProps> = ({ const LayoutOptions: React.FC<LayoutOptionsProps> = ({
numberOfPlayers, numberOfPlayers,
selectedOrientation, gridAreas,
onChange, onChange,
}) => { }) => {
const iconWidth = '21vmin'; const iconHeight = '30vmin';
const iconHeight = '40vmin'; const iconWidth = '20vmin';
const iconMaxWidth = '124px';
const iconMaxHeight = '196px';
const renderLayoutOptions = () => { const renderLayoutOptions = () => {
switch (numberOfPlayers) { switch (numberOfPlayers) {
case 1: case 1:
return ( return (
<div> <>
<FormControlLabel <FormControlLabel
value={Orientation.Landscape} value={GridTemplateAreas.OnePlayerLandscape}
control={ control={
<Radio <Radio
icon={ icon={
@@ -60,13 +61,12 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
/> />
} }
TouchRippleProps={{ style: { display: 'none' } }} TouchRippleProps={{ style: { display: 'none' } }}
style={{ maxWidth: iconMaxWidth, maxHeight: iconMaxHeight }}
/> />
} }
label="" label=""
/> />
<FormControlLabel <FormControlLabel
value={Orientation.Portrait} value={GridTemplateAreas.OnePlayerPortrait}
control={ control={
<Radio <Radio
icon={ icon={
@@ -84,21 +84,19 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
/> />
} }
TouchRippleProps={{ style: { display: 'none' } }} TouchRippleProps={{ style: { display: 'none' } }}
style={{ maxWidth: iconMaxWidth, maxHeight: iconMaxHeight }}
/> />
} }
label="" label=""
/> />
</div> </>
); );
case 2: case 2:
return ( return (
<> <>
<FormControlLabel <FormControlLabel
value={Orientation.Landscape} value={GridTemplateAreas.TwoPlayersSameSide}
control={ control={
<Radio <Radio
style={{ maxWidth: iconMaxWidth, maxHeight: iconMaxHeight }}
icon={ icon={
<TwoPlayersSameSide <TwoPlayersSameSide
height={iconHeight} height={iconHeight}
@@ -119,10 +117,9 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
label="" label=""
/> />
<FormControlLabel <FormControlLabel
value={Orientation.Portrait} value={GridTemplateAreas.TwoPlayersOppositePortrait}
control={ control={
<Radio <Radio
style={{ maxWidth: iconMaxWidth, maxHeight: iconMaxHeight }}
icon={ icon={
<TwoPlayersOppositePortrait <TwoPlayersOppositePortrait
height={iconHeight} height={iconHeight}
@@ -143,10 +140,9 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
label="" label=""
/> />
<FormControlLabel <FormControlLabel
value={Orientation.OppositeLandscape} value={GridTemplateAreas.TwoPlayersOppositeLandscape}
control={ control={
<Radio <Radio
style={{ maxWidth: iconMaxWidth, maxHeight: iconMaxHeight }}
icon={ icon={
<TwoPlayersOppositeLandscape <TwoPlayersOppositeLandscape
height={iconHeight} height={iconHeight}
@@ -172,10 +168,9 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
return ( return (
<> <>
<FormControlLabel <FormControlLabel
value={Orientation.Landscape} value={GridTemplateAreas.ThreePlayers}
control={ control={
<Radio <Radio
style={{ maxWidth: iconMaxWidth, maxHeight: iconMaxHeight }}
icon={ icon={
<ThreePlayers <ThreePlayers
height={iconHeight} height={iconHeight}
@@ -196,10 +191,9 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
label="" label=""
/> />
<FormControlLabel <FormControlLabel
value={Orientation.Portrait} value={GridTemplateAreas.ThreePlayersSide}
control={ control={
<Radio <Radio
style={{ maxWidth: iconMaxWidth, maxHeight: iconMaxHeight }}
icon={ icon={
<ThreePlayersSide <ThreePlayersSide
height={iconHeight} height={iconHeight}
@@ -226,10 +220,9 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
return ( return (
<> <>
<FormControlLabel <FormControlLabel
value={Orientation.Landscape} value={GridTemplateAreas.FourPlayers}
control={ control={
<Radio <Radio
style={{ maxWidth: iconMaxWidth, maxHeight: iconMaxHeight }}
icon={ icon={
<FourPlayers <FourPlayers
height={iconHeight} height={iconHeight}
@@ -250,10 +243,9 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
label="" label=""
/> />
<FormControlLabel <FormControlLabel
value={Orientation.Portrait} value={GridTemplateAreas.FourPlayersSide}
control={ control={
<Radio <Radio
style={{ maxWidth: iconMaxWidth, maxHeight: iconMaxHeight }}
icon={ icon={
<FourPlayersSide <FourPlayersSide
height={iconHeight} height={iconHeight}
@@ -280,10 +272,9 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
return ( return (
<> <>
<FormControlLabel <FormControlLabel
value={Orientation.Landscape} value={GridTemplateAreas.FivePlayers}
control={ control={
<Radio <Radio
style={{ maxWidth: iconMaxWidth, maxHeight: iconMaxHeight }}
icon={ icon={
<FivePlayers <FivePlayers
height={iconHeight} height={iconHeight}
@@ -333,10 +324,9 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
return ( return (
<> <>
<FormControlLabel <FormControlLabel
value={Orientation.Landscape} value={GridTemplateAreas.SixPlayers}
control={ control={
<Radio <Radio
style={{ maxWidth: iconMaxWidth, maxHeight: iconMaxHeight }}
icon={ icon={
<SixPlayers <SixPlayers
height={iconHeight} height={iconHeight}
@@ -392,9 +382,9 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
<RadioGroup <RadioGroup
row row
onChange={(_e, value) => { onChange={(_e, value) => {
onChange(value as Orientation); onChange(value as GridTemplateAreas);
}} }}
value={selectedOrientation} value={gridAreas}
style={{ justifyContent: 'center' }} style={{ justifyContent: 'center' }}
> >
{renderLayoutOptions()} {renderLayoutOptions()}
@@ -402,3 +392,5 @@ export const LayoutOptions: React.FC<LayoutOptionsProps> = ({
</LayoutWrapper> </LayoutWrapper>
); );
}; };
export default LayoutOptions;

View File

@@ -1,32 +1,58 @@
import { Button, FormControl, FormLabel, Switch } from '@mui/material'; import {
Button,
FormControl,
FormLabel,
MenuItem,
Select,
Switch,
} from '@mui/material';
import Slider from '@mui/material/Slider'; import Slider from '@mui/material/Slider';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { twc } from 'react-twc'; import styled from 'styled-components';
import { GridTemplateAreas } from '../../../Data/GridTemplateAreas';
import { createInitialPlayers } from '../../../Data/getInitialPlayers'; import { createInitialPlayers } from '../../../Data/getInitialPlayers';
import { theme } from '../../../Data/theme'; import { theme } from '../../../Data/theme';
import { useAnalytics } from '../../../Hooks/useAnalytics'; import { useAnalytics } from '../../../Hooks/useAnalytics';
import { useGlobalSettings } from '../../../Hooks/useGlobalSettings';
import { usePlayers } from '../../../Hooks/usePlayers';
import { Cog, Info } from '../../../Icons/generated'; import { Cog, Info } from '../../../Icons/generated';
import {
GameFormat,
InitialGameSettings,
Orientation,
} from '../../../Types/Settings';
import { InfoModal } from '../../Misc/InfoModal'; import { InfoModal } from '../../Misc/InfoModal';
import { SettingsModal } from '../../Misc/SettingsModal';
import { SupportMe } from '../../Misc/SupportMe'; import { SupportMe } from '../../Misc/SupportMe';
import { LayoutOptions } from './LayoutOptions'; import { H1, Paragraph } from '../../Misc/TextComponents';
import LayoutOptions from './LayoutOptions';
import { Spacer } from '../../Misc/Spacer';
import { usePlayers } from '../../../Hooks/usePlayers';
import { useGlobalSettings } from '../../../Hooks/useGlobalSettings';
import { GameFormat, InitialGameSettings } from '../../../Types/Settings';
import { SettingsModal } from '../../Misc/SettingsModal';
const MainWrapper = twc.div`w-[100dvw] h-fit pb-14 overflow-hidden items-center flex flex-col`; const MainWrapper = styled.div`
width: 100dvw;
height: fit-content;
padding-bottom: 58px;
overflow: hidden;
align-items: center;
display: flex;
flex-direction: column;
`;
const StartButtonFooter = twc.div`w-full max-w-[548px] fixed bottom-4 z-1 items-center flex flex-col px-4 z-10`; const StartButtonFooter = styled.div`
position: fixed;
bottom: 1rem;
translate: -50%, -50%;
z-index: 1;
`;
const SliderWrapper = twc.div`mx-8`; const ToggleButtonsWrapper = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
`;
const ToggleButtonsWrapper = twc.div`flex flex-row justify-between items-center`; const ToggleContainer = styled.div`
display: flex;
const ToggleContainer = twc.div`flex flex-col items-center`; flex-direction: column;
align-items: center;
`;
const playerMarks = [ const playerMarks = [
{ {
@@ -99,8 +125,8 @@ const Start = () => {
numberOfPlayers: 4, numberOfPlayers: 4,
startingLifeTotal: 40, startingLifeTotal: 40,
useCommanderDamage: true, useCommanderDamage: true,
orientation: Orientation.Portrait,
gameFormat: GameFormat.Commander, gameFormat: GameFormat.Commander,
gridAreas: GridTemplateAreas.FourPlayers,
} }
); );
@@ -138,9 +164,31 @@ const Start = () => {
return `${value}`; return `${value}`;
}; };
const getDefaultLayout = (numberOfPlayers: number) => {
switch (numberOfPlayers) {
case 1:
return GridTemplateAreas.OnePlayerLandscape;
case 2:
return GridTemplateAreas.TwoPlayersSameSide;
case 3:
return GridTemplateAreas.ThreePlayers;
case 4:
return GridTemplateAreas.FourPlayers;
case 5:
return GridTemplateAreas.FivePlayers;
case 6:
return GridTemplateAreas.SixPlayers;
default:
return GridTemplateAreas.FourPlayers;
}
};
useEffect(() => { useEffect(() => {
const defaultLayout = getDefaultLayout(playerOptions.numberOfPlayers);
setPlayerOptions({ setPlayerOptions({
...playerOptions, ...playerOptions,
gridAreas: defaultLayout,
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [playerOptions.numberOfPlayers]); }, [playerOptions.numberOfPlayers]);
@@ -172,134 +220,167 @@ const Start = () => {
<SupportMe /> <SupportMe />
<h1 className="text-3xl block font-bold mt-6 mb-5 text-text-primary"> <H1>Life Trinket</H1>
Life Trinket <FormControl focused={false} style={{ width: '80vw' }}>
</h1> <FormLabel>
{playerOptions.gameFormat === GameFormat.TwoHeadedGiant
? 'Number of Teams'
: 'Number of Players'}
</FormLabel>
<Slider
title="Number of Players"
max={6}
min={1}
aria-label="Custom marks"
value={playerOptions?.numberOfPlayers ?? 4}
getAriaValueText={valuetext}
step={null}
marks={playerMarks}
onChange={(_e, value) => {
setPlayerOptions({
...playerOptions,
numberOfPlayers: value as number,
});
}}
/>
<Spacer height="0.7rem" />
<FormLabel>Starting Health</FormLabel>
<Slider
title="Starting Health"
max={60}
min={20}
aria-label="Custom marks"
value={playerOptions?.startingLifeTotal ?? 40}
getAriaValueText={valuetext}
step={10}
marks={healthMarks}
onChange={(_e, value) =>
setPlayerOptions({
...playerOptions,
startingLifeTotal: value as number,
})
}
/>
<Spacer height="1rem" />
<div className="overflow-hidden items-center flex flex-col max-w-[548px] w-full mb-8 px-4"> <ToggleButtonsWrapper>
<FormControl focused={false} style={{ width: '100%' }}> <ToggleContainer>
<FormLabel>Number of Players</FormLabel> <FormLabel>Use Commander Damage</FormLabel>
<SliderWrapper> <Switch
<Slider disabled={playerOptions.gameFormat === GameFormat.Commander}
title="Number of Players" checked={playerOptions.useCommanderDamage}
max={6}
min={1}
aria-label="Custom marks"
value={playerOptions?.numberOfPlayers ?? 4}
getAriaValueText={valuetext}
step={null}
marks={playerMarks}
onChange={(_e, value) => { onChange={(_e, value) => {
setPlayerOptions({ switch (playerOptions.gameFormat) {
...playerOptions, case GameFormat.TwoHeadedGiant:
numberOfPlayers: value as number, if (value) {
orientation: Orientation.Landscape, setPlayerOptions({
}); ...playerOptions,
}} useCommanderDamage: value,
/> startingLifeTotal: 60,
</SliderWrapper> });
return;
<FormLabel className="mt-[0.7rem]">Starting Health</FormLabel> }
<SliderWrapper>
<Slider
title="Starting Health"
max={60}
min={20}
aria-label="Custom marks"
value={playerOptions?.startingLifeTotal ?? 40}
getAriaValueText={valuetext}
step={10}
marks={healthMarks}
onChange={(_e, value) =>
setPlayerOptions({
...playerOptions,
startingLifeTotal: value as number,
orientation: Orientation.Landscape,
})
}
/>
</SliderWrapper>
<ToggleButtonsWrapper className="mt-4">
<ToggleContainer>
<FormLabel>Commander</FormLabel>
<Switch
checked={
playerOptions.useCommanderDamage ??
initialGameSettings?.useCommanderDamage ??
true
}
onChange={(_e, value) => {
if (value) {
setPlayerOptions({ setPlayerOptions({
...playerOptions, ...playerOptions,
useCommanderDamage: value, useCommanderDamage: value,
numberOfPlayers: 4, startingLifeTotal: 30,
startingLifeTotal: 40,
orientation: Orientation.Landscape,
}); });
return; return;
}
setPlayerOptions({
...playerOptions,
useCommanderDamage: value,
numberOfPlayers: 2,
startingLifeTotal: 20,
orientation: Orientation.Landscape,
});
}}
/>
</ToggleContainer>
<Button
variant="contained"
style={{ height: '2rem' }}
onClick={() => {
setOpenSettingsModal(true);
}}
>
<Cog /> &nbsp; Other settings
</Button>
</ToggleButtonsWrapper>
<FormLabel>Layout</FormLabel> case GameFormat.Standard:
{/* <LayoutOptions case GameFormat.Commander:
default:
setPlayerOptions({
...playerOptions,
useCommanderDamage: value,
});
return;
}
}}
/>
</ToggleContainer>
<Button
variant="contained"
style={{ height: '2rem' }}
onClick={() => {
setOpenSettingsModal(true);
}}
>
<Cog /> &nbsp; Other settings
</Button>
</ToggleButtonsWrapper>
<FormLabel>Format</FormLabel>
<Select
value={playerOptions.gameFormat}
onChange={(e) => {
switch (e.target.value) {
case GameFormat.Standard:
setPlayerOptions({
...playerOptions,
useCommanderDamage: false,
numberOfPlayers: 2,
startingLifeTotal: 20,
gameFormat: GameFormat.Standard,
});
return;
case GameFormat.TwoHeadedGiant:
setPlayerOptions({
...playerOptions,
useCommanderDamage: false,
numberOfPlayers: 2,
startingLifeTotal: 30,
gameFormat: GameFormat.TwoHeadedGiant,
});
return;
case GameFormat.Commander:
default:
setPlayerOptions({
...playerOptions,
useCommanderDamage: true,
numberOfPlayers: 4,
startingLifeTotal: 40,
gameFormat: GameFormat.Commander,
});
return;
}
}}
>
<MenuItem value={GameFormat.Commander}>Commander</MenuItem>
<MenuItem value={GameFormat.Standard}>Standard</MenuItem>
<MenuItem value={GameFormat.TwoHeadedGiant}>
Two Headed Giant
</MenuItem>
</Select>
<FormLabel>Layout</FormLabel>
<LayoutOptions
numberOfPlayers={playerOptions.numberOfPlayers} numberOfPlayers={playerOptions.numberOfPlayers}
gridAreas={playerOptions.gridAreas} gridAreas={playerOptions.gridAreas}
onChange={(gridAreas) => onChange={(gridAreas) =>
setPlayerOptions({ setPlayerOptions({ ...playerOptions, gridAreas })
...playerOptions,
gridAreas,
//TODO fix the layout selection
orientation: Orientation.Portrait,
})
} }
/> */} />
<LayoutOptions </FormControl>
numberOfPlayers={playerOptions.numberOfPlayers}
selectedOrientation={playerOptions.orientation} {!isPWA && (
onChange={(orientation) => { <Paragraph
setPlayerOptions({ style={{ textAlign: 'center', maxWidth: '75%', fontSize: '0.7rem' }}
...playerOptions, >
orientation, If you're on iOS, this page works better if you{' '}
}); <strong>hide the toolbar</strong> or{' '}
}} <strong>add the app to your home screen</strong>.
/> </Paragraph>
</FormControl> )}
{!isPWA && (
<p className="text-center text-xs text-text-primary w-11/12 mt-4">
If you're on iOS, this page works better if you{' '}
<strong>hide the toolbar</strong> or{' '}
<strong>add the app to your home screen</strong>.
</p>
)}
</div>
<StartButtonFooter> <StartButtonFooter>
<Button <Button
size="large" size="large"
variant="contained" variant="contained"
onClick={doStartGame} onClick={doStartGame}
fullWidth style={{ width: '90dvw' }}
> >
START GAME START GAME
</Button> </Button>

View File

@@ -22,10 +22,6 @@ export type GlobalSettingsContextType = {
setInitialGameSettings: (initialGameSettings: InitialGameSettings) => void; setInitialGameSettings: (initialGameSettings: InitialGameSettings) => void;
settings: Settings; settings: Settings;
setSettings: (settings: Settings) => void; setSettings: (settings: Settings) => void;
playing: boolean;
setPlaying: (playing: boolean) => void;
stopPlayerRandomization: boolean;
setStopPlayerRandomization: (stopRandom: boolean) => void;
isPWA: boolean; isPWA: boolean;
}; };

View File

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

View File

@@ -0,0 +1,15 @@
export enum GridTemplateAreas {
OnePlayerLandscape = '"player0 player0"',
OnePlayerPortrait = '"player0" "player0"',
TwoPlayersOppositeLandscape = '"player0" "player1"',
TwoPlayersOppositePortrait = '"player0 player1" "player0 player1"',
TwoPlayersSameSide = '"player0 player1"',
ThreePlayers = '"player0 player0" "player1 player2"',
ThreePlayersSide = '"player0 player0 player0 player2" "player1 player1 player1 player2"',
FourPlayers = '"player0 player1" "player2 player3"',
FourPlayersSide = '"player0 player1 player1 player1 player3" "player0 player2 player2 player2 player3"',
FivePlayers = '"player0 player0 player0 player1 player1 player1" "player2 player2 player3 player3 player4 player4"',
FivePlayersSide = '"player0 player0 player0 player0 player0 player1 player1 player1 player1 player1 player2" "player3 player3 player3 player3 player3 player4 player4 player4 player4 player4 player2"',
SixPlayers = '"player0 player1 player2" "player3 player4 player5"',
SixPlayersSide = '"player0 player1 player1 player1 player1 player2 player2 player2 player2 player3" "player0 player4 player4 player4 player4 player5 player5 player5 player5 player3"',
}

View File

@@ -1,5 +1,6 @@
import { Player, Rotation } from '../Types/Player'; import { Player, Rotation } from '../Types/Player';
import { InitialGameSettings, Orientation } from '../Types/Settings'; import { InitialGameSettings } from '../Types/Settings';
import { GridTemplateAreas } from './GridTemplateAreas';
const presetColors = [ const presetColors = [
'#F06292', // Light Pink '#F06292', // Light Pink
@@ -12,187 +13,191 @@ const presetColors = [
'#FF8A80', // Coral '#FF8A80', // Coral
]; ];
const getOrientationRotations = ( const getRotation = (index: number, gridAreas: GridTemplateAreas): Rotation => {
index: number, if (gridAreas === GridTemplateAreas.OnePlayerLandscape && index === 0) {
numberOfPlayers: number, return Rotation.Normal;
orientation: Orientation
): Rotation => {
switch (numberOfPlayers) {
case 1:
switch (orientation) {
default:
case Orientation.Landscape:
return Rotation.Normal;
case Orientation.Portrait:
return Rotation.Side;
}
case 2:
switch (orientation) {
default:
case Orientation.Landscape:
return Rotation.Normal;
case Orientation.Portrait:
switch (index) {
case 0:
return Rotation.SideFlipped;
case 1:
return Rotation.Side;
default:
return Rotation.Normal;
}
case Orientation.OppositeLandscape:
switch (index) {
case 0:
return Rotation.Flipped;
case 1:
return Rotation.Normal;
default:
return Rotation.Normal;
}
}
case 3:
switch (orientation) {
default:
case Orientation.Landscape:
switch (index) {
case 0:
return Rotation.Flipped;
case 1:
return Rotation.Normal;
case 2:
return Rotation.Normal;
default:
return Rotation.Normal;
}
case Orientation.Portrait:
switch (index) {
case 0:
return Rotation.Flipped;
case 1:
return Rotation.Normal;
case 2:
return Rotation.Side;
default:
return Rotation.Normal;
}
}
case 4:
switch (orientation) {
default:
case Orientation.Landscape:
switch (index) {
case 0:
return Rotation.Flipped;
case 1:
return Rotation.Flipped;
case 2:
return Rotation.Normal;
case 3:
return Rotation.Normal;
default:
return Rotation.Normal;
}
case Orientation.Portrait:
switch (index) {
case 0:
return Rotation.SideFlipped;
case 1:
return Rotation.Flipped;
case 2:
return Rotation.Normal;
case 3:
return Rotation.Side;
default:
return Rotation.Normal;
}
}
case 5:
switch (orientation) {
default:
case Orientation.Landscape:
switch (index) {
case 0:
return Rotation.Flipped;
case 1:
return Rotation.Flipped;
case 2:
return Rotation.Normal;
case 3:
return Rotation.Normal;
case 4:
return Rotation.Normal;
default:
return Rotation.Normal;
}
case Orientation.Portrait:
switch (index) {
case 0:
return Rotation.Side;
case 1:
return Rotation.Side;
case 2:
return Rotation.SideFlipped;
case 3:
return Rotation.SideFlipped;
case 4:
return Rotation.SideFlipped;
default:
return Rotation.Normal;
}
}
case 6:
switch (orientation) {
default:
case Orientation.Landscape:
switch (index) {
case 0:
return Rotation.Flipped;
case 1:
return Rotation.Flipped;
case 2:
return Rotation.Flipped;
case 3:
return Rotation.Normal;
case 4:
return Rotation.Normal;
case 5:
return Rotation.Normal;
default:
return Rotation.Normal;
}
case Orientation.Portrait:
switch (index) {
case 0:
return Rotation.Side;
case 1:
return Rotation.Side;
case 2:
return Rotation.Side;
case 3:
return Rotation.SideFlipped;
case 4:
return Rotation.SideFlipped;
case 5:
return Rotation.SideFlipped;
default:
return Rotation.Normal;
}
}
default:
return Rotation.Normal;
} }
if (gridAreas === GridTemplateAreas.OnePlayerPortrait && index === 0) {
return Rotation.Side;
}
if (gridAreas === GridTemplateAreas.TwoPlayersOppositePortrait) {
switch (index) {
case 0:
return Rotation.SideFlipped;
case 1:
return Rotation.Side;
default:
return Rotation.Normal;
}
}
if (gridAreas === GridTemplateAreas.TwoPlayersOppositeLandscape) {
switch (index) {
case 0:
return Rotation.Flipped;
case 1:
return Rotation.Normal;
default:
return Rotation.Normal;
}
}
if (gridAreas === GridTemplateAreas.TwoPlayersSameSide) {
switch (index) {
case 0:
return Rotation.Normal;
case 1:
return Rotation.Normal;
default:
return Rotation.Normal;
}
}
if (gridAreas === GridTemplateAreas.ThreePlayers) {
switch (index) {
case 0:
return Rotation.Flipped;
case 1:
return Rotation.Normal;
case 2:
return Rotation.Normal;
default:
return Rotation.Normal;
}
}
if (gridAreas === GridTemplateAreas.ThreePlayersSide) {
switch (index) {
case 0:
return Rotation.Flipped;
case 1:
return Rotation.Normal;
case 2:
return Rotation.Side;
default:
return Rotation.Normal;
}
}
if (gridAreas === GridTemplateAreas.FourPlayers) {
switch (index) {
case 0:
return Rotation.Flipped;
case 1:
return Rotation.Flipped;
case 2:
return Rotation.Normal;
case 3:
return Rotation.Normal;
default:
return Rotation.Normal;
}
}
if (gridAreas === GridTemplateAreas.FourPlayersSide) {
switch (index) {
case 0:
return Rotation.SideFlipped;
case 1:
return Rotation.Flipped;
case 2:
return Rotation.Normal;
case 3:
return Rotation.Side;
default:
return Rotation.Normal;
}
}
if (gridAreas === GridTemplateAreas.FivePlayers) {
switch (index) {
case 0:
return Rotation.Flipped;
case 1:
return Rotation.Flipped;
case 2:
return Rotation.Normal;
case 3:
return Rotation.Normal;
case 4:
return Rotation.Normal;
default:
return Rotation.Normal;
}
}
if (gridAreas === GridTemplateAreas.FivePlayersSide) {
switch (index) {
case 0:
return Rotation.Flipped;
case 1:
return Rotation.Flipped;
case 2:
return Rotation.Side;
case 3:
return Rotation.Normal;
case 4:
return Rotation.Normal;
default:
return Rotation.Normal;
}
}
if (gridAreas === GridTemplateAreas.SixPlayers) {
switch (index) {
case 0:
return Rotation.Flipped;
case 1:
return Rotation.Flipped;
case 2:
return Rotation.Flipped;
case 3:
return Rotation.Normal;
case 4:
return Rotation.Normal;
case 5:
return Rotation.Normal;
default:
return Rotation.Normal;
}
}
if (gridAreas === GridTemplateAreas.SixPlayersSide) {
switch (index) {
case 0:
return Rotation.SideFlipped;
case 1:
return Rotation.Flipped;
case 2:
return Rotation.Flipped;
case 3:
return Rotation.Side;
case 4:
return Rotation.Normal;
case 5:
return Rotation.Normal;
default:
return Rotation.Normal;
}
}
return Rotation.Normal;
}; };
export const createInitialPlayers = ({ export const createInitialPlayers = ({
numberOfPlayers, numberOfPlayers,
startingLifeTotal, startingLifeTotal,
useCommanderDamage, useCommanderDamage,
orientation, gridAreas,
}: InitialGameSettings): Player[] => { }: InitialGameSettings): Player[] => {
const players: Player[] = []; const players: Player[] = [];
const availableColors = [...presetColors]; // Create a copy of the colors array const availableColors = [...presetColors]; // Create a copy of the colors array
const firstPlayerIndex = Math.floor(Math.random() * numberOfPlayers);
for (let i = 0; i <= numberOfPlayers - 1; i++) { for (let i = 0; i <= numberOfPlayers - 1; i++) {
const isStartingPlayer = i === firstPlayerIndex;
const colorIndex = Math.floor(Math.random() * availableColors.length); const colorIndex = Math.floor(Math.random() * availableColors.length);
const color = availableColors[colorIndex]; const color = availableColors[colorIndex];
@@ -208,7 +213,7 @@ export const createInitialPlayers = ({
}); });
} }
const rotation = getOrientationRotations(i, numberOfPlayers, orientation); const rotation = getRotation(i, gridAreas);
const player: Player = { const player: Player = {
lifeTotal: startingLifeTotal, lifeTotal: startingLifeTotal,
@@ -222,11 +227,11 @@ export const createInitialPlayers = ({
usePoison: false, usePoison: false,
rotation, rotation,
}, },
isStartingPlayer,
showStartingPlayer: isStartingPlayer,
extraCounters: [], extraCounters: [],
commanderDamage, commanderDamage,
hasLost: false, hasLost: false,
isStartingPlayer: false,
isSide: rotation === Rotation.Side || rotation === Rotation.SideFlipped,
}; };
players.push(player); players.push(player);

View File

@@ -1,28 +1,34 @@
import { createTheme } from '@mui/material'; import { createTheme } from '@mui/material';
import resolveConfig from 'tailwindcss/resolveConfig';
import tailwindConfig from '../../tailwind.config';
//TODO Create provider for this
const fullConfig = resolveConfig(tailwindConfig);
const { primary, secondary, background, text, action, common } =
fullConfig.theme.colors;
export const theme = createTheme({ export const theme = createTheme({
palette: { palette: {
primary, primary: {
secondary, main: '#7F9172',
background, },
text, secondary: {
action, main: '#5E714C',
common, },
background: {
default: '#495E35',
},
text: {
primary: '#F5F5F5',
secondary: '#b3b39b',
},
action: {
disabled: '#5E714C',
},
common: {
white: '#F9FFE3',
black: '#000000',
},
}, },
components: { components: {
MuiFormLabel: { MuiFormLabel: {
styleOverrides: { styleOverrides: {
root: { root: {
fontSize: '1rem', fontSize: '1rem',
color: text.primary, color: '#F5F5F5',
}, },
}, },
}, },
@@ -30,12 +36,12 @@ export const theme = createTheme({
styleOverrides: { styleOverrides: {
markLabel: { markLabel: {
fontSize: '1rem', fontSize: '1rem',
color: text.primary, color: '#F5F5F5',
}, },
valueLabel: { valueLabel: {
display: 'none', display: 'none',
color: text.primary, color: '#F5F5F5',
background: secondary.main, background: '#5E714C',
}, },
track: { track: {
height: '0.7rem', height: '0.7rem',
@@ -71,7 +77,7 @@ export const theme = createTheme({
styleOverrides: { styleOverrides: {
paper: { paper: {
top: '1rem', top: '1rem',
background: background.default, background: '#495E35',
height: 'auto', height: 'auto',
borderRadius: '8px', borderRadius: '8px',
}, },
@@ -80,7 +86,7 @@ export const theme = createTheme({
MuiBackdrop: { MuiBackdrop: {
styleOverrides: { styleOverrides: {
root: { root: {
backgroundColor: background.backdrop, backgroundColor: 'rgba(0, 0, 0, 0.3)',
}, },
}, },
}, },
@@ -94,7 +100,28 @@ export const theme = createTheme({
MuiSwitch: { MuiSwitch: {
styleOverrides: { styleOverrides: {
colorPrimary: { colorPrimary: {
color: action.disabled, color: '#5E714C',
},
},
},
MuiPaper: {
styleOverrides: {
root: {
backgroundColor: '#495E35',
},
},
},
MuiMenuItem: {
styleOverrides: {
root: {
backgroundColor: '#495E35',
},
},
},
MuiList: {
styleOverrides: {
root: {
backgroundColor: '#495E35',
}, },
}, },
}, },

View File

@@ -1,53 +0,0 @@
import { useEffect, useState } from 'react';
export interface OrientationState {
angle: number;
type: string;
}
const defaultState: OrientationState = {
angle: 0,
type: 'landscape-primary',
};
export default function useOrientation(
initialState: OrientationState = defaultState
) {
const [state, setState] = useState(initialState);
const [isLandscape, setIsLandscape] = useState(false);
useEffect(() => {
const screen = window.screen;
let mounted = true;
const onChange = () => {
if (mounted) {
const { orientation } = screen;
console.log(orientation);
if (orientation) {
const { angle, type } = orientation;
setState({ angle, type });
if (type.includes('landscape')) {
setIsLandscape(true);
} else if (type.includes('portrait')) {
setIsLandscape(false);
}
} else if (window.orientation !== undefined) {
setState({
angle:
typeof window.orientation === 'number' ? window.orientation : 0,
type: '',
});
}
}
};
onChange();
return () => {
mounted = false;
};
}, [isLandscape]);
return { state, isLandscape };
}

View File

@@ -1,15 +1,11 @@
import { ReactNode, useEffect, useMemo, useState } from 'react'; import { ReactNode, useEffect, useMemo, useState } from 'react';
import { useWakeLock } from 'react-screen-wake-lock';
import { import {
GlobalSettingsContext, GlobalSettingsContext,
GlobalSettingsContextType, GlobalSettingsContextType,
} from '../Contexts/GlobalSettingsContext'; } from '../Contexts/GlobalSettingsContext';
import { useWakeLock } from 'react-screen-wake-lock';
import { useAnalytics } from '../Hooks/useAnalytics'; import { useAnalytics } from '../Hooks/useAnalytics';
import { import { InitialGameSettings, Settings } from '../Types/Settings';
InitialGameSettings,
InitialGameSettingsSchema,
Settings,
} from '../Types/Settings';
export const GlobalSettingsProvider = ({ export const GlobalSettingsProvider = ({
children, children,
@@ -21,23 +17,11 @@ export const GlobalSettingsProvider = ({
const savedShowPlay = localStorage.getItem('showPlay'); const savedShowPlay = localStorage.getItem('showPlay');
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 [playing, setPlaying] = useState<boolean>(
savedPlaying ? savedPlaying === 'true' : false
);
const setPlayingAndLocalStorage = (playing: boolean) => {
setPlaying(playing);
localStorage.setItem('playing', String(playing));
};
const [showPlay, setShowPlay] = useState<boolean>( const [showPlay, setShowPlay] = useState<boolean>(
savedShowPlay ? savedShowPlay === 'true' : false savedShowPlay ? savedShowPlay === 'true' : false
); );
const [stopPlayerRandomization, setStopPlayerRandomization] =
useState<boolean>(false);
const [initialGameSettings, setInitialGameSettings] = const [initialGameSettings, setInitialGameSettings] =
useState<InitialGameSettings | null>( useState<InitialGameSettings | null>(
savedGameSettings ? JSON.parse(savedGameSettings) : null savedGameSettings ? JSON.parse(savedGameSettings) : null
@@ -46,47 +30,15 @@ export const GlobalSettingsProvider = ({
const [settings, setSettings] = useState<Settings>( const [settings, setSettings] = useState<Settings>(
savedSettings savedSettings
? JSON.parse(savedSettings) ? JSON.parse(savedSettings)
: { : { goFullscreenOnStart: true, keepAwake: true, showStartingPlayer: true }
goFullscreenOnStart: true,
keepAwake: true,
showStartingPlayer: true,
showPlayerMenuCog: true,
useRandomStartingPlayerInterval: false,
}
); );
const removeLocalStorage = async () => {
localStorage.removeItem('initialGameSettings');
localStorage.removeItem('players');
localStorage.removeItem('playing');
localStorage.removeItem('showPlay');
setPlaying(false);
setShowPlay(false);
};
useEffect(() => { useEffect(() => {
if (savedGameSettings && JSON.parse(savedGameSettings).gridAreas) {
removeLocalStorage();
window.location.reload();
return;
}
//parse existing game settings with zod schema
const parsedInitialGameSettings =
InitialGameSettingsSchema.safeParse(initialGameSettings);
if (!parsedInitialGameSettings.success) {
removeLocalStorage();
window.location.reload();
return;
}
localStorage.setItem( localStorage.setItem(
'initialGameSettings', 'initialGameSettings',
JSON.stringify(initialGameSettings) JSON.stringify(initialGameSettings)
); );
}, [initialGameSettings, savedGameSettings]); }, [initialGameSettings]);
useEffect(() => { useEffect(() => {
localStorage.setItem('settings', JSON.stringify(settings)); localStorage.setItem('settings', JSON.stringify(settings));
@@ -115,6 +67,14 @@ export const GlobalSettingsProvider = ({
request(); request();
} }
const removeLocalStorage = async () => {
localStorage.removeItem('initialGameSettings');
localStorage.removeItem('players');
localStorage.removeItem('playing');
localStorage.removeItem('showPlay');
setShowPlay(localStorage.getItem('showPlay') === 'true' ?? false);
};
const ctxValue = useMemo((): GlobalSettingsContextType => { const ctxValue = useMemo((): GlobalSettingsContextType => {
const goToStart = async () => { const goToStart = async () => {
const currentPlayers = localStorage.getItem('players'); const currentPlayers = localStorage.getItem('players');
@@ -129,6 +89,7 @@ export const GlobalSettingsProvider = ({
}; };
const toggleWakeLock = async () => { const toggleWakeLock = async () => {
console.log('on press', active);
if (active) { if (active) {
setSettings({ ...settings, keepAwake: false }); setSettings({ ...settings, keepAwake: false });
release(); release();
@@ -168,14 +129,10 @@ export const GlobalSettingsProvider = ({
goToStart, goToStart,
showPlay, showPlay,
setShowPlay, setShowPlay,
playing,
setPlaying: setPlayingAndLocalStorage,
initialGameSettings, initialGameSettings,
setInitialGameSettings, setInitialGameSettings,
settings, settings,
setSettings, setSettings,
stopPlayerRandomization,
setStopPlayerRandomization,
isPWA: window?.matchMedia('(display-mode: standalone)').matches, isPWA: window?.matchMedia('(display-mode: standalone)').matches,
}; };
}, [ }, [
@@ -184,12 +141,10 @@ export const GlobalSettingsProvider = ({
initialGameSettings, initialGameSettings,
isFullscreen, isFullscreen,
isSupported, isSupported,
playing,
release, release,
request, request,
settings, settings,
showPlay, showPlay,
stopPlayerRandomization,
type, type,
]); ]);

View File

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

View File

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

View File

@@ -1,37 +1,21 @@
import { z } from 'zod'; import { GridTemplateAreas } from '../Data/GridTemplateAreas';
export enum Orientation { export type Settings = {
OppositeLandscape = 'opposite-landscape', keepAwake: boolean;
Landscape = 'landscape', showStartingPlayer: boolean;
Portrait = 'portrait', goFullscreenOnStart: boolean;
} };
export type InitialGameSettings = {
startingLifeTotal: number;
useCommanderDamage: boolean;
gameFormat: GameFormat;
numberOfPlayers: number;
gridAreas: GridTemplateAreas;
};
export enum GameFormat { export enum GameFormat {
Commander = 'commander', Commander = 'commander',
Standard = 'standard', Standard = 'standard',
TwoHeadedGiant = 'two-headed-giant', TwoHeadedGiant = 'two-headed-giant',
} }
export type Settings = {
keepAwake: boolean;
showStartingPlayer: boolean;
showPlayerMenuCog: boolean;
goFullscreenOnStart: boolean;
useRandomStartingPlayerInterval: boolean;
};
export type InitialGameSettings = {
startingLifeTotal: number;
useCommanderDamage: boolean;
gameFormat?: GameFormat;
numberOfPlayers: number;
orientation: Orientation;
};
export const InitialGameSettingsSchema = z.object({
startingLifeTotal: z.number().min(1).max(200).default(20),
useCommanderDamage: z.boolean().default(false),
gameFormat: z.nativeEnum(GameFormat).optional(),
numberOfPlayers: z.number().min(1).max(6).default(2),
orientation: z.nativeEnum(Orientation).default(Orientation.Landscape),
});

View File

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

View File

@@ -1,20 +1,4 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
overflow: hidden;
}
body { body {
overflow: auto;
}
html,
body {
height: 100%;
position: relative;
background-color: theme('colors.background.default');
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
@@ -22,25 +6,8 @@ body {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
#root {
touch-action: manipulation;
}
code { code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace; monospace;
} }
@layer utilities {
.pointer-events-all {
pointer-events: all;
}
.webkit-user-select-none {
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
-moz-user-select: -moz-none;
-webkit-user-select: none;
-ms-user-select: none;
}
}

View File

@@ -1,108 +0,0 @@
//@ts-expect-error - tailwindcss-grid-areas does not have typescript support
import tailwindcssGridAreas from '@savvywombat/tailwindcss-grid-areas';
import type { Config } from 'tailwindcss';
export const baseColors = {
primary: {
main: '#3E7D78',
dark: '#2D5F5B',
},
secondary: {
main: '#284F4C',
dark: '#1B3B38',
},
background: {
default: '#08253B',
backdrop: 'rgba(0, 0, 0, 0.3)',
settings: 'rgba(20, 20, 0, 0.9)',
},
icons: {
dark: '#00000080',
light: '#ffffff4f',
},
text: {
primary: '#F5F5F5',
secondary: '#76A6A5',
},
action: {
disabled: '#234A47',
},
common: {
white: '#F9FFE3',
black: '#000000',
},
lifeCounter: {
text: 'rgba(0, 0, 0, 0.4)',
lostWrapper: '#000000',
},
interface: {
loseButton: {
background: '#43434380',
},
recentDifference: {
background: 'rgba(255, 255, 255, 0.6);',
text: '#333333',
},
},
};
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
screens: {
modalSm: '548px',
},
extend: {
gridTemplateAreas: {
onePlayerLandscape: ['player0 player0'],
onePlayerPortrait: ['player0', 'player0'],
twoPlayersOppositeLandscape: ['player0', 'player1'],
twoPlayersOppositePortrait: ['player0 player1', 'player0 player1'],
twoPlayersSameSideLandscape: ['player0 player1'],
threePlayers: ['player0 player0', 'player1 player2'],
threePlayersSide: [
'player0 player0 player0 player2',
'player1 player1 player1 player2',
],
fourPlayerPortrait: [
'player0 player1 player1 player1 player1 player3',
'player0 player2 player2 player2 player2 player3',
],
fourPlayer: ['player0 player1', 'player2 player3'],
fivePlayers: [
'player0 player0 player0 player1 player1 player1',
'player2 player2 player3 player3 player4 player4',
],
fivePlayersSide: [
'player0 player0 player0 player0 player0 player1 player1 player1 player1 player1 player2',
'player3 player3 player3 player3 player3 player4 player4 player4 player4 player4 player2',
],
sixPlayers: ['player0 player1 player2', 'player3 player4 player5'],
sixPlayersSide: [
'player0 player1 player1 player1 player1 player2 player2 player2 player2 player3',
'player0 player4 player4 player4 player4 player5 player5 player5 player5 player3',
],
},
colors: baseColors,
keyframes: {
fadeOut: {
'0%': {
opacity: '1',
},
'33%': {
opacity: '0.6',
},
'100%': {
opacity: '0',
},
},
},
animation: {
fadeOut: 'fadeOut 3s 1s ease-out forwards',
},
},
},
plugins: [tailwindcssGridAreas],
} satisfies Config;
// #98FF98

View File

@@ -1,27 +1,13 @@
import react from '@vitejs/plugin-react-swc';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa'; import react from '@vitejs/plugin-react-swc';
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [react()],
react(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
clientsClaim: true,
skipWaiting: true,
},
}),
],
build: { build: {
minify: 'esbuild', minify: 'esbuild',
rollupOptions: { rollupOptions: {
external: ['babel-plugin-macros'], external: ['babel-plugin-macros'],
}, },
}, },
define: {
APP_VERSION: JSON.stringify(process.env.npm_package_version),
REPO_READ_ACCESS_TOKEN: JSON.stringify(process.env.REPO_READ_ACCESS_TOKEN),
},
}); });

8867
yarn.lock

File diff suppressed because it is too large Load Diff