mirror of
https://github.com/luxfi/explore.git
synced 2026-09-24 00:28:18 +00:00
puzzle-15 (#2471)
* puzzle-15 * puzzle-review * puzzle game * puzzle fixes * separate puzzle feature in readme
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
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+ |
|
||||
|
||||
|
||||
|
||||
## External services configuration
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 437 KiB |
@@ -0,0 +1,118 @@
|
||||
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;
|
||||
@@ -0,0 +1,216 @@
|
||||
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) {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 45 KiB |
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user