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 { 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 (
|
||||
<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">
|
||||
{`${NOT_FOUND_ART}\n`}{t('notFound.console')}
|
||||
</pre>
|
||||
<BreakableBlock />
|
||||
<p className="max-w-md text-sm text-frost">{t('notFound.blockHint')}</p>
|
||||
<PageTitle
|
||||
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.',
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ export const commonRu = {
|
|||
body: 'Возможно, ссылка устарела.',
|
||||
home: 'На главную',
|
||||
console: '> chunk not found. Попробуй ещё раз.',
|
||||
blockAria: 'Сломать блок',
|
||||
blockHint: 'Кликни по блоку — он треснет и рассыплется, как в игре.',
|
||||
},
|
||||
shareCode: {
|
||||
copy: 'Скопировать',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue