diff --git a/frontend/src/pages/public/NotFoundPage.tsx b/frontend/src/pages/public/NotFoundPage.tsx index d27b02b..f4b7d75 100644 --- a/frontend/src/pages/public/NotFoundPage.tsx +++ b/frontend/src/pages/public/NotFoundPage.tsx @@ -1,15 +1,14 @@ import { useTranslation } from 'react-i18next' import { Link } from 'react-router' -import { NOT_FOUND_ART } from '../../shared/easter/art.generated' +import { BreakableBlock } from '../../shared/easter/BreakableBlock' import { PageTitle } from '../../shared/layout/PageTitle' export default function NotFoundPage() { const { t } = useTranslation('common') return (
- + +

{t('notFound.blockHint')}

diff --git a/frontend/src/shared/easter/BreakableBlock.tsx b/frontend/src/shared/easter/BreakableBlock.tsx new file mode 100644 index 0000000..2d82558 --- /dev/null +++ b/frontend/src/shared/easter/BreakableBlock.tsx @@ -0,0 +1,71 @@ +import { AnimatePresence, motion, useReducedMotion } from 'motion/react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import './block.css' + +/** Clicks needed before the block shatters: two crack stages then the burst. */ +const BREAK_AT = 3 + +type Particle = { id: number; dx: string; dy: string } + +/** + * `/` 404 easter egg: a grass block drops into a dusk world, cracks over a few clicks, + * then bursts into square debris and respawns. Under reduced motion it skips the fall and + * the burst — clicking still walks the crack stages and resets. + */ +export function BreakableBlock() { + const { t } = useTranslation('common') + const reduced = useReducedMotion() + const [cracks, setCracks] = useState(0) + const [particles, setParticles] = useState([]) + const [cycle, setCycle] = useState(0) // bump to replay the drop after a burst + + function burst() { + setParticles( + Array.from({ length: 10 }, (_, i) => ({ + id: i, + dx: `${(i - 4.5) * 16}px`, + dy: `${-40 - (i % 3) * 24}px`, + })), + ) + setCracks(0) + setCycle((c) => c + 1) + window.setTimeout(() => setParticles([]), 500) + } + + function crack() { + const next = cracks + 1 + if (next >= BREAK_AT) { + burst() + return + } + setCracks(next) + } + + return ( +
+ + + + + + {particles.map((p) => ( + + ))} + +
+ ) +} diff --git a/frontend/src/shared/easter/block.css b/frontend/src/shared/easter/block.css new file mode 100644 index 0000000..1edc358 --- /dev/null +++ b/frontend/src/shared/easter/block.css @@ -0,0 +1,87 @@ +/* 404 easter egg: a dusk world with a grass block that falls, cracks and bursts. */ +.lv-block-world { + position: relative; + display: grid; + place-items: end center; + width: 100%; + max-width: 420px; + height: 220px; + overflow: hidden; + border: 1px solid rgb(255 255 255 / 0.1); + border-radius: var(--radius-panel, 16px); + background: linear-gradient(180deg, #10162e 0%, #1a2340 55%, #24314f 100%); +} + +.lv-block-wrap { + margin-bottom: 26px; +} + +/* The block itself: grass crown over dirt, pixel-inset edges like a Minecraft tile. */ +.lv-block { + position: relative; + display: block; + width: 84px; + height: 84px; + padding: 0; + border: 0; + cursor: pointer; + image-rendering: pixelated; + background: linear-gradient(#6c4b2a 0, #6c4b2a 100%); + box-shadow: + inset 0 0 0 3px rgb(0 0 0 / 0.22), + inset 0 -10px 0 rgb(0 0 0 / 0.12); +} +.lv-block::before { + content: ''; + position: absolute; + inset: 0 0 auto 0; + height: 30%; + background: linear-gradient(#7ede68 0, #4f9c42 100%); + box-shadow: inset 0 -3px 0 rgb(0 0 0 / 0.18); +} +.lv-block:focus-visible { + outline: 2px solid var(--color-ice, #5cc8e7); + outline-offset: 3px; +} + +/* Crack overlay: dark jagged strokes revealed stage by stage. */ +.lv-block-crack { + position: absolute; + inset: 0; + pointer-events: none; + background-repeat: no-repeat; + background-image: + linear-gradient(65deg, transparent 47%, rgb(0 0 0 / 0.6) 48% 52%, transparent 53%), + linear-gradient(-40deg, transparent 44%, rgb(0 0 0 / 0.6) 45% 49%, transparent 50%), + linear-gradient(15deg, transparent 51%, rgb(0 0 0 / 0.6) 52% 55%, transparent 56%); +} +.lv-block-crack[data-stage='1'] { background-image: linear-gradient(65deg, transparent 47%, rgb(0 0 0 / 0.6) 48% 52%, transparent 53%); } +.lv-block-crack[data-stage='2'] { + background-image: + linear-gradient(65deg, transparent 47%, rgb(0 0 0 / 0.6) 48% 52%, transparent 53%), + linear-gradient(-40deg, transparent 44%, rgb(0 0 0 / 0.6) 45% 49%, transparent 50%); +} + +/* Square debris that flies out and fades when the block bursts. */ +.lv-block-particle { + position: absolute; + left: 50%; + bottom: 26px; + width: 10px; + height: 10px; + background: #4f9c42; + pointer-events: none; + animation: lv-block-burst 0.45s ease-out forwards; +} +.lv-block-particle:nth-child(even) { background: #6c4b2a; } +@keyframes lv-block-burst { + to { + transform: translate(var(--dx), var(--dy)) scale(0.2); + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .lv-block-wrap { margin-bottom: 26px; } + .lv-block-particle { display: none; } +} diff --git a/frontend/src/shared/easter/tests/breakable.test.tsx b/frontend/src/shared/easter/tests/breakable.test.tsx new file mode 100644 index 0000000..53c3b76 --- /dev/null +++ b/frontend/src/shared/easter/tests/breakable.test.tsx @@ -0,0 +1,36 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { expect, test, vi } from 'vitest' +import { BreakableBlock } from '../BreakableBlock' +import { renderApp } from '../../../test/render' + +// motion's useReducedMotion reads matchMedia, which jsdom lacks. +vi.stubGlobal('matchMedia', (query: string) => ({ + matches: !query.includes('reduce'), + media: query, + onchange: null, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + dispatchEvent: () => false, +})) + +test('a block drops in and cracks over three clicks, then bursts and respawns', async () => { + const user = userEvent.setup() + renderApp([{ path: '/', element: }]) + + // The block remounts (new key) on respawn, so re-query it before each click. + const block = () => screen.getByRole('button', { name: 'Сломать блок' }) + expect(block().querySelector('.lv-block-crack')).toBeNull() + + await user.click(block()) + expect(block().querySelector('.lv-block-crack')).toHaveAttribute('data-stage', '1') + await user.click(block()) + expect(block().querySelector('.lv-block-crack')).toHaveAttribute('data-stage', '2') + + // Third click shatters: cracks clear (respawn) and debris spawns. + await user.click(block()) + expect(block().querySelector('.lv-block-crack')).toBeNull() + expect(document.querySelectorAll('.lv-block-particle').length).toBeGreaterThan(0) +}) diff --git a/frontend/src/shared/i18n/common.en.ts b/frontend/src/shared/i18n/common.en.ts index 7c7c83f..832584b 100644 --- a/frontend/src/shared/i18n/common.en.ts +++ b/frontend/src/shared/i18n/common.en.ts @@ -20,6 +20,8 @@ export const commonEn = { body: 'This link may be outdated.', home: 'Go home', console: '> chunk not found. Try again.', + blockAria: 'Break the block', + blockHint: 'Click the block — it cracks and bursts, just like in the game.', }, shareCode: { copy: 'Copy', diff --git a/frontend/src/shared/i18n/common.ru.ts b/frontend/src/shared/i18n/common.ru.ts index 535dbdc..ec2b3fb 100644 --- a/frontend/src/shared/i18n/common.ru.ts +++ b/frontend/src/shared/i18n/common.ru.ts @@ -18,6 +18,8 @@ export const commonRu = { body: 'Возможно, ссылка устарела.', home: 'На главную', console: '> chunk not found. Попробуй ещё раз.', + blockAria: 'Сломать блок', + blockHint: 'Кликни по блоку — он треснет и рассыплется, как в игре.', }, shareCode: { copy: 'Скопировать',