feat(frontend): breakable grass block on the 404 page
Implements the last unbuilt easter egg (PLAN Task 11 #5, TODO «Пасхалки» #5): the 404 used to show static ASCII art; now a grass block drops into a dusk world, cracks over three clicks, then bursts into square debris and respawns. Keyboard accessible (a real button), and under prefers-reduced-motion the fall and burst are skipped while the crack stages still work. Replaces the NOT_FOUND_ART pane; i18n adds notFound.blockAria/blockHint (ru+en).
This commit is contained in:
parent
07b3f55bee
commit
904c724c9f
6 changed files with 201 additions and 4 deletions
|
|
@ -1,15 +1,14 @@
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Link } from 'react-router'
|
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'
|
import { PageTitle } from '../../shared/layout/PageTitle'
|
||||||
|
|
||||||
export default function NotFoundPage() {
|
export default function NotFoundPage() {
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
return (
|
return (
|
||||||
<section className="flex flex-1 flex-col items-start gap-6 py-16">
|
<section className="flex flex-1 flex-col items-start gap-6 py-16">
|
||||||
<pre aria-hidden="true" className="w-fit max-w-full overflow-x-auto rounded-panel border border-white/10 bg-black/30 p-4 font-code text-xs leading-tight text-ice">
|
<BreakableBlock />
|
||||||
{`${NOT_FOUND_ART}\n`}{t('notFound.console')}
|
<p className="max-w-md text-sm text-frost">{t('notFound.blockHint')}</p>
|
||||||
</pre>
|
|
||||||
<PageTitle
|
<PageTitle
|
||||||
lead={
|
lead={
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
71
frontend/src/shared/easter/BreakableBlock.tsx
Normal file
71
frontend/src/shared/easter/BreakableBlock.tsx
Normal file
|
|
@ -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<Particle[]>([])
|
||||||
|
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 (
|
||||||
|
<div className="lv-block-world">
|
||||||
|
<motion.span
|
||||||
|
key={cycle}
|
||||||
|
className="lv-block-wrap"
|
||||||
|
initial={reduced ? false : { y: -220, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={reduced ? { duration: 0 } : { type: 'spring', stiffness: 320, damping: 12 }}
|
||||||
|
>
|
||||||
|
<button type="button" className="lv-block" aria-label={t('notFound.blockAria')} onClick={crack}>
|
||||||
|
{cracks > 0 && <span className="lv-block-crack" data-stage={String(Math.min(cracks, 2))} aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
</motion.span>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{particles.map((p) => (
|
||||||
|
<motion.span
|
||||||
|
key={`${cycle}-${p.id}`}
|
||||||
|
className="lv-block-particle"
|
||||||
|
style={{ ['--dx' as string]: p.dx, ['--dy' as string]: p.dy }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
87
frontend/src/shared/easter/block.css
Normal file
87
frontend/src/shared/easter/block.css
Normal file
|
|
@ -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; }
|
||||||
|
}
|
||||||
36
frontend/src/shared/easter/tests/breakable.test.tsx
Normal file
36
frontend/src/shared/easter/tests/breakable.test.tsx
Normal file
|
|
@ -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: <BreakableBlock /> }])
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
})
|
||||||
|
|
@ -20,6 +20,8 @@ export const commonEn = {
|
||||||
body: 'This link may be outdated.',
|
body: 'This link may be outdated.',
|
||||||
home: 'Go home',
|
home: 'Go home',
|
||||||
console: '> chunk not found. Try again.',
|
console: '> chunk not found. Try again.',
|
||||||
|
blockAria: 'Break the block',
|
||||||
|
blockHint: 'Click the block — it cracks and bursts, just like in the game.',
|
||||||
},
|
},
|
||||||
shareCode: {
|
shareCode: {
|
||||||
copy: 'Copy',
|
copy: 'Copy',
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ export const commonRu = {
|
||||||
body: 'Возможно, ссылка устарела.',
|
body: 'Возможно, ссылка устарела.',
|
||||||
home: 'На главную',
|
home: 'На главную',
|
||||||
console: '> chunk not found. Попробуй ещё раз.',
|
console: '> chunk not found. Попробуй ещё раз.',
|
||||||
|
blockAria: 'Сломать блок',
|
||||||
|
blockHint: 'Кликни по блоку — он треснет и рассыплется, как в игре.',
|
||||||
},
|
},
|
||||||
shareCode: {
|
shareCode: {
|
||||||
copy: 'Скопировать',
|
copy: 'Скопировать',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue