Compare commits

..

1 Commits

Author SHA1 Message Date
Vikeo
8cf42b9a98 fix commander damage text not being centered on chrome 2025-02-22 11:45:05 +01:00
7 changed files with 3654 additions and 4259 deletions

View File

@@ -17,43 +17,39 @@
"deploy": "bun run build && firebase deploy --only hosting"
},
"dependencies": {
"firebase": "^10.14.1",
"firebase": "^10.3.0",
"ga-4-react": "^0.1.281",
"pako": "^2.1.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-screen-wake-lock": "^3.0.2",
"react-swipeable": "^7.0.2",
"react-twc": "^1.4.2",
"semver": "^7.7.1",
"zod": "^3.24.2"
"react-swipeable": "^7.0.1",
"react-twc": "^1.3.0",
"semver": "^7.6.2",
"zod": "^3.22.4"
},
"devDependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@savvywombat/tailwindcss-grid-areas": "^4.0.0",
"@svgr/cli": "^8.1.0",
"@types/pako": "^2.0.4",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@types/semver": "^7.5.8",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react-swc": "^3.8.0",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"@typescript-eslint/eslint-plugin": "^7.8.0",
"@typescript-eslint/parser": "^7.8.0",
"@vitejs/plugin-react-swc": "^3.6.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.19",
"firebase-tools": "^13.31.2",
"eslint-plugin-react-refresh": "^0.4.6",
"firebase-tools": "^13.7.5",
"install": "^0.13.0",
"postcss": "^8.5.3",
"postcss": "^8.4.38",
"prettier": "2.8.8",
"prop-types": "^15.8.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite": "^5.4.14",
"vite-plugin-pwa": "^0.20.5"
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.10",
"vite-plugin-pwa": "^0.20.0"
}
}

7514
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
import { useRef } from 'react';
import { twc } from 'react-twc';
import { useAnalytics } from '../../Hooks/useAnalytics';
import { useGlobalSettings } from '../../Hooks/useGlobalSettings';
@@ -8,7 +7,6 @@ import { Separator } from '../Misc/Separator';
import { Paragraph } from '../Misc/TextComponents';
import { ToggleButton } from '../Misc/ToggleButton';
import { Dialog } from './Dialog';
import { ShareGameDialog } from './ShareGameDialog';
const SettingContainer = twc.div`w-full flex flex-col mb-2`;
@@ -26,7 +24,6 @@ export const SettingsDialog = ({
}) => {
const { settings, setSettings, isPWA, version } = useGlobalSettings();
const analytics = useAnalytics();
const shareGameDialogRef = useRef<HTMLDialogElement | null>(null);
return (
<Dialog id="settings" title="⚙️ Settings ⚙️" dialogRef={dialogRef}>
@@ -246,16 +243,7 @@ export const SettingsDialog = ({
</Description>
</SettingContainer>
<Separator height="1px" />
<div className="flex w-full justify-center gap-3">
<button
className="mt-1 mb-1 bg-secondary-main px-3 py-1 rounded-md duration-200 ease-in-out shadow-[1px_2px_4px_0px_rgba(0,0,0,0.3)] hover:bg-secondary-dark font-bold"
onClick={() => {
analytics.trackEvent('share_game_button_clicked');
shareGameDialogRef.current?.showModal();
}}
>
<span className="text-sm">Share Game</span>
</button>
<div className="flex w-full justify-center">
<button
className="mt-1 mb-1 bg-primary-main px-3 py-1 rounded-md duration-200 ease-in-out shadow-[1px_2px_4px_0px_rgba(0,0,0,0.3)] hover:bg-primary-dark font-bold"
onClick={() => {
@@ -266,7 +254,6 @@ export const SettingsDialog = ({
<span className="text-sm">Save and Close</span>
</button>
</div>
<ShareGameDialog dialogRef={shareGameDialogRef} />
{!isPWA && (
<>
{window.isIOS && (

View File

@@ -1,138 +0,0 @@
import { useEffect, useState } from 'react';
import QRCode from 'qrcode';
import { Dialog } from './Dialog';
import { generateShareableUrl } from '../../Utils/gameStateSharing';
import { useAnalytics } from '../../Hooks/useAnalytics';
export const ShareGameDialog = ({
dialogRef,
}: {
dialogRef: React.MutableRefObject<HTMLDialogElement | null>;
}) => {
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
const [shareableUrl, setShareableUrl] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>('');
const [copied, setCopied] = useState(false);
const analytics = useAnalytics();
useEffect(() => {
// Generate QR code when dialog opens
const dialog = dialogRef.current;
if (!dialog) return;
const handleOpen = async () => {
setIsLoading(true);
setError('');
setCopied(false);
try {
// Small delay to ensure all localStorage writes from useEffect have completed
await new Promise((resolve) => setTimeout(resolve, 50));
// Generate the shareable URL
const url = generateShareableUrl();
setShareableUrl(url);
// Generate QR code with lower error correction for less detail
const qrDataUrl = await QRCode.toDataURL(url, {
width: 300,
margin: 2,
errorCorrectionLevel: 'L', // Low error correction = simpler QR code
color: {
dark: '#000000',
light: '#FFFFFF',
},
});
setQrCodeUrl(qrDataUrl);
analytics.trackEvent('share_game_qr_generated');
} catch (err) {
console.error('Error generating QR code:', err);
setError('Failed to generate QR code. Please try again.');
analytics.trackEvent('share_game_qr_error');
} finally {
setIsLoading(false);
}
};
// Listen for dialog open events
const observer = new MutationObserver(() => {
if (dialog.open) {
handleOpen();
}
});
observer.observe(dialog, { attributes: true, attributeFilter: ['open'] });
return () => observer.disconnect();
}, [dialogRef, analytics]);
const handleCopyUrl = async () => {
try {
await navigator.clipboard.writeText(shareableUrl);
setCopied(true);
analytics.trackEvent('share_game_url_copied');
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy URL:', err);
analytics.trackEvent('share_game_url_copy_error');
}
};
return (
<Dialog id="share-game" title="Share Game" dialogRef={dialogRef}>
<div className="flex flex-col items-center gap-4 py-4">
{isLoading && (
<div className="text-text-secondary">Generating QR code...</div>
)}
{error && (
<div className="text-red-500 text-sm text-center">{error}</div>
)}
{!isLoading && !error && qrCodeUrl && (
<>
<div className="bg-white p-4 rounded-lg">
<img src={qrCodeUrl} alt="QR Code" className="w-full h-auto" />
</div>
<div className="text-text-secondary text-sm text-center max-w-full">
Scan this QR code to share the current game state
</div>
<div className="w-full flex flex-col gap-2">
<div className="text-xs text-text-secondary text-center">
Or copy the link:
</div>
<div className="flex gap-2 items-center w-full">
<input
type="text"
value={shareableUrl}
readOnly
className="flex-1 bg-secondary-main text-text-primary text-xs px-2 py-1 rounded-md border-none outline-none overflow-hidden text-ellipsis"
onClick={(e) => e.currentTarget.select()}
/>
<button
onClick={handleCopyUrl}
className="bg-primary-main px-3 py-1 rounded-md text-sm font-semibold hover:bg-primary-dark transition-colors"
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
</>
)}
<button
className="mt-2 bg-primary-main px-4 py-2 rounded-md font-semibold hover:bg-primary-dark transition-colors"
onClick={() => {
analytics.trackEvent('share_game_dialog_closed');
dialogRef.current?.close();
}}
>
Close
</button>
</div>
</Dialog>
);
};

View File

@@ -1,7 +1,5 @@
import { useEffect, useState } from 'react';
import { twc } from 'react-twc';
import { useGlobalSettings } from '../Hooks/useGlobalSettings';
import { importGameState } from '../Utils/gameStateSharing';
import { Play } from './Views/Play';
import StartMenu from './Views/StartMenu/StartMenu';
@@ -28,88 +26,8 @@ const EmergencyResetButton = () => {
export const LifeTrinket = () => {
const { showPlay, initialGameSettings } = useGlobalSettings();
// Check for query parameter immediately on every render
const urlParams = new URLSearchParams(window.location.search);
const gameStateParam = urlParams.get('gameStateToLoad');
const [showImportDialog, setShowImportDialog] = useState(!!gameStateParam);
const [importSuccess, setImportSuccess] = useState<boolean | null>(null);
useEffect(() => {
// Update dialog visibility if query parameter changes
if (gameStateParam && !showImportDialog) {
setShowImportDialog(true);
}
}, [gameStateParam, showImportDialog]);
const handleImportConfirm = () => {
const urlParams = new URLSearchParams(window.location.search);
const gameStateParam = urlParams.get('gameStateToLoad');
if (gameStateParam) {
// Import game state to localStorage
const success = importGameState(gameStateParam);
if (success) {
// Remove query parameter and reload the page
// This ensures providers pick up the new localStorage values
window.location.href =
window.location.origin + window.location.pathname;
} else {
// Show error and remove query parameter
setImportSuccess(false);
window.history.replaceState({}, '', window.location.pathname);
setShowImportDialog(false);
}
}
};
const handleImportCancel = () => {
// Remove the query parameter from URL
const newUrl = window.location.pathname;
window.history.replaceState({}, '', newUrl);
setShowImportDialog(false);
};
// If import dialog is shown, only render the dialog and nothing else
if (showImportDialog) {
return (
<div className="fixed inset-0 bg-background-backdrop backdrop-blur-sm z-[9999] flex items-center justify-center">
<div className="bg-background-default rounded-2xl p-8 max-w-md mx-4">
<h2 className="text-2xl text-text-primary font-bold mb-4 text-center">
Load Shared Game?
</h2>
<p className="text-text-secondary mb-6 text-center">
A shared game state has been detected. Would you like to load it?
This will replace your current game state.
</p>
<div className="flex gap-4 justify-center">
<button
onClick={handleImportCancel}
className="px-4 py-2 bg-secondary-main text-text-primary rounded-md font-semibold hover:bg-secondary-dark transition-colors"
>
Cancel
</button>
<button
onClick={handleImportConfirm}
className="px-4 py-2 bg-primary-main text-text-primary rounded-md font-semibold hover:bg-primary-dark transition-colors"
>
Load Game
</button>
</div>
</div>
</div>
);
}
return (
<>
{importSuccess === false && (
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 bg-red-500 text-white px-6 py-3 rounded-md z-[9999]">
Failed to load game state. The link may be corrupted.
</div>
)}
{showPlay && initialGameSettings ? (
<PlayWrapper>
<Play />

View File

@@ -2,4 +2,5 @@ import { twc } from 'react-twc';
export const Paragraph = twc.p`text-text-primary`;
// eslint-disable-next-line react-refresh/only-export-components
export const H1 = twc.h1`text-text-primary;`;

View File

@@ -1,113 +0,0 @@
import pako from 'pako';
export interface GameStateExport {
settings: string | null;
initialGameSettings: string | null;
players: string | null;
playing: string | null;
showPlay: string | null;
preStartComplete: string | null;
savedGame: string | null;
startingPlayerIndex: string | null;
}
/**
* Export all game state from localStorage, compress it, and encode for URL
*/
export function exportGameState(): string {
try {
// Only export essential data with very short keys
const compactState: Record<string, string> = {};
const s = localStorage.getItem('settings');
const i = localStorage.getItem('initialGameSettings');
const p = localStorage.getItem('players');
const si = localStorage.getItem('startingPlayerIndex');
// Only include non-null values
if (s) compactState.s = s;
if (i) compactState.i = i;
if (p) compactState.p = p;
if (si) compactState.si = si;
// Convert to JSON string
const jsonString = JSON.stringify(compactState);
// Compress using pako (gzip) with maximum compression
const compressed = pako.deflate(jsonString, { level: 9 });
// Convert to base64 for URL safety
const base64 = btoa(String.fromCharCode(...compressed));
// Make URL-safe by replacing characters
const urlSafe = base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
return urlSafe;
} catch (error) {
console.error('Error exporting game state:', error);
throw new Error('Failed to export game state');
}
}
/**
* Import game state from compressed URL parameter and load into localStorage
*/
export function importGameState(encodedData: string): boolean {
try {
// Restore base64 characters
let base64 = encodedData.replace(/-/g, '+').replace(/_/g, '/');
// Add padding if needed
while (base64.length % 4) {
base64 += '=';
}
// Decode from base64
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// Decompress
const decompressed = pako.inflate(bytes, { to: 'string' });
// Parse JSON
const compactState: Record<string, string> = JSON.parse(decompressed);
// Validate that we have some data
if (!compactState || typeof compactState !== 'object') {
throw new Error('Invalid game state format');
}
// Map short keys back to full localStorage keys and load
if (compactState.s) localStorage.setItem('settings', compactState.s);
if (compactState.i)
localStorage.setItem('initialGameSettings', compactState.i);
if (compactState.p) localStorage.setItem('players', compactState.p);
if (compactState.si)
localStorage.setItem('startingPlayerIndex', compactState.si);
// Always set playing and showPlay to false after loading
// This ensures the user starts at the main menu and can review the state
localStorage.setItem('playing', 'false');
localStorage.setItem('showPlay', 'false');
return true;
} catch (error) {
console.error('Error importing game state:', error);
return false;
}
}
/**
* Generate shareable URL with game state
*/
export function generateShareableUrl(): string {
const encodedState = exportGameState();
const currentUrl = window.location.origin + window.location.pathname;
return `${currentUrl}?gameStateToLoad=${encodedState}`;
}