Commit 3d517141 authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

puzzle-15 (#2471)

* puzzle-15

* puzzle-review

* puzzle game

* puzzle fixes

* separate puzzle feature in readme
parent d92ef1f4
import type { Feature } from './types';
import { getEnvValue } from '../utils';
const badgeClaimLink = getEnvValue('NEXT_PUBLIC_PUZZLE_GAME_BADGE_CLAIM_LINK');
const title = 'Easter egg puzzle badge';
const config: Feature<{ badgeClaimLink: string }> = (() => {
if (badgeClaimLink) {
return Object.freeze({
title,
isEnabled: true,
badgeClaimLink,
});
}
return Object.freeze({
title,
isEnabled: false,
});
})();
export default config;
......@@ -12,6 +12,7 @@ export { default as csvExport } from './csvExport';
export { default as dataAvailability } from './dataAvailability';
export { default as deFiDropdown } from './deFiDropdown';
export { default as easterEggBadge } from './easterEggBadge';
export { default as easterEggPuzzleBadge } from './easterEggPuzzleBadge';
export { default as externalTxs } from './externalTxs';
export { default as faultProofSystem } from './faultProofSystem';
export { default as gasTracker } from './gasTracker';
......
......@@ -22,6 +22,7 @@ NEXT_PUBLIC_DEX_POOLS_ENABLED=true
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/eth.json
NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/footer-links/eth-mainnet.json
NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/sherblockHolmesBadge
NEXT_PUBLIC_PUZZLE_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/sherblockHolmesBadge
NEXT_PUBLIC_GAS_REFUEL_PROVIDER_CONFIG={'name': 'Need gas?', 'url_template': 'https://smolrefuel.com/?outboundChain={chainId}&partner=blockscout&utm_source=blockscout&disableBridges=true', 'dapp_id': 'smol-refuel', 'logo': 'https://blockscout-content.s3.amazonaws.com/smolrefuel-logo-action-button.png'}
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0xd01175f1efa23f36c5579b3c13e2bbd0885017643a7efef5cbcb6b474384dfa8
NEXT_PUBLIC_HAS_BEACON_CHAIN=true
......
......@@ -1072,6 +1072,7 @@ const schema = yup
NEXT_PUBLIC_REWARDS_SERVICE_API_HOST: yup.string().test(urlTest),
NEXT_PUBLIC_XSTAR_SCORE_URL: yup.string().test(urlTest),
NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK: yup.string().test(urlTest),
NEXT_PUBLIC_PUZZLE_GAME_BADGE_CLAIM_LINK: yup.string().test(urlTest),
NEXT_PUBLIC_TX_EXTERNAL_TRANSACTIONS_CONFIG: yup.mixed().test(
'shape',
'Invalid schema were provided for NEXT_PUBLIC_TX_EXTERNAL_TRANSACTIONS_CONFIG, it should have chain_name, chain_logo_url, and explorer_url_template',
......
......@@ -923,6 +923,13 @@ This feature enables Blockscout Merits program. It requires that the [My account
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK | `string` | Provide to enable the easter egg badge feature | - | - | `https://example.com` | v1.37.0+ |
### Puzzle game badge claim link
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_PUZZLE_GAME_BADGE_CLAIM_LINK | `string` | Provide to enable the easter egg puzzle badge feature | - | - | `https://example.com` | v2.2.0+ |
&nbsp;
## External services configuration
......
import { Box } from '@chakra-ui/react';
import React, { useState, useEffect } from 'react';
const Confetti = () => {
const [ shouldGenerate, setShouldGenerate ] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setShouldGenerate(false);
}, 5000);
return () => clearTimeout(timer);
}, []);
const getRandomShape = () => {
const shapes = [
// Circle
{ borderRadius: '50%' },
// Triangle
{ clipPath: 'polygon(50% 0%, 0% 100%, 100% 100%)' },
// Star
{ clipPath: 'polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)' },
// Diamond
{ clipPath: 'polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)' },
// Square (default)
{ borderRadius: '0' },
];
return shapes[Math.floor(Number(Math.random()) * shapes.length)];
};
const createSplash = (centerX: number, centerY: number, delay: number) => {
const confettiCount = 100;
return Array.from({ length: confettiCount }).map((_, i) => {
const angle = Number(Math.random()) * 360;
const distance = Number(Math.random()) * 200 + 100;
const shape = getRandomShape();
return {
id: `${ centerX }-${ centerY }-${ i }`,
left: `${ centerX }%`,
top: `${ centerY }%`,
angle,
distance,
animationDuration: `${ Number(Math.random()) * 1 + 1 }s`,
animationDelay: `${ delay }s`,
size: `${ Number(Math.random()) * 10 + 5 }px`,
color: `hsl(${ Number(Math.random()) * 360 }, 100%, 50%)`,
transform: `translate(
calc(-50% + ${ Math.cos(angle * Math.PI / 180) * distance }px),
calc(-50% + ${ Math.sin(angle * Math.PI / 180) * distance }px)
)`,
shape,
};
});
};
const splashes = shouldGenerate ? [
createSplash(50, 50, 0), // center
createSplash(20, 30, Number(Math.random()) * 3), // top left
createSplash(80, 30, Number(Math.random()) * 3), // top right
createSplash(20, 70, Number(Math.random()) * 3), // bottom left
createSplash(80, 70, Number(Math.random()) * 3), // bottom right
].flat() : [];
return (
<>
<style>
{ `
@keyframes splash {
0% {
transform: translate(-50%, -50%) scale(0);
opacity: 0;
}
1% {
opacity: 1;
}
80% {
opacity: 0.9;
}
100% {
transform: var(--final-transform) scale(1);
opacity: 0;
}
}
` }
</style>
<Box
position="fixed"
top="0"
left="0"
width="100%"
height="100%"
pointerEvents="none"
zIndex="9999"
>
{ splashes.map((piece) => (
<Box
key={ piece.id }
position="absolute"
left={ piece.left }
top={ piece.top }
width={ piece.size }
height={ piece.size }
backgroundColor={ piece.color }
opacity={ 0 }
animation={ `splash ${ piece.animationDuration } ease-out ${ piece.animationDelay } forwards` }
style={{
transformOrigin: 'center',
'--final-transform': piece.transform,
...piece.shape,
} as unknown as React.CSSProperties}
/>
)) }
</Box>
</>
);
};
export default Confetti;
import { Grid, Box, Flex, Text } from '@chakra-ui/react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import config from 'configs/app';
import { Button } from 'toolkit/chakra/button';
import { Link } from 'toolkit/chakra/link';
import Confetti from './Confetti';
const easterEggPuzzleBadgeFeature = config.features.easterEggPuzzleBadge;
const getPossibleMoves = (emptyIndex: number): Array<number> => {
const moves: Array<number> = [];
const row = Math.floor(emptyIndex / 4);
const col = emptyIndex % 4;
if (row > 0) {
// Move tile from above into the empty space
moves.push((row - 1) * 4 + col);
}
if (row < 3) {
// Move tile from below into the empty space
moves.push((row + 1) * 4 + col);
}
if (col > 0) {
// Move tile from the left into the empty space
moves.push(row * 4 + (col - 1));
}
if (col < 3) {
// Move tile from the right into the empty space
moves.push(row * 4 + (col + 1));
}
return moves;
};
const shuffleBoard = (initialBoard: Array<number>): Array<number> => {
const board = initialBoard.slice(); // Create a copy of the board
let emptyIndex = board.indexOf(15);
let lastMoveIndex = -1;
for (let i = 0; i < 100; i++) {
let possibleMoves = getPossibleMoves(emptyIndex);
// Prevent immediate reversal of the last move
if (lastMoveIndex !== -1) {
possibleMoves = possibleMoves.filter(index => index !== lastMoveIndex);
}
// Randomly select a tile to move into the empty space
const moveIndex = possibleMoves[Math.floor(Math.random() * possibleMoves.length)];
// Swap the selected tile with the empty space
[ board[emptyIndex], board[moveIndex] ] = [ board[moveIndex], board[emptyIndex] ];
// Update indices for the next iteration
lastMoveIndex = emptyIndex;
emptyIndex = moveIndex;
}
return board;
};
const Puzzle15 = () => {
const [ tiles, setTiles ] = useState<Array<number>>(Array.from({ length: 16 }, (_, i) => i));
const [ isWon, setIsWon ] = useState(false);
const [ image, setImage ] = useState<HTMLImageElement | null>(null);
const canvasRefs = useRef<Array<(HTMLCanvasElement | null)>>([]);
const initializeGame = useCallback(() => {
const newTiles = shuffleBoard(tiles);
setTiles(newTiles);
setIsWon(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
initializeGame();
}, [ initializeGame ]);
useEffect(() => {
const img = new Image();
img.src = '/static/4x4-easter-game-cut.png';
img.onload = () => setImage(img);
}, []);
useEffect(() => {
if (image) {
tiles.forEach((tile, index) => {
const canvas = canvasRefs.current[index];
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
const tileSize = image.width / 4;
const srcX = (tile % 4) * tileSize;
const srcY = Math.floor(tile / 4) * tileSize;
ctx.drawImage(
image,
srcX,
srcY,
tileSize,
tileSize,
0,
0,
canvas.width,
canvas.height,
);
}
}
});
}
}, [ tiles, image ]);
const isAdjacent = React.useCallback((index1: number, index2: number) => {
const row1 = Math.floor(index1 / 4);
const col1 = index1 % 4;
const row2 = Math.floor(index2 / 4);
const col2 = index2 % 4;
return Math.abs(row1 - row2) + Math.abs(col1 - col2) === 1;
}, []);
const checkWinCondition = useCallback((currentTiles: Array<number>) => {
setIsWon(currentTiles.every((tile, index) => tile === index));
}, []);
const moveTile = useCallback((index: number) => {
const emptyIndex = tiles.indexOf(15);
if (isAdjacent(index, emptyIndex)) {
const newTiles = [ ...tiles ];
[ newTiles[index], newTiles[emptyIndex] ] = [ newTiles[emptyIndex], newTiles[index] ];
setTiles(newTiles);
checkWinCondition(newTiles);
}
}, [ tiles, isAdjacent, checkWinCondition ]);
const handleTileClick = useCallback((index: number) => () => {
if (!isWon) {
moveTile(index);
}
}, [ isWon, moveTile ]);
return (
<Flex flexDirection="column" alignItems="center" justifyContent="center" my={ 10 }>
{ isWon && <Confetti/> }
<Grid templateColumns="repeat(4, 1fr)" w="400px" h="400px">
{ tiles.map((tile, index) => (
<div
key={ tile }
onClick={ handleTileClick(index) }
style={{
transition: 'all 0.3s ease',
transform: 'translate3d(0, 0, 0)',
}}
>
<Box position="relative">
<canvas
ref={ (el) => {
canvasRefs.current[index] = el;
} }
width="512"
height="512"
style={{
display: tile !== 15 ? 'block' : 'none',
border: '1px solid gray',
width: '100px',
height: '100px',
imageRendering: 'pixelated',
}}
/>
<Box
position="absolute"
top="0"
left="0"
right="0"
bottom="0"
display="flex"
alignItems="center"
justifyContent="center"
fontSize="3xl"
fontWeight="bold"
color="white"
opacity="0"
_hover={{ opacity: 0.3 }}
transition="opacity 0.2s"
>
{ tile !== 15 && tile + 1 }
</Box>
</Box>
</div>
)) }
</Grid>
{ !isWon && (
<>
<Text mt={ 10 }>Put the pieces together and win a prize</Text>
<Text mb={ 1 }>Click on a square to move it</Text>
</>
) }
{ isWon && easterEggPuzzleBadgeFeature.isEnabled && (
<Flex flexDirection="column" alignItems="center" justifyContent="center" gap={ 4 } mt={ 10 }>
<Text fontSize="2xl" fontWeight="bold">You unlocked a hidden badge!</Text>
<Text fontSize="lg" textAlign="center">Congratulations! You're eligible to claim an epic hidden badge!</Text>
<Link
href={ easterEggPuzzleBadgeFeature.badgeClaimLink }
target="_blank"
asChild
>
<Button>Claim</Button>
</Link>
</Flex>
) }
</Flex>
);
};
export default Puzzle15;
......@@ -33,7 +33,7 @@ test('status code 500', async({ render }) => {
});
test('tx not found', async({ render }) => {
const error = { message: 'Not found', cause: { status: 404, resource: 'tx' } } as Error;
const error = { message: 'Not found', cause: { status: 404, resource: 'general:tx' } } as Error;
const component = await render(<AppError error={ error }/>);
await expect(component).toHaveScreenshot();
});
......
......@@ -57,7 +57,7 @@ const AppError = ({ error, className }: Props) => {
undefined;
const statusCode = getErrorCauseStatusCode(error) || getErrorObjStatusCode(error);
const isInvalidTxHash = cause && 'resource' in cause && cause.resource === 'tx' && statusCode === 404;
const isInvalidTxHash = cause && 'resource' in cause && cause.resource === 'general:tx' && statusCode === 404;
const isBlockConsensus = messageInPayload?.includes('Block lost consensus');
if (isInvalidTxHash) {
......
......@@ -6,10 +6,10 @@ import { route } from 'nextjs-routes';
import { Button } from 'toolkit/chakra/button';
import { Link } from 'toolkit/chakra/link';
import Puzzle15 from 'ui/games/Puzzle15';
import IconSvg from 'ui/shared/IconSvg';
import AppErrorTitle from '../AppErrorTitle';
const AppErrorTxNotFound = () => {
const snippet = {
borderColor: { _light: 'blackAlpha.300', _dark: 'whiteAlpha.300' },
......@@ -17,6 +17,12 @@ const AppErrorTxNotFound = () => {
iconColor: { _light: 'white', _dark: 'black' },
};
const [ isPuzzleOpen, setIsPuzzleOpen ] = React.useState(false);
const showPuzzle = React.useCallback(() => {
setIsPuzzleOpen(true);
}, []);
return (
<>
<Box p={ 4 } borderColor={ snippet.borderColor } borderRadius="md" w="230px" borderWidth="1px">
......@@ -54,7 +60,11 @@ const AppErrorTxNotFound = () => {
<chakra.span fontWeight={ 600 }>sender/exchange/wallet/transaction provider</chakra.span>
<span> for additional information.</span>
</List.Item>
<List.Item>
<span>If you don’t want to look for a txn and just want to have fun, <Link onClick={ showPuzzle }>solve the puzzle</Link>, and be rewarded with a secret prize.</span>
</List.Item>
</List.Root>
{ isPuzzleOpen && <Puzzle15/> }
<Link href={ route({ pathname: '/' }) } asChild>
<Button
mt={ 8 }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment