From 7325ece71e73eb3dcb6b1935710adbf8392a1fdb Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Wed, 14 Oct 2026 23:05:40 +0530 Subject: [PATCH] Add Snake 404, a dot-matrix 404 a Nokia-style snake eats, which you can take over and play. Co-Authored-By: Claude Opus 5.5 --- .cursor/skills/23rd/SKILL.md | 8 +- .cursor/skills/23rd/apis.md | 25 + .cursor/skills/23rd/components.md | 28 + .cursor/skills/23rd/recipes.md | 2 +- README.md | 1 + components/demos.tsx | 4 + content/docs/components/meta.json | 1 + content/docs/components/snake-404.mdx | 80 ++ content/docs/getting-started.mdx | 2 +- public/og/docs/components/snake-404/image.png | Bin 0 -> 45301 bytes public/r/registry.json | 13 + public/r/snake-404.json | 15 + registry.json | 1 + registry/snake-404/registry.json | 18 + registry/snake-404/snake-404-demo.tsx | 219 +++++ registry/snake-404/snake-404-vanilla.ts | 881 ++++++++++++++++++ registry/snake-404/snake-404.tsx | 141 +++ skills/23rd/SKILL.md | 8 +- skills/23rd/apis.md | 25 + skills/23rd/components.md | 28 + skills/23rd/recipes.md | 2 +- 21 files changed, 1495 insertions(+), 7 deletions(-) create mode 100644 content/docs/components/snake-404.mdx create mode 100644 public/og/docs/components/snake-404/image.png create mode 100644 public/r/snake-404.json create mode 100644 registry/snake-404/registry.json create mode 100644 registry/snake-404/snake-404-demo.tsx create mode 100644 registry/snake-404/snake-404-vanilla.ts create mode 100644 registry/snake-404/snake-404.tsx diff --git a/.cursor/skills/23rd/SKILL.md b/.cursor/skills/23rd/SKILL.md index d254159..b9e5598 100644 --- a/.cursor/skills/23rd/SKILL.md +++ b/.cursor/skills/23rd/SKILL.md @@ -73,7 +73,7 @@ flowchart TD pick -->|Atmosphere behind a hero| atmo{Mood} pick -->|Footer or page-end| foot{Treatment} pick -->|Reveal an image from a dither| ds[dither-scan] - pick -->|Full page 404| d404[dithered-404] + pick -->|Full page 404| p404{Treatment} pick -->|Scroll makes the page lean| folio[folio] pick -->|Character or wordmark| mark{Which} pick -->|Type text out letter by letter| tw[typewriter] @@ -89,6 +89,8 @@ flowchart TD atmo -->|Isometric figures built from tiles| il[iso-lattice] atmo -->|Falling sheet music| ps[phosphor-score] foot -->|Rubber overscroll aurora| st[stretchy-footer] + p404 -->|Burns under the cursor| d404[dithered-404] + p404 -->|Snake eats the digits, playable| s404[snake-404] mark -->|Eyes that track the pointer| orb[live-orb] mark -->|ASCII wordmark, click to scatter| al[ascii-logo] ``` @@ -101,6 +103,7 @@ Disambiguation that agents get wrong: | Soft gradient behind a headline | `shader-gradient` | `gooey-color-picker` | | Fire, heat under a hero | `shader-anime-fire` | `dithered-404` unless the page is a 404 | | 404 that burns under the cursor | `dithered-404` | `shader-anime-fire` | +| Snake game, Nokia, retro 404 or 500 you can play | `snake-404` | `dithered-404` (fire, not a game) | | Image that un-dithers, scan line, enhance or processing reveal | `dither-scan` | `dithered-404` (a 404 glyph, not your image) | | Overscroll rubber band, aurora at the bottom | `stretchy-footer` | `folio` | | Page leans while scrolling | `folio` | `stretchy-footer` (that one stretches, it does not tilt) | @@ -128,7 +131,7 @@ Backgrounds and shaders fill the parent. They do not create a page. ``` -Same shell for `ShaderAnimeFire`, `ShaderFire`, `ShaderSky`, `AsciiFluid`, `LogoBurst`, `PhosphorScore`, `IsoLattice`, and `Dithered404`. Give the parent a height. Put UI in a later stacking context (`relative z-10`). +Same shell for `ShaderAnimeFire`, `ShaderFire`, `ShaderSky`, `AsciiFluid`, `LogoBurst`, `PhosphorScore`, `IsoLattice`, `Dithered404`, and `Snake404`. Give the parent a height. Put UI in a later stacking context (`relative z-10`). `ShaderMetal` uses the same shell but is transparent: the ribbons and their shadows sit on the parent's background, so set one (`bg-background`). The ribbons cross the middle; put copy top-left. @@ -181,6 +184,7 @@ Categories match `content/docs/components/meta.json`. | `live-orb` | Characters | Lit sphere; eyes follow the pointer | | `ascii-logo` | Characters | ASCII wordmark: hover shove, click scatter / fall / gather | | `dithered-404` | Pages | Bayer 404 burned by a fireball cursor, then reforms | +| `snake-404` | Pages | Dot-matrix 404 eaten by a Nokia-style snake; arrow keys take over | | `folio` | Sections | Page leans on scroll, then springs flat | | `gooey-color-picker` | Components | Swatch opens into hue, alpha, and hex under an SVG goo filter | | `typewriter` | Components | A real typewriter in SVG: keys press, typebars strike, the carriage steps | diff --git a/.cursor/skills/23rd/apis.md b/.cursor/skills/23rd/apis.md index 51b14ab..2e04ec7 100644 --- a/.cursor/skills/23rd/apis.md +++ b/.cursor/skills/23rd/apis.md @@ -382,6 +382,31 @@ type Dithered404Props = { `LIGHT_COLOR` `#18181B`, `DARK_COLOR` `#E4E4E7`. `dither={false}` is soft fire on the same glyph. No `text` prop. Wrapper does not expose `onHideCursor`. +## snake-404 + +```ts +type Snake404Props = { + code?: string // "404", digits 0–9 only, up to 6 + color?: string // theme ink, or LCD ink when lcd + cellSize?: number // 12 CSS px + speed?: number // 14 cells per second + maxLength?: number // 32 + grid?: boolean // true, faint unlit cells + lcd?: boolean // false, green Nokia screen + playable?: boolean // true, arrow keys / WASD / swipe + score?: boolean // true, pellets eaten, top left + smooth?: boolean // true, glide between cells + theme?: "light" | "dark" | "auto" + onEat?: (left: number, total: number) => void + onClear?: () => void + onPlayChange?: (playing: boolean) => void + label?: string // "404, page not found" + className?: string +} +``` + +`LIGHT_COLOR` `#18181B`, `DARK_COLOR` `#E4E4E7`, `LCD_SCREEN` `#C7F0D8`, `LCD_INK` `#43523D`. Crashing into a wall or itself while playing blinks the snake and restarts autopilot. + ## dither-scan ```ts diff --git a/.cursor/skills/23rd/components.md b/.cursor/skills/23rd/components.md index 6d8304c..161e849 100644 --- a/.cursor/skills/23rd/components.md +++ b/.cursor/skills/23rd/components.md @@ -382,6 +382,34 @@ export function NotFound() { Pitfalls: `interactive` hides the system cursor while the fireball is active (`onHideCursor` exists on the vanilla options; the React wrapper owns that state and does not expose the callback as a prop). Ink follows theme: `#18181B` light, `#E4E4E7` dark. The glyph string is the 404 itself; there is no `text` prop. +## snake-404 + +Page. A dot-matrix "404" where every dot is a food pellet. A Nokia-style snake slides in and eats its way through the digits on autopilot; each meal rides down the body as a bulge, and the digits pop back once the last pellet is gone. + +Best fit: the not-found route, full viewport, when the page should be a small game. `code="500"` works for other error pages. + +Not this: a 404 that reacts to the cursor without a game (`dithered-404`). + +Deps: none. Key props: `code` ("404"), `color`, `cellSize` (12), `speed` (14), `maxLength` (32), `grid` (true), `lcd` (false), `playable` (true), `score` (true), `smooth` (true), `theme`. + +```tsx +"use client" +import { Snake404 } from "@/components/ui/snake-404" + +export function NotFound() { + return ( +
+ +
+

This page could not be found.

+
+
+ ) +} +``` + +Pitfalls: overlays must be `pointer-events-none` or clicks never focus the field. With `playable` the root is focusable and swallows arrow keys and WASD only while focused; Esc or blur hands back to autopilot. `playable` also sets `touch-none` so swipes steer instead of scrolling. Only digits 0–9 are drawn. `lcd` paints the canvas green, so `color` defaults to LCD ink there. + ## dither-scan AI component. An image that starts as a 1-bit ordered dither in theme ink and paper. A diagonal scan line sweeps it `passes` times; each pass shrinks the cells and brings back tones and color, and the last leaves the source image. diff --git a/.cursor/skills/23rd/recipes.md b/.cursor/skills/23rd/recipes.md index bd81917..e8fe12e 100644 --- a/.cursor/skills/23rd/recipes.md +++ b/.cursor/skills/23rd/recipes.md @@ -74,7 +74,7 @@ export function Story() { ## 404 -Use `dithered-404` on the not-found route. Do not also mount `shader-anime-fire`. +Use `dithered-404` on the not-found route. Do not also mount `shader-anime-fire`. For a playable page, swap in `snake-404` with the same shell and keep the overlay `pointer-events-none`. ```tsx "use client" diff --git a/README.md b/README.md index a3ffbd1..713bc5c 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ npx skills add radiumcoders/23rd.dev --skill 23rd -g | [ASCII Logo](https://23rd.dev/docs/components/ascii-logo) | `@23rd/ascii-logo` | Interactive ASCII wordmark: hover shove, then click to scatter, drop, and gather | | [Dither Scan](https://23rd.dev/docs/components/dither-scan) | `@23rd/dither-scan` | A 1-bit dithered image a diagonal scan line clears over a set number of passes | | [Dithered 404](https://23rd.dev/docs/components/dithered-404) | `@23rd/dithered-404` | Bayer-pixel 404; a fireball cursor scorches it into embers and smoke, then it reforms | +| [Snake 404](https://23rd.dev/docs/components/snake-404) | `@23rd/snake-404` | Dot-matrix 404 where every dot is food; a Nokia-style snake eats it, and you can take over | | [Folio](https://23rd.dev/docs/components/folio) | `@23rd/folio` | The page leans back in perspective while you scroll, blurs, then springs flat | | [Gooey Color Picker](https://23rd.dev/docs/components/gooey-color-picker) | `@23rd/gooey-color-picker` | Floating swatch → hue wheel, alpha, hex, joined by an SVG gooey filter | | [Iso Lattice](https://23rd.dev/docs/components/iso-lattice) | `@23rd/iso-lattice` | Glowing isometric tiles build solid figures, one over the last, from the floor up | diff --git a/components/demos.tsx b/components/demos.tsx index 32e7c60..588b1f8 100644 --- a/components/demos.tsx +++ b/components/demos.tsx @@ -110,6 +110,10 @@ export const ShaderSkyDemo = lazyDemo(() => import("@/registry/shader-sky/shader-sky-demo").then((m) => m.ShaderSkyDemo) ) +export const Snake404Demo = lazyDemo(() => + import("@/registry/snake-404/snake-404-demo").then((m) => m.Snake404Demo) +) + export const StretchyFooterDemo = lazyDemo(() => import("@/registry/stretchy-footer/stretchy-footer-demo").then( (m) => m.StretchyFooterDemo diff --git a/content/docs/components/meta.json b/content/docs/components/meta.json index d035e50..f3efb37 100644 --- a/content/docs/components/meta.json +++ b/content/docs/components/meta.json @@ -21,6 +21,7 @@ "ascii-logo", "---Pages---", "dithered-404", + "snake-404", "---Sections---", "folio", "---Components---", diff --git a/content/docs/components/snake-404.mdx b/content/docs/components/snake-404.mdx new file mode 100644 index 0000000..ae364e7 --- /dev/null +++ b/content/docs/components/snake-404.mdx @@ -0,0 +1,80 @@ +--- +title: Snake 404 +description: A dot-matrix 404 where every dot is food. A Nokia-style snake eats the digits, then they come back. Click to play. +--- + +import { Snake404Demo } from "@/components/demos" + + + +## Installation + + + +Or from the public GitHub registry: + + + +## Usage + +Pin it inside a relatively positioned section. The snake finds its own way to the nearest pellet. Each meal travels down the body as a bulge, and the snake grows by one when the bulge reaches the tail. + +```tsx +"use client" + +import { Snake404 } from "@/components/ui/snake-404" + +export function NotFound() { + return ( +
+ +
+

+ This page could not be found. +

+
+
+ ) +} +``` + +Click the field, then steer with the arrow keys or WASD. On touch screens, swipe. Hitting a wall or the snake's own body ends the game: the snake blinks and autopilot starts again. Press Esc, or move focus away, to hand control back. Keep overlays `pointer-events-none` so clicks reach the field. + +The green Nokia screen: + +```tsx + +``` + +Other status codes. Only the digits 0–9 are drawn. + +```tsx + +``` + +Step cell by cell like the phone did: + +```tsx + +``` + +## Props + +| Prop | Type | Default | +| -------------- | ----------------------------- | ----------------------------- | +| `code` | `string` | `"404"` | +| `color` | `string` | theme ink, or LCD ink | +| `cellSize` | `number` | `12` | +| `speed` | `number` | `14`, cells per second | +| `maxLength` | `number` | `32` | +| `grid` | `boolean` | `true` | +| `lcd` | `boolean` | `false` | +| `playable` | `boolean` | `true` | +| `score` | `boolean` | `true` | +| `smooth` | `boolean` | `true` | +| `theme` | `"light" \| "dark" \| "auto"` | `"auto"` | +| `onEat` | `(left, total) => void` | none | +| `onClear` | `() => void` | none | +| `onPlayChange` | `(playing) => void` | none | +| `label` | `string` | `"404, page not found"` | +| `className` | `string` | none | diff --git a/content/docs/getting-started.mdx b/content/docs/getting-started.mdx index b298ca4..be11f8c 100644 --- a/content/docs/getting-started.mdx +++ b/content/docs/getting-started.mdx @@ -43,7 +43,7 @@ export function Hero() { | Shaders | [Shader Gradient](/docs/components/shader-gradient), [Shader Anime Fire](/docs/components/shader-anime-fire), [Shader Sky](/docs/components/shader-sky), [Shader Metal](/docs/components/shader-metal) | | Footers | [Stretchy Footer](/docs/components/stretchy-footer) | | Characters | [Live Orb](/docs/components/live-orb), [ASCII Logo](/docs/components/ascii-logo) | -| Pages | [Dithered 404](/docs/components/dithered-404) | +| Pages | [Dithered 404](/docs/components/dithered-404), [Snake 404](/docs/components/snake-404) | | Sections | [Folio](/docs/components/folio) | | Components | [Gooey Color Picker](/docs/components/gooey-color-picker) | | Experiments | [Phosphor Score](/docs/components/phosphor-score), [Typewriter](/docs/components/typewriter) | diff --git a/public/og/docs/components/snake-404/image.png b/public/og/docs/components/snake-404/image.png new file mode 100644 index 0000000000000000000000000000000000000000..0a31233185960fa7405004588ea3559720509238 GIT binary patch literal 45301 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

Ps18$Cx~ zA4oTjX@NK_Wy+!@8A8izoj}!5iqe(|uNR1INkn3MN>aF%;B6haN zsg)<*J=>}uX5{(t9_ou1Pr;E`-|4q)0 zJU7)~!<@tUE0x|CsPus1{AWS>T%>hF@HSNNbYEdkhX#u}%Yz^A(?3*vQNREGW73g3 zK~TZ7eTBFpD>cQvihKX1YyaDO1q^`IKq)@!olDPyqKDRgDtrG-P(WPf-L4UWW5ABTq=(k{pf;GDJ@YSUsC8U3;*Wh6u1V{eT zit+9`4#?T3O<8~0G+@}n&&acPdhfq2L5IADQ1r=HKw#Z|@jDLBH*m|D-Ozlf&=i@H;vD4@>+` zhyOoJ4iO(yF5Y`Xa4A4<0$G{mFw^jO z>;nzQ2f*~vx+UGAJWlr*CCYJIS2+tRkaXNV3qIiFtnM1;~0OIPr564oPlP#4ia(CQh&VrrJ};2Nb7I0F4(ZM-#?3X?dzmN5lEmoex<| z3;q6^LxgP{g{tw>Dg?ufIDjxFmagG&8{T(IZ?_3)we!k>(;^htwU0PfMq{|5ZiM0& z_8^Nx*3MqG#+PtTpaI>y`AoF1zXBoP`}IM>LIgv>M(R)Azd|>scRbZ2SXW2(u2@9NkM6SKL=c&k+l*OF_^u!@=d z0vd3{aazA>5dTroYk=3%ePQgmtf{~_IEH~BtdLvqoo*e;b$LQSSq9Oyv_;tUC`Tb5 z6Ut>)*Sr4?3*BT_CElR;?E@!yuzlq?*LCNO0MHJfL9`EfVCTSpTaN5=Df!4#px|)? zh-e;(KX&=Q4jO}4c^INZMwQ)%2=6S0s-5!T@15wSyO)Z-`|gT>J!AWmP}EA4@{-p4 zeJ>JcJf~cx``+Tu*d9{^DSL}uverQQ%sF0by);!7d4B)-YU1YXKv`^(M3#sX{jw7)R{f-F;rnE3ntLb$f%b zGrAgmoLxt#;he|N{Z?_l#G7?p3@~6@>PRkg>CuySo2g3+qLI4nu^aO>EpNXV%^knr=jRf2DmH9a3u{47P8?j-ZkG5#dgDi-Uv-UEyPEq@wla!wVdw#SJ+-`Qc6x?Us=N!hHziv@kAm?#a2P_T17!NX2%H zt_m+C{}|Ans?0$5=h5}qAD&`5)BV;s@@AL4HVN+wdb70Q3uvbvr&qv-ibt-uTCK)g zBJ!}Hoe;Y^=Ybp&^clVv1a{nIKpTlc7dNp571ga?muSJvmbS*=`veFQwTqXLOD!TB zP@j=L$&wF;+2O$zw3&j9K{2V5QVv<=r=O5kv2lCLf+ zp=*~ux7a3f6uSeX?X*;me#&5X$Nl#l@>hXfkW=5OWl5HGtJZJ%P0=?>5mLo(AMmE= z{k)=@z*_)Tw4T;iqyZ62O0g0dnFqOenoLg)2^|W+1WUIRj}$n6FkL$u@&}7xKrwyK zxS&D(mI2>Bp1S08%}EYy$i%Ll1jp38b&Op1RUfNix*@4cX=H!#%_TF0LI&h5L z>TPv+w2-DSWfPnRQxgtLaNbY7b!y(cp5iLS$8OihWj8pU$Xcusesm0^nl{xA`ugx; zgq&2|UfnVdt2b#g>6on_H;jIU&l2 z0#za;Tpg;3DL$4{0{U05yG%!_^nR=>cLmV&&fe}M)j-6`uH{5np)=`Om;Kd57Do_Z zt00T&FYN%U4pCKv$9lkC4|97FIEh91|EL4Wsm0zSBH#(VrI|2XKMl)~OdEF2O~D+t zPC;(3CoJeZxH9S~i#OP8i9FKXU z#|bYg@4o(O`V7D^Z>Jp%USL2|ZPhP8nlX;4WW^KS{NC6~)yZv(f{&^vh!)kBE{TW$oa7m^ez$yY_XTRN~fZ_1C&PgmDn)i%p6lDi!d|!s*Fw7jzC}T zQUN~pax1T{Yq4F1yd#-4XSb*1u`T-yB&sK35*MyyG=d0@1WjZ<>z!Luyr)8A99{!Z4?_DbpZ^Zsq_(1Pjah6UqEE+1#T8HN`Gk2LLD<=* za!BgP>-YjMbY|MlsQ&X!d=vmC0nd9s@SoQGXFQy9R3a>wp*TRfQ= znrDCkC-;|R-`g1vFJo(of{5vi1X+Vm8QO$pm-Iu|2^*tSsrU>AG%6)qgJd64i|Lgl z`+@g>4w_FawL1)1Z7;>E7UhLa@Ci^}RiLwpb!@;7LQVPAF};=UgptwZOd>`UCa59n zvGW-6rreM#r+|7_9)l=-z z&-)db1H9&5-tQ@UFGxuZ9M#bQF9Qs4A8Tkvk$f!NDsD|JgV~p#%feSh^Y1uko?vU6}3kpQCFF)0L(AGD-=Pn>6d-F zk`z20y!(np)Sz+lG3W}nl4f(P*XNyLB$0ImG)PboCx$=$f`^muLC&Lq!RqK@7 zO8RCPbU2-NKq+ZU_PM?s(ucqbX@qM@2$;d8Ex9upqsNONqd4X(Zb@`LblvGH87=ds+r=W^cSDQMb~KiMt`wpa>kl znH?!o_SY@cmcv9+j|M^`%Ed6~&*myZyXlLX`m06d;Wp&dCTB^>IU3dJ#mXVa`3P@e zSr8$Sk8NLGW$Db=wDyj$^+erb$Zh=~Q>t5(x2O~EGubw21nGH9ETO)*m8-!lHgdzg zJz1fP2+FxA$L^EBhU&9&xZ60q{J9=)VVAT0v<%&AZf7}l(eBfW$~~sUG-S|9b*m&c zz(TD|!ZxCA9$>D74dn=p@HSYEU(t6zlw}fF+NobI?LBqb=DtIk{L#DqC}~L`J7YFR z!5oamvik$d9#cR>BsTqc%}K=`PTc;5Td@wdBQrRNv}j<6*q2}S!%n1gC)3>DUW-HGc!ACzCkc&Qi=776 z@P!;EF=#h_miRP8in5Cx?ZVr~UC=R=QQ3UlU#1-O1{9p8eoqrHDpVP?3P6S;4bzhg ziBh$Uuic*3RC}gM1Yhmu^zt~Pu}|&K$qY|Nrx};Y6E`#cyu!W~1yKr~OnvDV*hNkd z6y>odm40v_)J#p1ZU$$!OjY&Uu!G4hF(jCUI_tn~@&!ULSFAEyMR~2;QdN{_!vGr8pwo6< z8tFTqDOTQI8&697UNlmuKNk%s9d^XC6>;Jcq#bE=Ojmf2>`&ORY&*nT0*u|FvKyAR z@ydYL@Tvjxr5vd{FUg}`p}NPaF2fSIYM4qX$Jnd^ z!uzydaCbRdl_IybeV+>Kv1Nfd?o&~JvIkocoTb~;)O=!p$}ZFY@Jy_7O|Oj2&mQB* zQh2oVkS*ftTbE#2P*P7B7?{FANm>KJUr+j);}o2&UAe5$;QU7rw6fnRrF8xYe%>35 z=J^bxBo2hv#RErIA0$yGuT$#z9#g<+JXWd!U-YzqrDK{cP2^Sg3g#+I%M1j~mRPNS zHesuiV>EWG``d=njkj;)?)`O@?!|Q2!wp8-9Oggt+V3lt38RJ_vFwj&$meFn0Z}FX z3%fCFAWD_y1dVrlqL)_2$B3hX(SbU3{-2F^lc0}Cg0OI&4dCIA!u7UcX}sGj)rslY zl#18#9+qkNfU%Gx2$dZal{B~bN>_9z-y_+(ytJJyX%#Ee z#cxRe$jE^=C6-=V(oNZJ(Y<|kcFl8Dgn2>VuZ%kY2Q@;HHcN1&RH$kSW!NbtJxo}vBi*lEu^mN_#npfaP}yr!HR4^M;37%x zZq%0#2_C7u@*}!y2!@r?^{&_N%;P^-*xJ^DVaCj&Dli@QQm#RfnCvM9$y%sg)~B=f zjni?aZ$QCTHV1w_na}7{?P?DUDF-u}JB56vV#PSp)M@6pJIsD>u|r3yK;ST#s~R$V z4iMBy8bWEV+^ECW%h|Dtd=(6D%su(3*qPHMz&+0@05sEp927)ueya>t243ekMY%Q8 zuP!w3N;`z7sUd^BmevxMEU6TZDkRMQkJ@@sLa>wx3TL}LqtKM#P&&n%*Rlng3)$rv|kf(uY+hfydpkU8u4}IO(cLC!SwN6jgkA zextWm7vn0vH4J!PKADf!bllqcIO??#x(ZaW(roxAUS}Wr6ytYV+4j@K^6+IN`a-bh z#P;g9NiSzh2A-vvE6u;NJ6pqpn=D!A2=a0ltE199oL&i(G2AIngxB0 zu0DjG%9II~cFXG+Th&sk5}0+EUY^JqprkX*eLP~dLZ9G7u8oDp(j`Jl%ZK&3X?~lr z2=^16g**jGm-G5Lawm7G=pigO7o9XU%UOE zs#JC<5P$Ze-93%^PIY_2%JjKxMM^-bd@BgtgF43HQ?}1WLJZ=s+DDw2@eZZ{U6tZD z91Bq$=!IES9Y8EmO9SSQF*_B!*&)8>G()I8TA$af*;;q^px(qKByHr7!G;Lj!ak^y;U? z@J}8Dh*IC?=K8WU#6@;VOGwowRaeyHm4kTc* z+y~2@;Vg1TtO81qJ6lx$RKXh2^h6EC3h4$z3%U*r04_gt6!a=$*K0ZTFP`*NQKb){ zUfuK?>v{8BVum{8q4s^QAU@-U2Ix&RUhcb7d9I<`zTS^<88B!cWdEJeK7QHk8vV~U zxTl5gqjzEeCbs-(y9>Y5jW@nVx%zGUOqy~O%7WJ33(`Q3qlcd7T1Ixu4piy4!8Y zd3GO2!%3TQ7VZOa46Qg%SHHw4*X}IsF5;PF8aO@4HTr2lv6+xgyZ`y@Hhr>B!t5rL z4mIc&>i6V;Om|bd9=SnKCd8IcNaXHm|A$thy_HqB&uI>)H9)Idiyu?7Oe=<|C_ij^{ zM1QeL-4TxSS$+?*ZU>XW;!3O&@^JO^D%pE7x-zbT*niYbxG=As&M0 zPly3tPOn{O{!%2PVwE)YrS&*9$%Z+IQO#paVig_%<9y=mtTGR(>k^SR<$ey5S8>sZj^G|AwhBRQt)4aLBpZa)XYM45rl3s7Qv%Q>8Fs1BOfw*e*AlMtEn zPU!_l1iSyI%=eD~1ElL%%~ThViMI`>PnmH(Ay0S#X3Bx}P5_HRZ;M%hpog=_Ba&79 zN1v8;1g(~}fG6fG@cE;`Pzj9VXSU*|QRY5XJx-;dg(B){vufINTbJdyuk{smhg%jk zOk%M6*-G|uyr;HF5ae~?$s$>jzX)A*!8879W8CVEGBieSgTav&dtJ7NmVuPM)Z!_y z$BS6-MzPv85an+l>>fu6%USiyF{P{lZnvs^G()>}0qDba^Ka9(S8`i+%_e>b2o>`^Oabt>#Xg@csRBCV{wUGZDt>Mh3{C#&+};im=5VKV@<%dKK}XV zpHKd7t!CI0kU$6SzkS2>3toOZr2ikY@cuTS|NqPRARL~m~U8xNPZHaQSY?xaO5b)<{{u#Gw_Si2(x$(_Dfm0ai-F_3omFR6Kx%^r4 zfr6rXM2D1ozxAWUC_K%HvMn}DCfo4pELHnI*Ys;kT;*v9uMJ}ie;t1ALCb7ka(5aT)Cmvw5DZpA&g1ImjHs$1FvRq|v)g?@qWW-%x#t76Nb z;kn8wtotdj7N=Q2b)?G`9sXx%wWV{Cm@aQ{TSXQRm`At66Lh!}XR;7^?ju2x@jxrBxsAFXp)MeY~=F1?WR_jZJf)T4_7p@49y6w3_6*uS~!svQy}tIC60s zb-j$4;sDXjKWH5|@0mu1-+wu2*8pb9K=X9kZ}^6$z@+?;t27qS_0z4LPGTEF`bu** zsbrYa+GmscZnDbDxyO$I{2*K3aRwVzbLhD9)m%$@6Hi;iTR%ln)|t&6Q&;~P$e84{ zlK?bDs?w&1n}Jh9zCpOb1!W0TuzO}7ifP&X1lHoj1I784SHN@crq`~!k6RUp$+2EX zc`v&N=^2~ZzlwAXw^zGk{+d~g!w)xkTt}?rDqaoTLzO7gLhr7_cbftbeyc;NhyLP*g?m##*NcSjs@c;$&T6Y5f-3G>%9mn?Mfu)EW0@fH#SG#5zAY$8P=>Z5> zcSNApaSK|}d)h5R%&6lSAcJkpqX6aFyI%?2Kc{d(5zJ+8ftF?*=_5+`M{kKz$LH~gxpkw%hYyBG(+pmEto*X zP@(E`8p(V0d4%f$dmzkp73D7&&OKk{L_|eAiA6+Omf;+M%+NP)&ND(Z-y)X~-s+%x z5RutRhph7;5NaDGXpc%&_{O%kfRY@qwVB#zi>)fyV7Is5>E#@_nn~L+qw6dUTR+yj z1MWv!iaRwws6dKAgK71uTw4H}%QgYIO`C0SfhW?0edrey076MVrs4^ps7NeBj*<_w zJ7Y5C-k--*in4bsU=!UJVD#%&mWJs!B$3463WI>&4@>8E#{+kfWqgb)0JL?gWcm7U z5yP#}-gTGu7mEt!^I)<%KAHb<6qJ4g2lWk&bdRvTTIC;DU;JdXpAJQ}iS>Z@3di@8 zd{%?z%0lk6gG*4>CwvfFC}#G+p2Fq;&|zN&+PtrY+!PpJR$dPWZV2ZkH@#HQ;)Tku zCpG1mkHrYzk<;{J;ig~x1v&qH0y?cMfk7v;#Z|@mA)((EwVJpr3R7Sa@G@9B>ZVr0 zKnAScOUm$rMj`3L5)cOTroMe1(vZ!_x$V3wDK+?jY>#j$v9{>ZzNYSIGAeMSd(6bdj)wc0I~~?&yl; zg>970-O?!42<;u@0kYiC1Odn5D640OZYEVn?1^R9L5f)+3TcHaggB_T8S=v+&6QgD zGU1(T64Ww^!&Y6;lLf25t82dKopN>+eK}a*K=kN#Jq7L2R}nESJE>k|KTlCkm;?G+ z`*`U22E(*G`v~66h`3=7P*VyJr?YdvRRHie~7g zRt==pQ)!x3PI)_{Aiu}V_0ZJnh_!RQk>6a^+&1i0BymyT|C64`C~kLe(Ed7t?axJq z^(_GrNjLJ9VqJ%xya0aYsHSW&!U>rbT}TQnrBKZz-Z#%w4^|BxNt64-Dr(3sR*wy7 zJeOO(56UYrbf8~RH&k-Qs2-3A>R`R>8#NCCdR&~sxEg{Q#kyb3T8?zMGXG{HXd}5K z@a^h481jWnT??J7xrv2xWOM>qbK8H=@&99&T@K4j{Ei$lnCy7X*Ak6ua}u#8DVUYARMMieIQcW;_d zQSJ|~(5P332y&4tTni7(-S#qc5UBLl9sCvmoGH@-(FIqkx|9&NXB*TzSbZuK&h2hFUbfx1p7y~8qb5eaNpZdh0? z<)*w-O^f3`ejYZFkeX(hqx#(KQAy7BNpA&%cdAXyU7azNdb^d(T5;;|G9N7?sQBy` z1V_l{G5=C(4e-J_N|=c7Bf^^!G-#*XJV>!&=0k7xznk3w&F78)h_OzJQ=s9-IBI>= ztH~kbDvI=Bq$vbpf!@Z^7||ZfF@1J2Jkz~{+baoRls|KNqenY}VqY@n4d9I%NAY0b z=8NO&4$CL=A(1Ixz_lg3VbQ5Zq9f&k+M&f2OeoNcFLAWu-UIXNFhVb(EPcm9^aw5Y zB4lx&0y%ce-I=!8@=K*S(DKX~jiZAq1mHL3dd5;iv}X>Ks}jE^Q?U+R-v|n|zCna`rg1cfpxtHsrjLTQ^oBxn@nRVS36-)N}@B+>aS@MsN08wSSH(emE zNf2H?cyH#bCgIr-!A2l?{~-EGw=D%7xnDvvl0RWsyIub>rD~;?Gj08gBO(=s_bAB5 zUMR{PP%qiK1XDD`lYxn7uxFZM`ZaWkD=Sh8n{uM~2XYcMQJsjd&qhl>>5 zef&yxplnW7`B>#6WxfF}Mf@j8r8C#@n&h7Sm%J{eM;i`m^c|VDFf0>IBXzivudsYm ze2v?_(-ObiGkD+aNF#d~{SfH?y6OPs^@0lOb^z6!hg{<5tRu4`0 zBD5QjlKLiVm)muYbB-P!qMZ_lh&UoR>f$Utfd;5TxLOc7wlp)qGbhYU<565q?Ugt6 z%`2XY9TaqgN{EKi>gr7mcI@L{IBirgd#23F-aqWRg%)6Bmkdg%rz@WNN85lotRaWF*dMTZk?tL59}kt;?+g?-mI78d>d)L-g%&-szI70G@Gl_4y+twh7) zt@r!T1CSZ#`ilAIIK80RcUOx}^3CMh=?G1rin_i%0y&yM8J#r@Bp3DIp067DF&cLT zRY2ig7xZ1R=xvLrF4ey69E@4p%H&nVM&(u(u7Kw6mNsKjzORO)>l+R(rs+Pi%eyvn z{PoJ-HPE;&Fi~@>=vC}J66ql69njStSeQtYDa&BcZDvHIy1aF3(GUn}4wN%7jB8z4 zMNg8;oD<9mfJNvvLHr@40^S*Cb9sdL#xQ9sSm1d`^HcNOQW!Ee}X{5NjLgLRYD-Pj<}-w>9VeXl1o}9HQ#>5w|#yPmZ=8YZR(9mpZmib zbim$H_rS8~$l*0nTJC5`Rr!s;T-y zZ|}X_okHd}i-q^@u;l7@N}SCzZ~B)PV5pPT5A_~bWxRU3j^N#9afjuj3uf1uEgu1{ zegeEHb-PA?Wp*J}1QJ=_+^=<_uc3ZUNAO*rG>h>?MEa-GM--nHe{J8_|LM%!%fSoZ zYW7de-OpUHHw4#}2&^^HBU$>fr=8u)}&>3$`a;W$AM6@xE)za)Gu< zA`WdzkvPGY}A7ZQL|CJC@5L$oIap8@NVw_MYLX&DJDlomKH#qw|OT>)nPB2gdA= z8cT1fLZu68(6L@fYmp%~qK!~Z6Q!&0JBT%dAGosVrIuWw*5h9ce_1hi?ro*&$$$^)*K)ZO<7PY`y)$?Yt4v=o*^By&QlPkW{k!vTlq|3rr5!D z%wwT1U#czDyu&i)Amsv~B7$3^)gQ5= zGY#F|QnZ|Fgopk6Wypu$IV3^Z-lm_1eSpj|{K?(rzZf!a^&N$uv90$*WU+zAuqAa|<@D_8GjPUs&#nrL)tORqL1Ya-=p94->~HJwg`$56FNQMQcW5L_Tjsn0d9h+{R_%cU9&t(+cQ$6YPWuWGY6Q$`BX z8oHwrB(B@1ofSA*t8;qRE>D2bxF$-lbf(aLNPdwN_E*`eS-aGYGNYZAw`w zU^5pJ`%++m=Gk-IY|9iSVWy6jt@9|*zSyK07(wGlFPJwglWc3fg0r`tMEG_Eos#Q_ zg8r4Tzq=`Cvj6StKjPQqO06K+18!-G^vp(Q|>^Nj|6FogqW*ccd|HUZ4b49 zS?vq8(sD{MeUShs+56V=@HG+&1ChqTuVg=7#vC`x#5~($I@tW@Czm3*p1w)`z|ARwFDBTHd&-9Nr3}1=d)_OLM_X z=I>(59@#+uI^=(V=1s5^B~N_PKQ&Wb6YBD&b=vEoeTD|jFjs>KRV$#2*7l(I&0050 zBhZGlTZ9FtOdAwJ2Ws^uTzRsf*&GPSm`iL3XUV ztN?20G-Ql5T-Qz@d9cOCRf0n{B&vk!ekVmDi8T}!Dqef)}-45GSnBAw_A7ch(pRjITy=hz#IA-L^x%KJa(7r2RvxzxVmL9Qm$t_(FTo* znZ-h7kr=~2a=K2;1yp1jS!Z^j1oT|*H?62!frl8i zovDV!wpH0T>PJUQRU~?UU9B$DI5gSU=^bO%(PnP^?n)Jc#BgDC&Y^%wtY$)Hb5DDH zNM)?WMw~Q%iw#RI>CU`MrO< z&phwUJEJo)41|1hm2+SBxlm+YI*Y8Pt6^k!l+YAK|nON?mA$89%ni;#-$w^i(IA|~-Z zh}F;B1MZBvj~1`*PLjF_gr3%S(&q9$RGMVt5+of4?9N&4S=R&nyqgQqk`E4{}-oF5Ec&$!y(@?Q( zb8q)?qTrd=$?)JCkU_d|P5R~}`=(d9 zzpnPXJZ(RHWYbV%tj?ycieN%y^*~g@&a2D(Z`r*RM}pf;GUZgor>4m*A1fF^kl%Nj zIgv=VA+|@BeYl11HgF zC*H-+j(P15AnUwlG%j2ys8%p3;k79`&vwo%zNp=E+VR?)UliJ9S-S9)0PdT;`@e+9`%KPk#CC?z>PS;t4JcaffPB zZ&T(TV1=+ht)_6qS!1t*+-pdE+|hg&kB}g12gDth$$(l1EnG|W`tSDgxq(n~rKQW` zWhk727DKjyasg+|O}U?6?P#K-h^OVmfyv_fn!^<0Wbw2RkewX>vpsb77v>cToWelZ zc3O-*?`ZmJMW2*Y2(vWn;Gn@xpc{nY&&$r;^WxzLkHYxusyG_ZV9;@Xy5so%q_3Ow z@&_u2)5hl+?m?;v!5?2G`0M_(Ls#ukj>I9f#G$zSoz)}$4!{IihMc?wpMnsd9wln?ygJRJ*Tk4F)g9rtZv24R$ z1hCOI2ZR<%)(9(VRz(ybeC|kg#5iqEYd^EMBMO$Sq{#JEV)g~J+&B2uY-MK1W_G@0 z5-)!+yZ`D-n`FyDsm-6qh+=K9Gu4@Vrp0H0ou+UfJw$<%@B*aDgD<$=wVASyAnN*djp%|>DN;6swTlVygI zcU=-97P%jy8rUHz{R@dU>cn*te}sKVL6E-3|H>CF#isJ3aA$ zePVl`ntfo;Zny?# z#fY8;R|3NCw$n1_5MP~s_OIUKFnzHzXhX-Ui$A*`&F=S4EtP9i9zrG8UN)*}EryT3EJxN1x8KvMq<}F87sa z5bNJ(KJF$xkvckF_uKinZ`p0i6TOG6rfWd=gMA*1oNKXRJC}{3PjS)2ohQkPhc5NZ zh>pJPmT4w>W=UpdxQ#eolf{?Ilzyy)pL;M8Xe#m{M0o~u-gPM=xx#G5cH&#pak0wM zEYIL14EJF8NJ-= zoN3~5f=R5p$v~6KMG9j3+fN=86+1YaS0Y4oWm7Mwo1Qp2xRV8mqnC<3hK;umk4b?@^nxuB}C-qC7OR-dk{}&PuUj!^0_A_%vBvxvJakBnz$Kp zdoeT6{T2Ep!Bac_V!_@cOt-Q}u9?n5Oa*BW*X?X}1)YiA zS=$}o!%^3rt~r})F3zg_cCYTqCgUU}?Y?vCEwZ?bjhcZrnf*yTZPyXDy!`_S-6&C& zLmfIHfL*`Bl_wN_yQuE<8GPb%xTU7s<561#PQp9#^HlMkG;7YT*`ZksS?x8WSK!TU zdxD-f-yHw#@~P$(xBalGk*{yfcr0ywgZ&qC>boory*J-x$0iUrwV4%`Om39kc>$#J zH~V$XzUi|pxL)<>kEl`|xZt&)#L)~i5Xs+4MpZWe#QMPR6h+Qp1hMmjSW)-q^3h;v zljx|+w}VHBZS8@>xipwDUB4`oGki|KP?9Qu{HxH#%749AofA-Ajtm@11@*r?^#f2D+L4ggJ}>@r_F!A&rSA2e+%!2te|GWjxx|8dv?YlOjq4 zo%A$~&A5VjA=f$KL=fMpVp6_tjDg>t5HaI7$c>$SNPRbK;OQ$K`87{RRqBXF(|A23 zyZ8QJ@1={pG)t+lz@MW`xYA+hN05Q}jjQwADV1L}Gy<&d7a0rXBOB|R?hyR6dxsNM zkD9|Bs8ep?jJ-R~G(Cv4F;qUR@WdjRV{AC#M6q)oT1Q>5hwW6c;4G=|*h?x>5x&>B zr#51j0E~E;pGnpGSf_+4sbh#Imu*RFif5jWZ$UIf&0GqDJ?d9N-Y~U8JlY!uGN~leWV#^Tnkyu zVpELYjZwIym~*-f(pMcaC7y|Iur8p7#Gcv=IrMB@hP5G67)i_S>&J1M3n}61YgDH@ z4N;0)JnMI!rR-Z8Wj}wMq>_WzHK8JhJU(`O3LHF9IF=; z;>@VcmV776`Sx9}*~M8&_r?9gtBUkaMLUUZ|YewYb92Z&6&QhndHGuiU8h7N51=S?YU8A)5e<&{5aEgk)k%L@GsWnt%H3*V8Dbnhbn zrN@wevCqPQN`S82wFr6;_+c}&k5fNNyW4zbm9zEfs(t-@*+rv{5#E|aXJc-BATRQ( z)69mkc=QzAYEi?mk8=uS$X=pUUyH@zHHsMe>B?o3-#Cux!hQYrwM~@#o-1&)^|l__ zx_A&WI(R@|DJoI^&cR5(x!(JqdpT^ee49%BL>*RU+CmXq!&evmWV|>D^NN9EM?fTg zF<=)5&xC(XK!zaoT<8`DeBW!s>oq)vr{{}m<}0hs?L1~nOnnbDh0}!*^{(&?m!au- zZR24zgKP_e%+KW`u3+fmnFhn1tl$p?Rx7dgQz^Tw&3)kc>W2y*^Fex~M1VWk z^IfoL_={SZ*!a4m*(bNnUOn~r_2BcT0bZxR;*&}MtcjU-&*J?j(|+7tnH##ukL%}O zsgR3-J$H@riDPR?+Z8|4mMe3*O11r2@*5ImlNsl@USG$e^1XGY6aT_9rvw%3hW3Z} zCmVtFPFlm>5vnVZ@0gJ@GYPL-R~RA}c~0tqT{syNmQ*?(JBzA7(2rQTSB3IBx~vFG*)|c&Cu4LF-4t}>xQH2cJ2$?K?TE#0 zpkNCV(H7-hvhqw3Pb?@8F3}q>TGlh?2S-`pD&>#|+M1fO?e|*22d;CkM?&;2+p=%# zx#(YpVpNAKM&D3ky3rDHz8Nxo+)KeW!_AYqV%5tKiJc9Ack6>fM;R%l?Vs(X{JWCK z6psQT3S88|S#2m6C4N4m$Fk3Kh8`B57fa@Mj-j9y<<|8|l6w+gUDasFUCrsVKzGZU zc*rbrk6eKWS$>JMB3GdE{R3x|?FSG9hun^6*ie&+p{qG&3ax84QMn{S(us_2CU;hb zB76@-$R6>NdJFr771@Z7oy(YyaW7AdbQ7N&@h)wz{@CgBnv$kiY4u#x~?ycTFtBmS{wecoO+0NHk z>(51rX)mmyOY9`Ex(N1qNW?15GcWgHQnVh<^H+lbd(0V=S~Q!RVt7J4QbD{qHe6+e ziljOzh?04na+%m&spkp*_Fe7LRuUoy?x-U%G?|~*yPn)L!-C~P41xNVzsLHQ{QigB z9ChSIv|wU?H2!mma@zi!uY1dlFk>pSR)Vv3@SSUTCtk^igR_ zL>k(;zVwkvmv%s1j;Rs{iL&SzLE&%%eEiml_^!g<1#PKwkWGzeBTS~~Q)j4WhKvHy z=r68{2*Q3{=BCCl7si`p8y`Pa?B7=Zcs?u%9XnQanQ>R*LT#lQRM zzh4w}GHPp7)ZhQ$k6+!4eHbN1JP>gAtDYryVP`cYoEBz zIl)-}>vK3fgE*XXIQHK^1)e>40`*MDb6e&!V21yE#D9kT5matoDpbh#ixaf-*wxLrs2swK zm}-rbv7;l`qFGGdES-lA1lqdLlFU3nVkuv$_S078N^hP4%b`NTYN**rs7-r1?CzhF z=kP2u1em>!<1q=_+XO418{DA|U=dhhtJD2?`m^qbXkAsYufgK~>LM zc$i5KzKR^LTYqTkDVe!yuw%%|!iA2`rmx2M^GDZkL;Kze)}J|>`&J%8FePDHjBk{% z=lQ&O7E>VnggeNzxBZf!jn-+N%zE8Qam&zd;|>NL9sx%sF0oF(6gZ2&cCB)3lyKjE z*(RVFsfCKk}`GH(M6#!7u9^UvS=w{2WUr(<;6QVdD1~hAE-KfkC zJt1`tU;E1aqV=bBfSv?rmr}LzGaqQPM%J+f`;N-r=;UYEDGJzlM#HX8RUHkxkvtm- zNL3D0@&{aRRLu8ggB;b!OcIYlGLsM+ASqUIi{mj~i?Hx$4*mU}V7Gs?+XN&nFFr(11(L!> zTK2sxUIBQbR$nF_VudRo^Z%R`Tj*7am2t1SHt1&4Wf!j>?35o%!=F~Tj`3*cLez-+S}z5`hfK)DjkZpyQ;VYl#Uf^;?72kgzIUOy zS>Qg1AN;Nwgc53SDiS~@h7GvMUaoqN8^qNbiWXx;uto&X^RCrkI`*EilmFT%$jrWa()e{B?5vt5ReQop(`feQXg$Au>IZ?a99k{7yv~Zl^DQ{NRGD^a|;o`KW zr^60I1xV_{&FNp3Gl8ZgEU3>N2(P~|)c8|&RuJm&Nqk| ztk5$kujJ(lBjm5~=jM6dV^ynN@e#}8tZAmvS8%a5xjw}F#*zwuQH4+ycPq~0J6AH$ zd{)Ra_SnQ173#UF-5pySD(bnj31ru9`KT`BH*oQ{s!q(@coiW9KNStv9=Ht7h~O>Z zNTbWSS_LZM7FADLp9(yd0VG9S0nAWO_<9>40#Zp`#ELyN@~#EAjwnuX`p{d1w{FDN zle3S2F0GbJQ{|9fDb!Q45ofXv`$(Lt0Q7crqF}!5^_*3G_Scup=F-TjB6BKC*$8@k z2~Zka0_~g+=?uHP*Q)BVnO+H>o5}R>od;De4nV%Z^-6s3M7%Ton-x|yKX&7=F(JF# z5a7dMvR~*nW6RAYO(Pd@o$n(~I;QZ9Yb_+LVxmFC67{^gs;i`wMxQSMK;JJh0g9&( zB08L=xE$xkM%$@AO(*R{4~;DG9X`N&tLd!l((f|QXzkv2c&^bbhi}plzd8(W$ zYG~v0uE@#=gJ8WHY+PNgRgRH|R~V8rNbBpIS_{@6wX^Hn%7(dE*gLz@JszO=l;V4i z%8k(bNmKq$hArdaLJG45fLh!5N-q0zobH@<2oGS#pnxFPAkXk9yztxJbX=Wu5(YamX80z*zo`{|)DnZm)~ zH@jjyv9*u~J8+!=lr*7~#vZ^&fjSfrQ1~-4y{^inqF}7Ry#ZtFS3#sKCX)0B@b52Y zgC@X99oQI4M}Gy=yz4vX&>=()S{ux z;ihm+@x4fhI2X80aMQ18~zzGslF*9eA;6eb1*zecs-T8QRQ)Luzg zoyjUjyXrrCu+V=gJg!RFgJVPxF#U1+RU#H*TTm@Ln%k~?PgtUV9> zV9<=2UaKXk4>^IzXp*qPUo^UJ0gj9Jt!O|uB{P^0w?J{%&AKh#RruIxf!Rzuqs1!2 z6;n|n%;u!89(ku@rc?tD-{?))9X$^T{PY}E{k?}qI|BC@oiCRVk#Drf%20daa-wpo zj>UXiwH9|@8U?lUL9)aLz)swQr6gT1$3^2XM9wq5Dsg3W|BHMo^Kc@gtqMW) zsk(lVpJtNUK9+h=FFdm8BZdw8-t5Xh0TI!Wg%h6XH9=IT4vd6sOnN9?Q%TC7>()00 z4SvC(A*#FM^hKkJ$xa^jI82@w^%B1@ww}gmBAjemA(4K`k4Q^vqBU#tM>VUiL&#{` z`e)1K1?4sFUDGB%4?p!XJ%3DU7Jmodd8k}x&SZ+v9Av-r%G`kkNiCBeSv@?^D<6|0 zJOEeBK$ntkAPfI3WW+!V^r~_vEqAEto8T-N zT4KX)PKFcj+0lQS!WeRkWQG);WIkvtP7nR=j;Z>w@S|Xm(3MP$Rp~ z{%vUm!S26DUC;Yy5P2QxC&c`SnwN?u)ig~M-V3bd6m~%&I6)UfSlxUXKHA#z=2~^B zkpq?OnBMn5r;Hk;HBv>IXDV9g6L+^QH`f}*TC93HvwBQ8V}*?$3VYtMFPht6-BMj` zV%?|WVwy9K(nz%OwiiQzlyPkicR(Ww-jZNHPv{RbZaZj=|1A`dobBV|YJQCy3HYoi zuwlGzTrT*O6{+orT<74exlgwNCwYn}R+&M=&$S-($4D#z^5bcG^UNxf#6tfQ+h=hu z&%Snh`!PA^= z;PM6~S@42s*!&?g_rphxkN6uFFa=B8GgWqf!v9?6cq3(jFuQYJRXdROn(#|&*_$4M zr{9&P;xRM&crh_vlic2ToO)q8{_}}BauQU0ashNrVW4PQebs&7vMJo;tylVLua0{I zZ9Wa44@=Tynf7gNTc&{)n)dse2ReiEElyRKM#}hy<0&2M8mt4D7ydKKcZUx0nzSgs zgwLSX)^^|uq*R_|1T-9D+5$e{mkco1y}Rjw56?tfJP$VcTj)@hD!BPXJGi_VHkH!m zob+XDJn`FnM)Tb-@i}VFGs^B$PhO`ni}OYRQC(TjcjIr#*bA7%&O#;6*$mA~Nv1a{zdsRSm=w}jR%1?zT zyF73K8~v830l7_mF@pa%J@1u*c4fQOsc^LdrQSN`i%~%5*)>O9(b*==#zTP~?(eFR z*9;-QJ7U#`!T^!G_WqKcN(U5EtL6J_jy0a zyg=Kbiw=*uZ-Z1$yvNnvEni6Fw=Vudg1*2(ny%=3vgIy904{&`$_uWzPp) zf5>j$Js~BYn5_+pNk}sKt@>8`l$(*cvK0eJr+d}mmaaOqv-&yX=h2b$n#Yfh_!p4i zEjU5{ysYo5)(4J~=E^2a?|X?qwM;F~w|)80Aos)#sJi4dn@21rB&_Cn$Z>k_juCS zkFA~xRoZr&!qSBiSjmM<8t9abXw0H6!o3u2Y?vJxmhbySo2r@bGK3O+Wb>0%F^=Iw{A+vDpBRgDFJk>T}t)+jSx`L zWatbPdyz2hRJAF~=-bR7_9H|HB)N}kki!5aszGBhZbr7FmWifRHTz$nyPEeW8@34} zz=umZF>mt-7fNpQtL~{V&YyLy@9u;QvWBjz`7xAl$M{O@!eEani9WP>s0;05s7Fg` zrk+S0HwNo+#xEeR_v#wVhF|@XEswHz9zYaEur5f1G)S{JnP@Yxlf)RPqScwj0z4Zy z#v(@Rq5lfXD{LsE2X8^>)OBvJ>S;>uVe5Ey?NC@-(a7+qYee-M4v_F@P!MDkaaW0& zj#(_qJ&K4ecov1C?8~V<7X1JpYD%w%(-Ya3nS*`iY*w5OU06Woja50$!Li>F- zAbYnb4zB30*r0yd?k^LD!w$E+3tf?ehL+bE+m9x@JB1+kD;j1SO;{;$vSJxJN%(g; zyWG!o*k#D>c?$4B4ewBkAsQf1z%A~IDL81+F3zooGu2xXInXI!IkJyRaW;LJ$%>He zm`-n$8yZ?Q%aj@*kV*}#ur|%V52&u|o(}(7l`ib$4SS3^YHTsFl-K8HEJNjGCVilN zmydjA|NM{hO!j8_aCYp-X!qlJYt9eAVlravdYVbs)u4C_i`KD%#8F@2{2zGT4H=>yY6#>ls~0zM@}- zuJmX?kLj0|eOtt5yp9D6A67mb`9z6xJ$U~I$_PNcEJZQ@+g9BE5J-aG*>?P&iCYJa zDd6(a{%r5*?^2lmNoJ-A?-$-C^v}O~rlht_zR2~X==V?gPm0V`XuHsszg*2$0(W4ZpFF8=?#xbS1S z{mPN(A7|HJ$H}HG{|A-UAJ+B5x_(%fz?lAT;23^b*AMIZAFK<& zmH;%auB^LKwGOO|DZME>-334)#RkBebk^K5k?P7~_vRI%A^;g3yEV!`3Q&iWwD;b8(G0Ura16I6z_LbKFtqF67a>Ta@tnsb1F zZMVQ+y~Vun2A5$O9{~_yafYdYya$CUz?`r=4VRzqDiGip;wURkhkWct0CJ*y!MO4P z+W1>_hStHJ9%7C_)g73P`iNOTB&SG_%U7{gU?!5nCl~-65OBB_m^ zM_n4ISf#AiF{keb5BoMVw#cl#4&{XCk=h1PDBnte)u^x2O-B6fHpl-C)d$}IKq~U3 z$Ay3t5Kse+i4*XY3z(*TSpd2yjLVnd^fxOTJAe-?0;PYq%$u!CNIsiJix==`){pxm z7qQ7iKq@ST<&OmB)>1$;j}X8>ECuo@%Eq@R$mDjQLTk&UP0}G903Ry7QZXtKWQ++~ z;dWh-vFGdJFo0SK5)rjL7hE64U56-x&m?m@ISIcH?T1Q)t`kAyi}VOI0pu?cKgd@B zU@uJcINxybNxa?SnaQz9YURpw=x!JEWp&-BEMZ~#C<;RQ?Bpb!#c`GKZt-pMowhieW>w_OuE5~6sG`(;or87K`N=90=Dg}+Hyjv#C+&1P%{%S{d2AAl@X&jd7>V#B)QHj zU+x~n0qmP3Y?Vdgb{OQS>}{jGP*A$O7=m9>yLqVWz<=^K|8g8RC9J0IKa-Gcm<~>L zN*2q9U15n5%`psUGN!4>?`t))(AM&scpfFkWzYr(gQzL;aa?JNW)BZY)rax{wW-0j z8@OyUEGp3*6(Si&+0~BKgke0r9 zbm=xI_!e8f$OBJl^{+Wu#1_5|kAMp)_ee!6!JQ%O>=L2dyCYsHYnlBv9fgASIV&k~s{HqWa|fK=$Q^ z62+Zyc@mHQ;VD%=uEYU3!kuqg%j!+mjY~k#FIeJL)-|wd5X2$Jj{snwr@-q^#!Yjx zTy&7QpVHC_5iMz;TDCBv&&%i%PN+!0R%c5FM&v1>6b10XX5bL5io!iw1pvEl<=R+DnYgeK#7U8 zS30$%9N0LJ_zk`;05fR2Ve6?4Kk-Tej3O1>Gsgk-x0)uP3R~m2U%j5yXWI$1CA1P? zDXgAZcGA1yy;E$yq7_c8#E}ep7Yr3r;9_|P5$C{NmcdtMXh(EGQHk8PQ2Q9S+yikV z*ROw9U;THo2-sF3$6o1nfcK~;6}9x10o5#x5wFURp;&O0x+Cr(Co7{;FLFd|^ z+z+`qi+$aCo)cnfpv8S`r1C%5@TFr{&x1uD7(B;n#Ix^HgMr2$=oTm(hD&bEA6`&} z_5e_-2Qal?VxEB72}1b&vgV7-D3POr<3pnyduJMn>oh-+k37Vzm9R=wGJ z@`c#lq03!YXp&B;LMiw=sots0&>g{Dr;HbsdUmKEQHOgsh-v@hjFJYN_-?y_E$8Nv zQr`7*2}n!he{T1|>%bV>AR5dBc_?3Pt7?ykN+|Mw zW2^R8f$B>}xS7F!PZEHa|4|$GZc=_EoBz7HtA6}Z%K7)P0$jDCTn_%f?N;~^<^PEC y|A#35M|JgIV3i*k>hCtl|8whB`olN;Ey^E%&W#}5|GEkI2f1W+vGBaZ-TwujVSQTw literal 0 HcmV?d00001 diff --git a/public/r/registry.json b/public/r/registry.json index 6475dce..29b3d1d 100644 --- a/public/r/registry.json +++ b/public/r/registry.json @@ -239,6 +239,19 @@ ], "type": "registry:ui" }, + { + "name": "snake-404", + "title": "Snake 404", + "description": "Dot-matrix 404 where every dot is food: a Nokia-style snake eats the digits, then they come back. Arrow keys, WASD, or a swipe take over.", + "files": [ + { + "path": "registry/snake-404/snake-404.tsx", + "type": "registry:ui", + "target": "components/ui/snake-404.tsx" + } + ], + "type": "registry:ui" + }, { "name": "stretchy-footer", "title": "Stretchy Footer", diff --git a/public/r/snake-404.json b/public/r/snake-404.json new file mode 100644 index 0000000..5e707ce --- /dev/null +++ b/public/r/snake-404.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "snake-404", + "title": "Snake 404", + "description": "Dot-matrix 404 where every dot is food: a Nokia-style snake eats the digits, then they come back. Arrow keys, WASD, or a swipe take over.", + "files": [ + { + "path": "registry/snake-404/snake-404.tsx", + "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type Snake404Theme = \"light\" | \"dark\" | \"auto\"\n\nexport type Snake404Options = {\n /** Digits drawn as food. Default `\"404\"`. Anything but 0–9 is dropped. */\n code?: string\n /** Snake and food ink (hex). Default follows theme, or LCD ink. */\n color?: string\n /** Grid cell in CSS px. Default `12` */\n cellSize?: number\n /** Cells per second. Default `14` */\n speed?: number\n /** Longest the snake grows. Default `32` */\n maxLength?: number\n /** Faint unlit cells behind the field. Default `true` */\n grid?: boolean\n /** Paint a green Nokia LCD behind the field. Default `false` */\n lcd?: boolean\n /** Arrow keys, WASD, or swipes take over the snake. Default `true` */\n playable?: boolean\n /** Pellets eaten, top left. Default `true` */\n score?: boolean\n /** Glide between cells. Off steps cell by cell. Default `true` */\n smooth?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: Snake404Theme\n /** After each pellet: how many are left, out of how many. */\n onEat?: (left: number, total: number) => void\n /** The last pellet is gone; the digits come back shortly after. */\n onClear?: () => void\n /** A player took over (`true`) or handed back to autopilot (`false`). */\n onPlayChange?: (playing: boolean) => void\n}\n\n/** 0 right, 1 down, 2 left, 3 up */\nexport type Snake404Direction = 0 | 1 | 2 | 3\n\nexport type Snake404Instance = {\n setOptions: (options: Partial) => void\n /** Steer as the player. Takes over from autopilot. */\n steer: (direction: Snake404Direction) => void\n destroy: () => void\n}\n\n/** Near-black ink on paper */\nexport const LIGHT_COLOR = \"#18181B\"\n/** Near-white ink on slate */\nexport const DARK_COLOR = \"#E4E4E7\"\n/** Nokia 3310 screen */\nexport const LCD_SCREEN = \"#C7F0D8\"\n/** Nokia 3310 pixels */\nexport const LCD_INK = \"#43523D\"\n\nconst START_LENGTH = 4\nconst ENTER_DELAY_MS = 700\nconst REFILL_DELAY_MS = 900\nconst REFILL_MS = 700\nconst POP_MS = 240\nconst DEATH_MS = 1300\nconst BLINK_MS = 150\nconst BLINKS = 6\nconst MAX_QUEUE = 3\nconst SWIPE_PX = 24\n\nconst DX = [1, 0, -1, 0] as const\nconst DY = [0, 1, 0, -1] as const\n\n/** 6×7 bold digits, `#` is food. */\nconst DIGITS: Record = {\n \"0\": [\".####.\", \"##..##\", \"##..##\", \"##..##\", \"##..##\", \"##..##\", \".####.\"],\n \"1\": [\"..##..\", \".###..\", \"..##..\", \"..##..\", \"..##..\", \"..##..\", \".####.\"],\n \"2\": [\".####.\", \"##..##\", \"....##\", \"..###.\", \".##...\", \"##....\", \"######\"],\n \"3\": [\".####.\", \"##..##\", \"....##\", \"..###.\", \"....##\", \"##..##\", \".####.\"],\n \"4\": [\"##..##\", \"##..##\", \"##..##\", \"######\", \"....##\", \"....##\", \"....##\"],\n \"5\": [\"######\", \"##....\", \"#####.\", \"....##\", \"....##\", \"##..##\", \".####.\"],\n \"6\": [\".####.\", \"##....\", \"#####.\", \"##..##\", \"##..##\", \"##..##\", \".####.\"],\n \"7\": [\"######\", \"....##\", \"...##.\", \"..##..\", \"..##..\", \"..##..\", \"..##..\"],\n \"8\": [\".####.\", \"##..##\", \"##..##\", \".####.\", \"##..##\", \"##..##\", \".####.\"],\n \"9\": [\".####.\", \"##..##\", \"##..##\", \".#####\", \"....##\", \"....##\", \".####.\"],\n}\nconst DIGIT_W = 6\nconst DIGIT_H = 7\nconst DIGIT_GAP = 2\n\n/** 3×5 score digits, one row per string. */\nconst SCORE_DIGITS = [\n \"111101101101111\",\n \"010110010010111\",\n \"111001111100111\",\n \"111001111001111\",\n \"101101111001001\",\n \"111100111001111\",\n \"111100111101111\",\n \"111001001001001\",\n \"111101111101111\",\n \"111101111001111\",\n] as const\n\nconst BAYER4 = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5] as const\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: Snake404Theme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\n/** Digits of `code` that have a glyph, `\"404\"` when none do. */\nexport function codeDigits(code: string | undefined): string {\n const digits = (code ?? \"\").replace(/[^0-9]/g, \"\").slice(0, 6)\n return digits || \"404\"\n}\n\nfunction easeOutBack(t: number) {\n const c = 1.70158\n const u = t - 1\n return 1 + (c + 1) * u * u * u + c * u * u\n}\n\nconst KEYS: Record = {\n ArrowRight: 0,\n ArrowDown: 1,\n ArrowLeft: 2,\n ArrowUp: 3,\n d: 0,\n s: 1,\n a: 2,\n w: 3,\n D: 0,\n S: 1,\n A: 2,\n W: 3,\n}\n\ntype Phase = \"wait\" | \"run\" | \"dead\"\n\nexport function createSnake404(\n wrap: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: Snake404Options = {}\n): Snake404Instance | null {\n let options: Snake404Options = {\n code: \"404\",\n cellSize: 12,\n speed: 14,\n maxLength: 32,\n grid: true,\n lcd: false,\n playable: true,\n score: true,\n smooth: true,\n theme: \"auto\",\n ...initial,\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) return null\n\n let reduce = false\n let dark = resolveDark(options.theme ?? \"auto\")\n let visible = true\n let running = true\n let raf = 0\n let lastNow = performance.now()\n\n let cssW = 0\n let cssH = 0\n let dpr = 1\n let cell = 12\n let cols = 0\n let rows = 0\n let ox = 0\n let oy = 0\n let builtKey = \"\"\n\n // Field\n let food = new Uint8Array(0)\n let born = new Float64Array(0)\n let glyph: number[] = []\n let remaining = 0\n let refillAt = 0\n\n // Snake, head first. Cells may sit off the grid while it slides in.\n let bx: number[] = []\n let by: number[] = []\n let fed: boolean[] = []\n let occ = new Uint8Array(0)\n let dir: Snake404Direction = 0\n let nextX = 0\n let nextY = 0\n let growing = false\n let phase: Phase = \"wait\"\n let enterAt = 0\n let deadAt = 0\n let acc = 0\n let score = 0\n\n let playing = false\n let queue: Snake404Direction[] = []\n\n // Search scratch, reused every step.\n let seen = new Uint32Array(0)\n let dist = new Int32Array(0)\n let bfs = new Int32Array(0)\n let stamp = 0\n\n // Unlit LCD cells, drawn once per layout and ink.\n const ghost = document.createElement(\"canvas\")\n let ghostKey = \"\"\n\n const inkOf = () =>\n options.color ?? (options.lcd ? LCD_INK : dark ? DARK_COLOR : LIGHT_COLOR)\n\n const inGrid = (x: number, y: number) =>\n x >= 0 && y >= 0 && x < cols && y < rows\n\n const tailCell = () => {\n const i = bx.length - 1\n return inGrid(bx[i]!, by[i]!) ? by[i]! * cols + bx[i]! : -1\n }\n\n /** Free next step: empty, or the tail cell that moves out this step. */\n const passable = (x: number, y: number, freeTail: number) => {\n if (!inGrid(x, y)) return false\n const i = y * cols + x\n return occ[i] === 0 || (i === freeTail && occ[i] === 1)\n }\n\n const nextStamp = () => {\n stamp++\n if (stamp === 0xffffffff) {\n seen.fill(0)\n stamp = 1\n }\n return stamp\n }\n\n /** Cells reachable from `start`, stopping once it passes `cap`. */\n const flood = (start: number, freeTail: number, cap: number) => {\n const s = nextStamp()\n // The head moves to `start`, so the old head cell stays blocked.\n seen[start] = s\n bfs[0] = start\n let head = 0\n let tail = 1\n while (head < tail) {\n const c = bfs[head++]!\n const x = c % cols\n const y = (c / cols) | 0\n for (let d = 0; d < 4; d++) {\n const nx = x + DX[d]!\n const ny = y + DY[d]!\n if (!passable(nx, ny, freeTail)) continue\n const n = ny * cols + nx\n if (seen[n] === s) continue\n seen[n] = s\n bfs[tail++] = n\n if (tail > cap) return tail\n }\n }\n return tail\n }\n\n const visibleFood = (i: number, now: number) => food[i] === 1 && born[i]! <= now\n\n /** Autopilot: shortest way to the nearest pellet that will not trap it. */\n const autopilot = (freeTail: number, now: number): Snake404Direction => {\n const hx = bx[0]!\n const hy = by[0]!\n if (!inGrid(hx, hy)) return dir\n const head = hy * cols + hx\n const order = [dir, (dir + 3) % 4, (dir + 1) % 4] as Snake404Direction[]\n const len = bx.length\n\n // Nearest pellet by path length.\n let target = -1\n if (remaining > 0) {\n const s = nextStamp()\n seen[head] = s\n bfs[0] = head\n let qh = 0\n let qt = 1\n search: while (qh < qt) {\n const c = bfs[qh++]!\n const x = c % cols\n const y = (c / cols) | 0\n for (const d of order) {\n const nx = x + DX[d]!\n const ny = y + DY[d]!\n if (!passable(nx, ny, freeTail)) continue\n const n = ny * cols + nx\n if (seen[n] === s) continue\n seen[n] = s\n if (visibleFood(n, now)) {\n target = n\n break search\n }\n bfs[qt++] = n\n }\n }\n }\n\n let pick = -1\n if (target >= 0) {\n // Distances back from the pellet; step to the neighbor closest to it,\n // keeping straight on ties so paths turn once instead of zigzagging.\n const s = nextStamp()\n seen[target] = s\n dist[target] = 0\n bfs[0] = target\n let qh = 0\n let qt = 1\n while (qh < qt) {\n const c = bfs[qh++]!\n const x = c % cols\n const y = (c / cols) | 0\n for (let d = 0; d < 4; d++) {\n const nx = x + DX[d]!\n const ny = y + DY[d]!\n if (!passable(nx, ny, freeTail)) continue\n const n = ny * cols + nx\n if (seen[n] === s) continue\n seen[n] = s\n dist[n] = dist[c]! + 1\n bfs[qt++] = n\n }\n }\n let best = Infinity\n for (const d of order) {\n const nx = hx + DX[d]!\n const ny = hy + DY[d]!\n if (!passable(nx, ny, freeTail)) continue\n const n = ny * cols + nx\n if (seen[n] !== s) continue\n if (dist[n]! < best) {\n best = dist[n]!\n pick = d\n }\n }\n if (pick >= 0) {\n const n = (hy + DY[pick]!) * cols + hx + DX[pick]!\n if (flood(n, freeTail, len) < len) pick = -1\n }\n }\n if (pick >= 0) return pick as Snake404Direction\n\n // No safe pellet: wander, mostly straight, away from walls and itself.\n const cap = len * 2 + 8\n let bestArea = -1\n let bestDir = dir\n const ahead = (d: number) =>\n passable(hx + DX[d]! * 2, hy + DY[d]! * 2, freeTail) ? 1 : 0\n const wander =\n Math.random() < 0.08 ? [order[1]!, order[2]!, order[0]!] : order\n for (const d of wander) {\n const nx = hx + DX[d]!\n const ny = hy + DY[d]!\n if (!passable(nx, ny, freeTail)) continue\n const area = flood(ny * cols + nx, freeTail, cap) * 2 + ahead(d)\n if (area > bestArea) {\n bestArea = area\n bestDir = d\n }\n }\n return bestDir\n }\n\n const die = (now: number) => {\n phase = \"dead\"\n deadAt = now\n queue = []\n if (playing) {\n playing = false\n options.onPlayChange?.(false)\n }\n }\n\n /** Decide the move for the step now starting. */\n const plan = (now: number) => {\n const last = bx.length - 1\n growing = fed[last]! && bx.length < Math.max(START_LENGTH, options.maxLength ?? 32)\n const freeTail = growing ? -1 : tailCell()\n let d = dir\n if (playing) {\n if (queue.length) d = queue.shift()!\n } else {\n d = autopilot(freeTail, now)\n }\n const nx = bx[0]! + DX[d]!\n const ny = by[0]! + DY[d]!\n if (!passable(nx, ny, freeTail)) {\n die(now)\n return\n }\n dir = d\n nextX = nx\n nextY = ny\n }\n\n /** Finish the step: the tail follows, the head lands and maybe eats. */\n const commit = (now: number) => {\n const last = bx.length - 1\n if (growing) {\n // The bulge reached the tail; the tail holds and the snake is longer.\n fed[last] = false\n } else {\n if (inGrid(bx[last]!, by[last]!)) {\n const t = by[last]! * cols + bx[last]!\n occ[t] = occ[t]! - 1\n }\n bx.pop()\n by.pop()\n fed.pop()\n }\n bx.unshift(nextX)\n by.unshift(nextY)\n const i = nextY * cols + nextX\n occ[i] = occ[i]! + 1\n const ate = food[i] === 1\n fed.unshift(ate)\n if (ate) {\n food[i] = 0\n remaining--\n score++\n options.onEat?.(remaining, glyph.length)\n if (remaining === 0) {\n refillAt = now + REFILL_DELAY_MS\n options.onClear?.()\n }\n }\n }\n\n const spawn = (now: number) => {\n const row = Math.floor(rows / 2)\n // Slide in from the left edge; reduced motion parks it on screen.\n const onScreen = reduce\n bx = []\n by = []\n fed = []\n occ.fill(0)\n for (let k = 0; k < START_LENGTH; k++) {\n const x = onScreen ? START_LENGTH - k : -1 - k\n bx.push(x)\n by.push(row)\n fed.push(false)\n if (inGrid(x, row)) occ[row * cols + x] = 1\n }\n dir = 0\n nextX = bx[0]! + 1\n nextY = row\n growing = false\n acc = 0\n score = 0\n queue = []\n phase = onScreen ? \"run\" : \"wait\"\n enterAt = now + ENTER_DELAY_MS\n }\n\n const refill = (now: number) => {\n const span = Math.max(1, cols + rows)\n for (const i of glyph) {\n if (food[i] === 1) continue\n const x = i % cols\n const y = (i / cols) | 0\n food[i] = 1\n born[i] =\n now + ((x + y) / span) * REFILL_MS * 2 + (BAYER4[(y & 3) * 4 + (x & 3)]! / 16) * 140\n remaining++\n }\n }\n\n const layout = (now: number) => {\n cell = Math.max(4, Math.round(options.cellSize ?? 12))\n cols = Math.max(1, Math.floor(cssW / cell))\n rows = Math.max(1, Math.floor(cssH / cell))\n ox = Math.floor((cssW - cols * cell) / 2)\n oy = Math.floor((cssH - rows * cell) / 2)\n const n = cols * rows\n food = new Uint8Array(n)\n born = new Float64Array(n)\n occ = new Uint8Array(n)\n seen = new Uint32Array(n)\n dist = new Int32Array(n)\n bfs = new Int32Array(n + 4)\n stamp = 0\n glyph = []\n remaining = 0\n refillAt = 0\n\n const digits = codeDigits(options.code)\n const bw = digits.length * DIGIT_W + (digits.length - 1) * DIGIT_GAP\n const k = Math.max(\n 1,\n Math.floor(Math.min((cols * 0.74) / bw, (rows * 0.5) / DIGIT_H))\n )\n const gx0 = Math.floor((cols - bw * k) / 2)\n const gy0 = Math.floor((rows - DIGIT_H * k) / 2)\n for (let di = 0; di < digits.length; di++) {\n const bitmap = DIGITS[digits[di]!]!\n const left = gx0 + di * (DIGIT_W + DIGIT_GAP) * k\n for (let r = 0; r < DIGIT_H; r++) {\n for (let c = 0; c < DIGIT_W; c++) {\n if (bitmap[r]![c] !== \"#\") continue\n for (let sy = 0; sy < k; sy++) {\n for (let sx = 0; sx < k; sx++) {\n const x = left + c * k + sx\n const y = gy0 + r * k + sy\n if (!inGrid(x, y)) continue\n const i = y * cols + x\n food[i] = 1\n glyph.push(i)\n remaining++\n }\n }\n }\n }\n }\n if (playing) {\n playing = false\n options.onPlayChange?.(false)\n }\n spawn(now)\n }\n\n /** Returns whether the backing store changed (which clears the canvas). */\n const resize = () => {\n dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = wrap.clientWidth\n const h = wrap.clientHeight\n if (w <= 0 || h <= 0) return false\n cssW = w\n cssH = h\n const width = Math.max(1, Math.floor(w * dpr))\n const height = Math.max(1, Math.floor(h * dpr))\n const changed = width !== canvas.width || height !== canvas.height\n if (changed) {\n canvas.width = width\n canvas.height = height\n }\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n const c = Math.max(4, Math.round(options.cellSize ?? 12))\n const key = `${Math.floor(w / c)}x${Math.floor(h / c)}:${c}:${codeDigits(options.code)}`\n if (key !== builtKey) {\n builtKey = key\n layout(performance.now())\n }\n return changed\n }\n\n const drawGhost = (ink: string) => {\n const key = `${canvas.width}x${canvas.height}:${cols}x${rows}:${cell}:${ink}:${options.lcd}:${dark}`\n if (key === ghostKey) return\n ghostKey = key\n ghost.width = canvas.width\n ghost.height = canvas.height\n const g = ghost.getContext(\"2d\")\n if (!g) return\n g.setTransform(dpr, 0, 0, dpr, 0, 0)\n g.clearRect(0, 0, cssW, cssH)\n g.fillStyle = ink\n g.globalAlpha = options.lcd ? 0.07 : dark ? 0.05 : 0.035\n const inset = Math.max(1, Math.round(cell * 0.16))\n const size = cell - inset * 2\n for (let y = 0; y < rows; y++) {\n for (let x = 0; x < cols; x++) {\n g.fillRect(ox + x * cell + inset, oy + y * cell + inset, size, size)\n }\n }\n }\n\n const draw = (now: number, p: number) => {\n const ink = inkOf()\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n ctx.clearRect(0, 0, cssW, cssH)\n if (options.lcd) {\n ctx.fillStyle = LCD_SCREEN\n ctx.fillRect(0, 0, cssW, cssH)\n }\n if (options.grid) {\n drawGhost(ink)\n ctx.drawImage(ghost, 0, 0, cssW, cssH)\n }\n\n ctx.fillStyle = ink\n\n // Food: one pellet per lit cell, popping in after a refill.\n const inset = Math.max(1, Math.round(cell * 0.16))\n const pellet = cell - inset * 2\n for (const i of glyph) {\n if (food[i] !== 1) continue\n const t = (now - born[i]!) / POP_MS\n if (t < 0) continue\n const x = ox + (i % cols) * cell\n const y = oy + ((i / cols) | 0) * cell\n if (t >= 1) {\n ctx.fillRect(x + inset, y + inset, pellet, pellet)\n } else {\n const s = pellet * easeOutBack(t)\n const o = (cell - s) / 2\n ctx.fillRect(x + o, y + o, s, s)\n }\n }\n\n // Snake.\n const dead = phase === \"dead\"\n const blink = Math.floor((now - deadAt) / BLINK_MS)\n const shown = !dead || (blink < BLINKS && blink % 2 === 1)\n if (shown && bx.length > 0) {\n // Body as thick as a pellet; a meal swells it to nearly a full cell.\n const th = pellet\n const half = th / 2\n const cx = (x: number) => ox + (x + 0.5) * cell\n const cy = (y: number) => oy + (y + 0.5) * cell\n const moving = phase === \"run\"\n const t = moving ? p : 0\n const last = bx.length - 1\n\n const hx = cx(bx[0]! + (moving ? (nextX - bx[0]!) * t : 0))\n const hy = cy(by[0]! + (moving ? (nextY - by[0]!) * t : 0))\n const tailT = moving && !growing ? t : 0\n const tx = cx(bx[last]! + (bx[Math.max(0, last - 1)]! - bx[last]!) * tailT)\n const ty = cy(by[last]! + (by[Math.max(0, last - 1)]! - by[last]!) * tailT)\n\n const band = (ax: number, ay: number, bx2: number, by2: number) => {\n ctx.fillRect(\n Math.min(ax, bx2) - half,\n Math.min(ay, by2) - half,\n Math.abs(ax - bx2) + th,\n Math.abs(ay - by2) + th\n )\n }\n\n let px = hx\n let py = hy\n for (let k = 0; k < last; k++) {\n const qx = cx(bx[k]!)\n const qy = cy(by[k]!)\n band(px, py, qx, qy)\n px = qx\n py = qy\n }\n band(px, py, tx, ty)\n\n // Swallowed pellets: a bulge that stays put while the body slides by.\n const swell = cell - Math.max(1, Math.round(cell * 0.06)) * 2\n for (let k = 1; k <= last; k++) {\n if (!fed[k] || (k === last && !growing)) continue\n ctx.fillRect(cx(bx[k]!) - swell / 2, cy(by[k]!) - swell / 2, swell, swell)\n }\n\n // Eye, and a mouth that opens when food is next.\n const fx = DX[dir]!\n const fy = DY[dir]!\n const lx = fy\n const ly = -fx\n const cut = (x: number, y: number, w: number, h: number) => {\n if (options.lcd) {\n ctx.fillStyle = LCD_SCREEN\n ctx.fillRect(x, y, w, h)\n ctx.fillStyle = ink\n return\n }\n ctx.globalCompositeOperation = \"destination-out\"\n ctx.fillRect(x, y, w, h)\n ctx.globalCompositeOperation = \"source-over\"\n }\n // The head is a full swell, so the eye and mouth have room.\n ctx.fillRect(hx - swell / 2, hy - swell / 2, swell, swell)\n const eye = Math.max(2, Math.round(cell * 0.2))\n const ex = hx - fx * swell * 0.06 + lx * swell * 0.22\n const ey = hy - fy * swell * 0.06 + ly * swell * 0.22\n cut(ex - eye / 2, ey - eye / 2, eye, eye)\n if (moving && inGrid(nextX, nextY) && visibleFood(nextY * cols + nextX, now)) {\n const w = Math.max(2, Math.round(swell * 0.36))\n const depth = Math.max(1, Math.round(swell * 0.3))\n const mx = hx + fx * (swell / 2 - depth / 2)\n const my = hy + fy * (swell / 2 - depth / 2)\n const mw = fx !== 0 ? depth : w\n const mh = fx !== 0 ? w : depth\n cut(mx - mw / 2, my - mh / 2, mw, mh)\n }\n }\n\n if (options.score) {\n const dot = Math.max(2, Math.round(cell / 4))\n const digits = String(score).padStart(4, \"0\")\n const x0 = ox + cell\n const y0 = oy + cell\n ctx.globalAlpha = 0.6\n for (let d = 0; d < digits.length; d++) {\n const bits = SCORE_DIGITS[digits.charCodeAt(d) - 48]!\n for (let b = 0; b < 15; b++) {\n if (bits[b] !== \"1\") continue\n ctx.fillRect(\n x0 + d * dot * 4 + (b % 3) * dot,\n y0 + Math.floor(b / 3) * dot,\n dot,\n dot\n )\n }\n }\n ctx.globalAlpha = 1\n }\n }\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min(100, now - lastNow)\n lastNow = now\n\n if (refillAt && now >= refillAt) {\n refillAt = 0\n refill(now)\n }\n if (phase === \"wait\" && now >= enterAt && !reduce) phase = \"run\"\n if (phase === \"dead\" && now - deadAt >= DEATH_MS) spawn(now)\n\n const stepMs = 1000 / Math.max(1, options.speed ?? 14)\n if (phase === \"run\" && (!reduce || playing)) {\n acc += dt\n while (acc >= stepMs && phase === \"run\") {\n acc -= stepMs\n commit(now)\n plan(now)\n }\n }\n const p = options.smooth ? Math.min(1, acc / stepMs) : 0\n draw(now, p)\n\n if (visible) raf = requestAnimationFrame(tick)\n }\n\n const start = () => {\n cancelAnimationFrame(raf)\n lastNow = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n const steer = (d: Snake404Direction) => {\n if (!options.playable || phase === \"dead\") return\n if (!playing) {\n playing = true\n queue = []\n options.onPlayChange?.(true)\n }\n if (phase === \"wait\") phase = \"run\"\n const last = queue.length ? queue[queue.length - 1]! : dir\n if (d === last || d === (last + 2) % 4) return\n if (queue.length < MAX_QUEUE) queue.push(d)\n }\n\n const handBack = () => {\n if (!playing) return\n playing = false\n queue = []\n options.onPlayChange?.(false)\n }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n reduce = mqReduce.matches\n const onReduce = () => {\n const was = reduce\n reduce = mqReduce.matches\n if (reduce !== was && !playing) spawn(performance.now())\n }\n mqReduce.addEventListener(\"change\", onReduce)\n\n resize()\n draw(performance.now(), 0)\n\n const ro = new ResizeObserver(() => {\n const changed = resize()\n if (changed && running) draw(performance.now(), 0)\n })\n ro.observe(wrap)\n\n const io = new IntersectionObserver(([entry]) => {\n const was = visible\n visible = entry?.isIntersecting ?? true\n if (visible && !was) start()\n })\n io.observe(wrap)\n\n const syncTheme = () => {\n dark = resolveDark(options.theme ?? \"auto\")\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const onKey = (e: KeyboardEvent) => {\n if (!options.playable || e.metaKey || e.ctrlKey || e.altKey) return\n if (e.key === \"Escape\") {\n if (playing) {\n e.preventDefault()\n handBack()\n }\n return\n }\n const d = KEYS[e.key]\n if (d === undefined) return\n e.preventDefault()\n steer(d)\n }\n\n let swipe: { id: number; x: number; y: number } | null = null\n const onDown = (e: PointerEvent) => {\n if (!options.playable) return\n swipe = { id: e.pointerId, x: e.clientX, y: e.clientY }\n }\n const onUp = (e: PointerEvent) => {\n if (!swipe || swipe.id !== e.pointerId) return\n const dx = e.clientX - swipe.x\n const dy = e.clientY - swipe.y\n swipe = null\n if (Math.max(Math.abs(dx), Math.abs(dy)) < SWIPE_PX) return\n if (Math.abs(dx) > Math.abs(dy)) steer(dx > 0 ? 0 : 2)\n else steer(dy > 0 ? 1 : 3)\n }\n const onCancel = () => {\n swipe = null\n }\n\n wrap.addEventListener(\"keydown\", onKey)\n wrap.addEventListener(\"focusout\", handBack)\n wrap.addEventListener(\"pointerdown\", onDown)\n wrap.addEventListener(\"pointerup\", onUp)\n wrap.addEventListener(\"pointercancel\", onCancel)\n\n start()\n\n return {\n setOptions(next) {\n const prev = options\n options = { ...options, ...next }\n if (\n options.cellSize !== prev.cellSize ||\n codeDigits(options.code) !== codeDigits(prev.code)\n ) {\n builtKey = \"\"\n resize()\n }\n if (options.theme !== prev.theme) syncTheme()\n if (!options.playable) handBack()\n if (!visible) draw(performance.now(), 0)\n },\n steer,\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n io.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n wrap.removeEventListener(\"keydown\", onKey)\n wrap.removeEventListener(\"focusout\", handBack)\n wrap.removeEventListener(\"pointerdown\", onDown)\n wrap.removeEventListener(\"pointerup\", onUp)\n wrap.removeEventListener(\"pointercancel\", onCancel)\n },\n }\n}\n\nexport type Snake404Props = Snake404Options & {\n /** Accessible name. Default `\", page not found\"`. */\n label?: string\n className?: string\n}\n\n/**\n * A dot-matrix 404 where every dot is food. A Nokia-style snake eats its\n * way through the digits, then they come back. Click and use the arrow\n * keys, WASD, or swipe to play.\n */\nexport function Snake404({\n code = \"404\",\n color,\n cellSize = 12,\n speed = 14,\n maxLength = 32,\n grid = true,\n lcd = false,\n playable = true,\n score = true,\n smooth = true,\n theme = \"auto\",\n onEat,\n onClear,\n onPlayChange,\n label,\n className,\n}: Snake404Props) {\n const wrapRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const callbacksRef = useRef({ onEat, onClear, onPlayChange })\n const [playing, setPlaying] = useState(false)\n\n useEffect(() => {\n callbacksRef.current = { onEat, onClear, onPlayChange }\n }, [onEat, onClear, onPlayChange])\n\n const options = {\n code,\n color,\n cellSize,\n speed,\n maxLength,\n grid,\n lcd,\n playable,\n score,\n smooth,\n theme,\n }\n\n useEffect(() => {\n const wrap = wrapRef.current\n const canvas = canvasRef.current\n if (!wrap || !canvas) return\n instanceRef.current = createSnake404(wrap, canvas, {\n ...options,\n onEat: (left, total) => callbacksRef.current.onEat?.(left, total),\n onClear: () => callbacksRef.current.onClear?.(),\n onPlayChange: (next) => {\n setPlaying(next)\n callbacksRef.current.onPlayChange?.(next)\n },\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions(options)\n // `options` is rebuilt every render; its fields are the real deps.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [\n code,\n color,\n cellSize,\n speed,\n maxLength,\n grid,\n lcd,\n playable,\n score,\n smooth,\n theme,\n ])\n\n const name = label ?? `${codeDigits(code)}, page not found`\n\n return (\n \n \n \n )\n}\n", + "type": "registry:ui", + "target": "components/ui/snake-404.tsx" + } + ], + "type": "registry:ui" +} \ No newline at end of file diff --git a/registry.json b/registry.json index 523afb8..d03609e 100644 --- a/registry.json +++ b/registry.json @@ -20,6 +20,7 @@ "registry/shader-gradient/registry.json", "registry/shader-metal/registry.json", "registry/shader-sky/registry.json", + "registry/snake-404/registry.json", "registry/stretchy-footer/registry.json", "registry/typewriter/registry.json" ] diff --git a/registry/snake-404/registry.json b/registry/snake-404/registry.json new file mode 100644 index 0000000..deed871 --- /dev/null +++ b/registry/snake-404/registry.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "items": [ + { + "name": "snake-404", + "type": "registry:ui", + "title": "Snake 404", + "description": "Dot-matrix 404 where every dot is food: a Nokia-style snake eats the digits, then they come back. Arrow keys, WASD, or a swipe take over.", + "files": [ + { + "path": "snake-404.tsx", + "type": "registry:ui", + "target": "components/ui/snake-404.tsx" + } + ] + } + ] +} diff --git a/registry/snake-404/snake-404-demo.tsx b/registry/snake-404/snake-404-demo.tsx new file mode 100644 index 0000000..3bdbe72 --- /dev/null +++ b/registry/snake-404/snake-404-demo.tsx @@ -0,0 +1,219 @@ +"use client" + +import { useCallback, useLayoutEffect, useMemo, useState } from "react" + +import { + ComponentControls, + ControlColor, + ControlSlider, + ControlSwitch, +} from "@/components/component-controls" +import { ComponentPreview } from "@/components/component-preview" +import { useHydratedTheme } from "@/hooks/use-hydrated-theme" +import { usePreviewProps } from "@/hooks/use-preview-props" +import { cn } from "@/lib/utils" +import { + DARK_COLOR, + LCD_INK, + LIGHT_COLOR, + Snake404, +} from "@/registry/snake-404/snake-404" + +const CODES = ["404", "500", "403"] as const + +function norm(hex: string) { + return hex.trim().toUpperCase() +} + +function isStockColor(color: string) { + const value = norm(color) + return ( + value === norm(LIGHT_COLOR) || + value === norm(DARK_COLOR) || + value === norm(LCD_INK) + ) +} + +export function Snake404Demo() { + const theme = useHydratedTheme() + const palette = theme === "dark" ? DARK_COLOR : LIGHT_COLOR + + const defaults = useMemo( + () => ({ + code: "404" as string, + color: palette, + cellSize: 12, + speed: 14, + maxLength: 32, + lcd: false, + grid: true, + smooth: true, + score: true, + playable: true, + }), + [palette] + ) + + const { props, updateProp, resetProps, hasChanges, setProps } = + usePreviewProps(defaults) + const [playing, setPlaying] = useState(false) + const [left, setLeft] = useState(null) + + useLayoutEffect(() => { + setProps((prev) => { + if (!isStockColor(prev.color)) return prev + const stock = prev.lcd ? LCD_INK : palette + if (norm(prev.color) === norm(stock)) return prev + return { ...prev, color: stock } + }) + }, [palette, props.lcd, setProps]) + + const onEat = useCallback((n: number) => setLeft(n), []) + const onClear = useCallback(() => setLeft(null), []) + + const useAutoColor = isStockColor(props.color) + + return ( + <> + +

+ +

+ {!props.playable + ? "This page could not be found." + : playing + ? "Esc hands the snake back." + : "Click, then steer with the arrow keys."} +

+
+ + + +
+ Code +
+ {CODES.map((code) => ( + + ))} +
+
+
+ {playing ? "You're steering" : "Autopilot"} + + {left === null ? "Full" : `${left} left`} + +
+ updateProp("color", v)} + /> + updateProp("cellSize", v)} + /> + updateProp("speed", v)} + /> + updateProp("maxLength", v)} + /> + updateProp("lcd", v)} + /> + updateProp("grid", v)} + /> + updateProp("smooth", v)} + /> + updateProp("score", v)} + /> + updateProp("playable", v)} + /> +
+ + ) +} diff --git a/registry/snake-404/snake-404-vanilla.ts b/registry/snake-404/snake-404-vanilla.ts new file mode 100644 index 0000000..fbde6ca --- /dev/null +++ b/registry/snake-404/snake-404-vanilla.ts @@ -0,0 +1,881 @@ +export type Snake404Theme = "light" | "dark" | "auto" + +export type Snake404Options = { + /** Digits drawn as food. Default `"404"`. Anything but 0–9 is dropped. */ + code?: string + /** Snake and food ink (hex). Default follows theme, or LCD ink. */ + color?: string + /** Grid cell in CSS px. Default `12` */ + cellSize?: number + /** Cells per second. Default `14` */ + speed?: number + /** Longest the snake grows. Default `32` */ + maxLength?: number + /** Faint unlit cells behind the field. Default `true` */ + grid?: boolean + /** Paint a green Nokia LCD behind the field. Default `false` */ + lcd?: boolean + /** Arrow keys, WASD, or swipes take over the snake. Default `true` */ + playable?: boolean + /** Pellets eaten, top left. Default `true` */ + score?: boolean + /** Glide between cells. Off steps cell by cell. Default `true` */ + smooth?: boolean + /** + * Palette mode. Default `auto` follows shadcn / next-themes + * (`html.dark` class). + */ + theme?: Snake404Theme + /** After each pellet: how many are left, out of how many. */ + onEat?: (left: number, total: number) => void + /** The last pellet is gone; the digits come back shortly after. */ + onClear?: () => void + /** A player took over (`true`) or handed back to autopilot (`false`). */ + onPlayChange?: (playing: boolean) => void +} + +/** 0 right, 1 down, 2 left, 3 up */ +export type Snake404Direction = 0 | 1 | 2 | 3 + +export type Snake404Instance = { + setOptions: (options: Partial) => void + /** Steer as the player. Takes over from autopilot. */ + steer: (direction: Snake404Direction) => void + destroy: () => void +} + +/** Near-black ink on paper */ +export const LIGHT_COLOR = "#18181B" +/** Near-white ink on slate */ +export const DARK_COLOR = "#E4E4E7" +/** Nokia 3310 screen */ +export const LCD_SCREEN = "#C7F0D8" +/** Nokia 3310 pixels */ +export const LCD_INK = "#43523D" + +const START_LENGTH = 4 +const ENTER_DELAY_MS = 700 +const REFILL_DELAY_MS = 900 +const REFILL_MS = 700 +const POP_MS = 240 +const DEATH_MS = 1300 +const BLINK_MS = 150 +const BLINKS = 6 +const MAX_QUEUE = 3 +const SWIPE_PX = 24 + +const DX = [1, 0, -1, 0] as const +const DY = [0, 1, 0, -1] as const + +/** 6×7 bold digits, `#` is food. */ +const DIGITS: Record = { + "0": [".####.", "##..##", "##..##", "##..##", "##..##", "##..##", ".####."], + "1": ["..##..", ".###..", "..##..", "..##..", "..##..", "..##..", ".####."], + "2": [".####.", "##..##", "....##", "..###.", ".##...", "##....", "######"], + "3": [".####.", "##..##", "....##", "..###.", "....##", "##..##", ".####."], + "4": ["##..##", "##..##", "##..##", "######", "....##", "....##", "....##"], + "5": ["######", "##....", "#####.", "....##", "....##", "##..##", ".####."], + "6": [".####.", "##....", "#####.", "##..##", "##..##", "##..##", ".####."], + "7": ["######", "....##", "...##.", "..##..", "..##..", "..##..", "..##.."], + "8": [".####.", "##..##", "##..##", ".####.", "##..##", "##..##", ".####."], + "9": [".####.", "##..##", "##..##", ".#####", "....##", "....##", ".####."], +} +const DIGIT_W = 6 +const DIGIT_H = 7 +const DIGIT_GAP = 2 + +/** 3×5 score digits, one row per string. */ +const SCORE_DIGITS = [ + "111101101101111", + "010110010010111", + "111001111100111", + "111001111001111", + "101101111001001", + "111100111001111", + "111100111101111", + "111001001001001", + "111101111101111", + "111101111001111", +] as const + +const BAYER4 = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5] as const + +/** Resolves shadcn / next-themes dark mode (`attribute="class"` → `html.dark`). */ +export function isDarkTheme(): boolean { + if (typeof document === "undefined") return false + const root = document.documentElement + if (root.classList.contains("dark")) return true + if (root.classList.contains("light")) return false + const dataTheme = root.getAttribute("data-theme") + if (dataTheme === "dark") return true + if (dataTheme === "light") return false + return window.matchMedia("(prefers-color-scheme: dark)").matches +} + +export function resolveDark(theme: Snake404Theme): boolean { + if (theme === "dark") return true + if (theme === "light") return false + return isDarkTheme() +} + +/** Digits of `code` that have a glyph, `"404"` when none do. */ +export function codeDigits(code: string | undefined): string { + const digits = (code ?? "").replace(/[^0-9]/g, "").slice(0, 6) + return digits || "404" +} + +function easeOutBack(t: number) { + const c = 1.70158 + const u = t - 1 + return 1 + (c + 1) * u * u * u + c * u * u +} + +const KEYS: Record = { + ArrowRight: 0, + ArrowDown: 1, + ArrowLeft: 2, + ArrowUp: 3, + d: 0, + s: 1, + a: 2, + w: 3, + D: 0, + S: 1, + A: 2, + W: 3, +} + +type Phase = "wait" | "run" | "dead" + +export function createSnake404( + wrap: HTMLElement, + canvas: HTMLCanvasElement, + initial: Snake404Options = {} +): Snake404Instance | null { + let options: Snake404Options = { + code: "404", + cellSize: 12, + speed: 14, + maxLength: 32, + grid: true, + lcd: false, + playable: true, + score: true, + smooth: true, + theme: "auto", + ...initial, + } + + const ctx = canvas.getContext("2d", { alpha: true }) + if (!ctx) return null + + let reduce = false + let dark = resolveDark(options.theme ?? "auto") + let visible = true + let running = true + let raf = 0 + let lastNow = performance.now() + + let cssW = 0 + let cssH = 0 + let dpr = 1 + let cell = 12 + let cols = 0 + let rows = 0 + let ox = 0 + let oy = 0 + let builtKey = "" + + // Field + let food = new Uint8Array(0) + let born = new Float64Array(0) + let glyph: number[] = [] + let remaining = 0 + let refillAt = 0 + + // Snake, head first. Cells may sit off the grid while it slides in. + let bx: number[] = [] + let by: number[] = [] + let fed: boolean[] = [] + let occ = new Uint8Array(0) + let dir: Snake404Direction = 0 + let nextX = 0 + let nextY = 0 + let growing = false + let phase: Phase = "wait" + let enterAt = 0 + let deadAt = 0 + let acc = 0 + let score = 0 + + let playing = false + let queue: Snake404Direction[] = [] + + // Search scratch, reused every step. + let seen = new Uint32Array(0) + let dist = new Int32Array(0) + let bfs = new Int32Array(0) + let stamp = 0 + + // Unlit LCD cells, drawn once per layout and ink. + const ghost = document.createElement("canvas") + let ghostKey = "" + + const inkOf = () => + options.color ?? (options.lcd ? LCD_INK : dark ? DARK_COLOR : LIGHT_COLOR) + + const inGrid = (x: number, y: number) => + x >= 0 && y >= 0 && x < cols && y < rows + + const tailCell = () => { + const i = bx.length - 1 + return inGrid(bx[i]!, by[i]!) ? by[i]! * cols + bx[i]! : -1 + } + + /** Free next step: empty, or the tail cell that moves out this step. */ + const passable = (x: number, y: number, freeTail: number) => { + if (!inGrid(x, y)) return false + const i = y * cols + x + return occ[i] === 0 || (i === freeTail && occ[i] === 1) + } + + const nextStamp = () => { + stamp++ + if (stamp === 0xffffffff) { + seen.fill(0) + stamp = 1 + } + return stamp + } + + /** Cells reachable from `start`, stopping once it passes `cap`. */ + const flood = (start: number, freeTail: number, cap: number) => { + const s = nextStamp() + // The head moves to `start`, so the old head cell stays blocked. + seen[start] = s + bfs[0] = start + let head = 0 + let tail = 1 + while (head < tail) { + const c = bfs[head++]! + const x = c % cols + const y = (c / cols) | 0 + for (let d = 0; d < 4; d++) { + const nx = x + DX[d]! + const ny = y + DY[d]! + if (!passable(nx, ny, freeTail)) continue + const n = ny * cols + nx + if (seen[n] === s) continue + seen[n] = s + bfs[tail++] = n + if (tail > cap) return tail + } + } + return tail + } + + const visibleFood = (i: number, now: number) => food[i] === 1 && born[i]! <= now + + /** Autopilot: shortest way to the nearest pellet that will not trap it. */ + const autopilot = (freeTail: number, now: number): Snake404Direction => { + const hx = bx[0]! + const hy = by[0]! + if (!inGrid(hx, hy)) return dir + const head = hy * cols + hx + const order = [dir, (dir + 3) % 4, (dir + 1) % 4] as Snake404Direction[] + const len = bx.length + + // Nearest pellet by path length. + let target = -1 + if (remaining > 0) { + const s = nextStamp() + seen[head] = s + bfs[0] = head + let qh = 0 + let qt = 1 + search: while (qh < qt) { + const c = bfs[qh++]! + const x = c % cols + const y = (c / cols) | 0 + for (const d of order) { + const nx = x + DX[d]! + const ny = y + DY[d]! + if (!passable(nx, ny, freeTail)) continue + const n = ny * cols + nx + if (seen[n] === s) continue + seen[n] = s + if (visibleFood(n, now)) { + target = n + break search + } + bfs[qt++] = n + } + } + } + + let pick = -1 + if (target >= 0) { + // Distances back from the pellet; step to the neighbor closest to it, + // keeping straight on ties so paths turn once instead of zigzagging. + const s = nextStamp() + seen[target] = s + dist[target] = 0 + bfs[0] = target + let qh = 0 + let qt = 1 + while (qh < qt) { + const c = bfs[qh++]! + const x = c % cols + const y = (c / cols) | 0 + for (let d = 0; d < 4; d++) { + const nx = x + DX[d]! + const ny = y + DY[d]! + if (!passable(nx, ny, freeTail)) continue + const n = ny * cols + nx + if (seen[n] === s) continue + seen[n] = s + dist[n] = dist[c]! + 1 + bfs[qt++] = n + } + } + let best = Infinity + for (const d of order) { + const nx = hx + DX[d]! + const ny = hy + DY[d]! + if (!passable(nx, ny, freeTail)) continue + const n = ny * cols + nx + if (seen[n] !== s) continue + if (dist[n]! < best) { + best = dist[n]! + pick = d + } + } + if (pick >= 0) { + const n = (hy + DY[pick]!) * cols + hx + DX[pick]! + if (flood(n, freeTail, len) < len) pick = -1 + } + } + if (pick >= 0) return pick as Snake404Direction + + // No safe pellet: wander, mostly straight, away from walls and itself. + const cap = len * 2 + 8 + let bestArea = -1 + let bestDir = dir + const ahead = (d: number) => + passable(hx + DX[d]! * 2, hy + DY[d]! * 2, freeTail) ? 1 : 0 + const wander = + Math.random() < 0.08 ? [order[1]!, order[2]!, order[0]!] : order + for (const d of wander) { + const nx = hx + DX[d]! + const ny = hy + DY[d]! + if (!passable(nx, ny, freeTail)) continue + const area = flood(ny * cols + nx, freeTail, cap) * 2 + ahead(d) + if (area > bestArea) { + bestArea = area + bestDir = d + } + } + return bestDir + } + + const die = (now: number) => { + phase = "dead" + deadAt = now + queue = [] + if (playing) { + playing = false + options.onPlayChange?.(false) + } + } + + /** Decide the move for the step now starting. */ + const plan = (now: number) => { + const last = bx.length - 1 + growing = fed[last]! && bx.length < Math.max(START_LENGTH, options.maxLength ?? 32) + const freeTail = growing ? -1 : tailCell() + let d = dir + if (playing) { + if (queue.length) d = queue.shift()! + } else { + d = autopilot(freeTail, now) + } + const nx = bx[0]! + DX[d]! + const ny = by[0]! + DY[d]! + if (!passable(nx, ny, freeTail)) { + die(now) + return + } + dir = d + nextX = nx + nextY = ny + } + + /** Finish the step: the tail follows, the head lands and maybe eats. */ + const commit = (now: number) => { + const last = bx.length - 1 + if (growing) { + // The bulge reached the tail; the tail holds and the snake is longer. + fed[last] = false + } else { + if (inGrid(bx[last]!, by[last]!)) { + const t = by[last]! * cols + bx[last]! + occ[t] = occ[t]! - 1 + } + bx.pop() + by.pop() + fed.pop() + } + bx.unshift(nextX) + by.unshift(nextY) + const i = nextY * cols + nextX + occ[i] = occ[i]! + 1 + const ate = food[i] === 1 + fed.unshift(ate) + if (ate) { + food[i] = 0 + remaining-- + score++ + options.onEat?.(remaining, glyph.length) + if (remaining === 0) { + refillAt = now + REFILL_DELAY_MS + options.onClear?.() + } + } + } + + const spawn = (now: number) => { + const row = Math.floor(rows / 2) + // Slide in from the left edge; reduced motion parks it on screen. + const onScreen = reduce + bx = [] + by = [] + fed = [] + occ.fill(0) + for (let k = 0; k < START_LENGTH; k++) { + const x = onScreen ? START_LENGTH - k : -1 - k + bx.push(x) + by.push(row) + fed.push(false) + if (inGrid(x, row)) occ[row * cols + x] = 1 + } + dir = 0 + nextX = bx[0]! + 1 + nextY = row + growing = false + acc = 0 + score = 0 + queue = [] + phase = onScreen ? "run" : "wait" + enterAt = now + ENTER_DELAY_MS + } + + const refill = (now: number) => { + const span = Math.max(1, cols + rows) + for (const i of glyph) { + if (food[i] === 1) continue + const x = i % cols + const y = (i / cols) | 0 + food[i] = 1 + born[i] = + now + ((x + y) / span) * REFILL_MS * 2 + (BAYER4[(y & 3) * 4 + (x & 3)]! / 16) * 140 + remaining++ + } + } + + const layout = (now: number) => { + cell = Math.max(4, Math.round(options.cellSize ?? 12)) + cols = Math.max(1, Math.floor(cssW / cell)) + rows = Math.max(1, Math.floor(cssH / cell)) + ox = Math.floor((cssW - cols * cell) / 2) + oy = Math.floor((cssH - rows * cell) / 2) + const n = cols * rows + food = new Uint8Array(n) + born = new Float64Array(n) + occ = new Uint8Array(n) + seen = new Uint32Array(n) + dist = new Int32Array(n) + bfs = new Int32Array(n + 4) + stamp = 0 + glyph = [] + remaining = 0 + refillAt = 0 + + const digits = codeDigits(options.code) + const bw = digits.length * DIGIT_W + (digits.length - 1) * DIGIT_GAP + const k = Math.max( + 1, + Math.floor(Math.min((cols * 0.74) / bw, (rows * 0.5) / DIGIT_H)) + ) + const gx0 = Math.floor((cols - bw * k) / 2) + const gy0 = Math.floor((rows - DIGIT_H * k) / 2) + for (let di = 0; di < digits.length; di++) { + const bitmap = DIGITS[digits[di]!]! + const left = gx0 + di * (DIGIT_W + DIGIT_GAP) * k + for (let r = 0; r < DIGIT_H; r++) { + for (let c = 0; c < DIGIT_W; c++) { + if (bitmap[r]![c] !== "#") continue + for (let sy = 0; sy < k; sy++) { + for (let sx = 0; sx < k; sx++) { + const x = left + c * k + sx + const y = gy0 + r * k + sy + if (!inGrid(x, y)) continue + const i = y * cols + x + food[i] = 1 + glyph.push(i) + remaining++ + } + } + } + } + } + if (playing) { + playing = false + options.onPlayChange?.(false) + } + spawn(now) + } + + /** Returns whether the backing store changed (which clears the canvas). */ + const resize = () => { + dpr = Math.min(window.devicePixelRatio || 1, 2) + const w = wrap.clientWidth + const h = wrap.clientHeight + if (w <= 0 || h <= 0) return false + cssW = w + cssH = h + const width = Math.max(1, Math.floor(w * dpr)) + const height = Math.max(1, Math.floor(h * dpr)) + const changed = width !== canvas.width || height !== canvas.height + if (changed) { + canvas.width = width + canvas.height = height + } + canvas.style.width = `${w}px` + canvas.style.height = `${h}px` + const c = Math.max(4, Math.round(options.cellSize ?? 12)) + const key = `${Math.floor(w / c)}x${Math.floor(h / c)}:${c}:${codeDigits(options.code)}` + if (key !== builtKey) { + builtKey = key + layout(performance.now()) + } + return changed + } + + const drawGhost = (ink: string) => { + const key = `${canvas.width}x${canvas.height}:${cols}x${rows}:${cell}:${ink}:${options.lcd}:${dark}` + if (key === ghostKey) return + ghostKey = key + ghost.width = canvas.width + ghost.height = canvas.height + const g = ghost.getContext("2d") + if (!g) return + g.setTransform(dpr, 0, 0, dpr, 0, 0) + g.clearRect(0, 0, cssW, cssH) + g.fillStyle = ink + g.globalAlpha = options.lcd ? 0.07 : dark ? 0.05 : 0.035 + const inset = Math.max(1, Math.round(cell * 0.16)) + const size = cell - inset * 2 + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) { + g.fillRect(ox + x * cell + inset, oy + y * cell + inset, size, size) + } + } + } + + const draw = (now: number, p: number) => { + const ink = inkOf() + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, cssW, cssH) + if (options.lcd) { + ctx.fillStyle = LCD_SCREEN + ctx.fillRect(0, 0, cssW, cssH) + } + if (options.grid) { + drawGhost(ink) + ctx.drawImage(ghost, 0, 0, cssW, cssH) + } + + ctx.fillStyle = ink + + // Food: one pellet per lit cell, popping in after a refill. + const inset = Math.max(1, Math.round(cell * 0.16)) + const pellet = cell - inset * 2 + for (const i of glyph) { + if (food[i] !== 1) continue + const t = (now - born[i]!) / POP_MS + if (t < 0) continue + const x = ox + (i % cols) * cell + const y = oy + ((i / cols) | 0) * cell + if (t >= 1) { + ctx.fillRect(x + inset, y + inset, pellet, pellet) + } else { + const s = pellet * easeOutBack(t) + const o = (cell - s) / 2 + ctx.fillRect(x + o, y + o, s, s) + } + } + + // Snake. + const dead = phase === "dead" + const blink = Math.floor((now - deadAt) / BLINK_MS) + const shown = !dead || (blink < BLINKS && blink % 2 === 1) + if (shown && bx.length > 0) { + // Body as thick as a pellet; a meal swells it to nearly a full cell. + const th = pellet + const half = th / 2 + const cx = (x: number) => ox + (x + 0.5) * cell + const cy = (y: number) => oy + (y + 0.5) * cell + const moving = phase === "run" + const t = moving ? p : 0 + const last = bx.length - 1 + + const hx = cx(bx[0]! + (moving ? (nextX - bx[0]!) * t : 0)) + const hy = cy(by[0]! + (moving ? (nextY - by[0]!) * t : 0)) + const tailT = moving && !growing ? t : 0 + const tx = cx(bx[last]! + (bx[Math.max(0, last - 1)]! - bx[last]!) * tailT) + const ty = cy(by[last]! + (by[Math.max(0, last - 1)]! - by[last]!) * tailT) + + const band = (ax: number, ay: number, bx2: number, by2: number) => { + ctx.fillRect( + Math.min(ax, bx2) - half, + Math.min(ay, by2) - half, + Math.abs(ax - bx2) + th, + Math.abs(ay - by2) + th + ) + } + + let px = hx + let py = hy + for (let k = 0; k < last; k++) { + const qx = cx(bx[k]!) + const qy = cy(by[k]!) + band(px, py, qx, qy) + px = qx + py = qy + } + band(px, py, tx, ty) + + // Swallowed pellets: a bulge that stays put while the body slides by. + const swell = cell - Math.max(1, Math.round(cell * 0.06)) * 2 + for (let k = 1; k <= last; k++) { + if (!fed[k] || (k === last && !growing)) continue + ctx.fillRect(cx(bx[k]!) - swell / 2, cy(by[k]!) - swell / 2, swell, swell) + } + + // Eye, and a mouth that opens when food is next. + const fx = DX[dir]! + const fy = DY[dir]! + const lx = fy + const ly = -fx + const cut = (x: number, y: number, w: number, h: number) => { + if (options.lcd) { + ctx.fillStyle = LCD_SCREEN + ctx.fillRect(x, y, w, h) + ctx.fillStyle = ink + return + } + ctx.globalCompositeOperation = "destination-out" + ctx.fillRect(x, y, w, h) + ctx.globalCompositeOperation = "source-over" + } + // The head is a full swell, so the eye and mouth have room. + ctx.fillRect(hx - swell / 2, hy - swell / 2, swell, swell) + const eye = Math.max(2, Math.round(cell * 0.2)) + const ex = hx - fx * swell * 0.06 + lx * swell * 0.22 + const ey = hy - fy * swell * 0.06 + ly * swell * 0.22 + cut(ex - eye / 2, ey - eye / 2, eye, eye) + if (moving && inGrid(nextX, nextY) && visibleFood(nextY * cols + nextX, now)) { + const w = Math.max(2, Math.round(swell * 0.36)) + const depth = Math.max(1, Math.round(swell * 0.3)) + const mx = hx + fx * (swell / 2 - depth / 2) + const my = hy + fy * (swell / 2 - depth / 2) + const mw = fx !== 0 ? depth : w + const mh = fx !== 0 ? w : depth + cut(mx - mw / 2, my - mh / 2, mw, mh) + } + } + + if (options.score) { + const dot = Math.max(2, Math.round(cell / 4)) + const digits = String(score).padStart(4, "0") + const x0 = ox + cell + const y0 = oy + cell + ctx.globalAlpha = 0.6 + for (let d = 0; d < digits.length; d++) { + const bits = SCORE_DIGITS[digits.charCodeAt(d) - 48]! + for (let b = 0; b < 15; b++) { + if (bits[b] !== "1") continue + ctx.fillRect( + x0 + d * dot * 4 + (b % 3) * dot, + y0 + Math.floor(b / 3) * dot, + dot, + dot + ) + } + } + ctx.globalAlpha = 1 + } + } + + const tick = (now: number) => { + if (!running) return + const dt = Math.min(100, now - lastNow) + lastNow = now + + if (refillAt && now >= refillAt) { + refillAt = 0 + refill(now) + } + if (phase === "wait" && now >= enterAt && !reduce) phase = "run" + if (phase === "dead" && now - deadAt >= DEATH_MS) spawn(now) + + const stepMs = 1000 / Math.max(1, options.speed ?? 14) + if (phase === "run" && (!reduce || playing)) { + acc += dt + while (acc >= stepMs && phase === "run") { + acc -= stepMs + commit(now) + plan(now) + } + } + const p = options.smooth ? Math.min(1, acc / stepMs) : 0 + draw(now, p) + + if (visible) raf = requestAnimationFrame(tick) + } + + const start = () => { + cancelAnimationFrame(raf) + lastNow = performance.now() + raf = requestAnimationFrame(tick) + } + + const steer = (d: Snake404Direction) => { + if (!options.playable || phase === "dead") return + if (!playing) { + playing = true + queue = [] + options.onPlayChange?.(true) + } + if (phase === "wait") phase = "run" + const last = queue.length ? queue[queue.length - 1]! : dir + if (d === last || d === (last + 2) % 4) return + if (queue.length < MAX_QUEUE) queue.push(d) + } + + const handBack = () => { + if (!playing) return + playing = false + queue = [] + options.onPlayChange?.(false) + } + + const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") + reduce = mqReduce.matches + const onReduce = () => { + const was = reduce + reduce = mqReduce.matches + if (reduce !== was && !playing) spawn(performance.now()) + } + mqReduce.addEventListener("change", onReduce) + + resize() + draw(performance.now(), 0) + + const ro = new ResizeObserver(() => { + const changed = resize() + if (changed && running) draw(performance.now(), 0) + }) + ro.observe(wrap) + + const io = new IntersectionObserver(([entry]) => { + const was = visible + visible = entry?.isIntersecting ?? true + if (visible && !was) start() + }) + io.observe(wrap) + + const syncTheme = () => { + dark = resolveDark(options.theme ?? "auto") + } + const mo = new MutationObserver(syncTheme) + mo.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "data-theme", "style"], + }) + const mqDark = window.matchMedia("(prefers-color-scheme: dark)") + mqDark.addEventListener("change", syncTheme) + + const onKey = (e: KeyboardEvent) => { + if (!options.playable || e.metaKey || e.ctrlKey || e.altKey) return + if (e.key === "Escape") { + if (playing) { + e.preventDefault() + handBack() + } + return + } + const d = KEYS[e.key] + if (d === undefined) return + e.preventDefault() + steer(d) + } + + let swipe: { id: number; x: number; y: number } | null = null + const onDown = (e: PointerEvent) => { + if (!options.playable) return + swipe = { id: e.pointerId, x: e.clientX, y: e.clientY } + } + const onUp = (e: PointerEvent) => { + if (!swipe || swipe.id !== e.pointerId) return + const dx = e.clientX - swipe.x + const dy = e.clientY - swipe.y + swipe = null + if (Math.max(Math.abs(dx), Math.abs(dy)) < SWIPE_PX) return + if (Math.abs(dx) > Math.abs(dy)) steer(dx > 0 ? 0 : 2) + else steer(dy > 0 ? 1 : 3) + } + const onCancel = () => { + swipe = null + } + + wrap.addEventListener("keydown", onKey) + wrap.addEventListener("focusout", handBack) + wrap.addEventListener("pointerdown", onDown) + wrap.addEventListener("pointerup", onUp) + wrap.addEventListener("pointercancel", onCancel) + + start() + + return { + setOptions(next) { + const prev = options + options = { ...options, ...next } + if ( + options.cellSize !== prev.cellSize || + codeDigits(options.code) !== codeDigits(prev.code) + ) { + builtKey = "" + resize() + } + if (options.theme !== prev.theme) syncTheme() + if (!options.playable) handBack() + if (!visible) draw(performance.now(), 0) + }, + steer, + destroy() { + running = false + cancelAnimationFrame(raf) + ro.disconnect() + io.disconnect() + mo.disconnect() + mqReduce.removeEventListener("change", onReduce) + mqDark.removeEventListener("change", syncTheme) + wrap.removeEventListener("keydown", onKey) + wrap.removeEventListener("focusout", handBack) + wrap.removeEventListener("pointerdown", onDown) + wrap.removeEventListener("pointerup", onUp) + wrap.removeEventListener("pointercancel", onCancel) + }, + } +} diff --git a/registry/snake-404/snake-404.tsx b/registry/snake-404/snake-404.tsx new file mode 100644 index 0000000..4e14971 --- /dev/null +++ b/registry/snake-404/snake-404.tsx @@ -0,0 +1,141 @@ +"use client" + +import { useEffect, useRef, useState } from "react" + +import { cn } from "@/lib/utils" + +import { + codeDigits, + createSnake404, + type Snake404Instance, + type Snake404Options, +} from "./snake-404-vanilla" + +export { + DARK_COLOR, + LCD_INK, + LCD_SCREEN, + LIGHT_COLOR, +} from "./snake-404-vanilla" +export type { + Snake404Direction, + Snake404Instance, + Snake404Options, + Snake404Theme, +} from "./snake-404-vanilla" + +export type Snake404Props = Snake404Options & { + /** Accessible name. Default `", page not found"`. */ + label?: string + className?: string +} + +/** + * A dot-matrix 404 where every dot is food. A Nokia-style snake eats its + * way through the digits, then they come back. Click and use the arrow + * keys, WASD, or swipe to play. + */ +export function Snake404({ + code = "404", + color, + cellSize = 12, + speed = 14, + maxLength = 32, + grid = true, + lcd = false, + playable = true, + score = true, + smooth = true, + theme = "auto", + onEat, + onClear, + onPlayChange, + label, + className, +}: Snake404Props) { + const wrapRef = useRef(null) + const canvasRef = useRef(null) + const instanceRef = useRef(null) + const callbacksRef = useRef({ onEat, onClear, onPlayChange }) + const [playing, setPlaying] = useState(false) + + useEffect(() => { + callbacksRef.current = { onEat, onClear, onPlayChange } + }, [onEat, onClear, onPlayChange]) + + const options = { + code, + color, + cellSize, + speed, + maxLength, + grid, + lcd, + playable, + score, + smooth, + theme, + } + + useEffect(() => { + const wrap = wrapRef.current + const canvas = canvasRef.current + if (!wrap || !canvas) return + instanceRef.current = createSnake404(wrap, canvas, { + ...options, + onEat: (left, total) => callbacksRef.current.onEat?.(left, total), + onClear: () => callbacksRef.current.onClear?.(), + onPlayChange: (next) => { + setPlaying(next) + callbacksRef.current.onPlayChange?.(next) + }, + }) + return () => { + instanceRef.current?.destroy() + instanceRef.current = null + } + // Engine reads live options via setOptions; mount once. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + instanceRef.current?.setOptions(options) + // `options` is rebuilt every render; its fields are the real deps. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + code, + color, + cellSize, + speed, + maxLength, + grid, + lcd, + playable, + score, + smooth, + theme, + ]) + + const name = label ?? `${codeDigits(code)}, page not found` + + return ( +
+ +
+ ) +} diff --git a/skills/23rd/SKILL.md b/skills/23rd/SKILL.md index d254159..b9e5598 100644 --- a/skills/23rd/SKILL.md +++ b/skills/23rd/SKILL.md @@ -73,7 +73,7 @@ flowchart TD pick -->|Atmosphere behind a hero| atmo{Mood} pick -->|Footer or page-end| foot{Treatment} pick -->|Reveal an image from a dither| ds[dither-scan] - pick -->|Full page 404| d404[dithered-404] + pick -->|Full page 404| p404{Treatment} pick -->|Scroll makes the page lean| folio[folio] pick -->|Character or wordmark| mark{Which} pick -->|Type text out letter by letter| tw[typewriter] @@ -89,6 +89,8 @@ flowchart TD atmo -->|Isometric figures built from tiles| il[iso-lattice] atmo -->|Falling sheet music| ps[phosphor-score] foot -->|Rubber overscroll aurora| st[stretchy-footer] + p404 -->|Burns under the cursor| d404[dithered-404] + p404 -->|Snake eats the digits, playable| s404[snake-404] mark -->|Eyes that track the pointer| orb[live-orb] mark -->|ASCII wordmark, click to scatter| al[ascii-logo] ``` @@ -101,6 +103,7 @@ Disambiguation that agents get wrong: | Soft gradient behind a headline | `shader-gradient` | `gooey-color-picker` | | Fire, heat under a hero | `shader-anime-fire` | `dithered-404` unless the page is a 404 | | 404 that burns under the cursor | `dithered-404` | `shader-anime-fire` | +| Snake game, Nokia, retro 404 or 500 you can play | `snake-404` | `dithered-404` (fire, not a game) | | Image that un-dithers, scan line, enhance or processing reveal | `dither-scan` | `dithered-404` (a 404 glyph, not your image) | | Overscroll rubber band, aurora at the bottom | `stretchy-footer` | `folio` | | Page leans while scrolling | `folio` | `stretchy-footer` (that one stretches, it does not tilt) | @@ -128,7 +131,7 @@ Backgrounds and shaders fill the parent. They do not create a page. ``` -Same shell for `ShaderAnimeFire`, `ShaderFire`, `ShaderSky`, `AsciiFluid`, `LogoBurst`, `PhosphorScore`, `IsoLattice`, and `Dithered404`. Give the parent a height. Put UI in a later stacking context (`relative z-10`). +Same shell for `ShaderAnimeFire`, `ShaderFire`, `ShaderSky`, `AsciiFluid`, `LogoBurst`, `PhosphorScore`, `IsoLattice`, `Dithered404`, and `Snake404`. Give the parent a height. Put UI in a later stacking context (`relative z-10`). `ShaderMetal` uses the same shell but is transparent: the ribbons and their shadows sit on the parent's background, so set one (`bg-background`). The ribbons cross the middle; put copy top-left. @@ -181,6 +184,7 @@ Categories match `content/docs/components/meta.json`. | `live-orb` | Characters | Lit sphere; eyes follow the pointer | | `ascii-logo` | Characters | ASCII wordmark: hover shove, click scatter / fall / gather | | `dithered-404` | Pages | Bayer 404 burned by a fireball cursor, then reforms | +| `snake-404` | Pages | Dot-matrix 404 eaten by a Nokia-style snake; arrow keys take over | | `folio` | Sections | Page leans on scroll, then springs flat | | `gooey-color-picker` | Components | Swatch opens into hue, alpha, and hex under an SVG goo filter | | `typewriter` | Components | A real typewriter in SVG: keys press, typebars strike, the carriage steps | diff --git a/skills/23rd/apis.md b/skills/23rd/apis.md index 51b14ab..2e04ec7 100644 --- a/skills/23rd/apis.md +++ b/skills/23rd/apis.md @@ -382,6 +382,31 @@ type Dithered404Props = { `LIGHT_COLOR` `#18181B`, `DARK_COLOR` `#E4E4E7`. `dither={false}` is soft fire on the same glyph. No `text` prop. Wrapper does not expose `onHideCursor`. +## snake-404 + +```ts +type Snake404Props = { + code?: string // "404", digits 0–9 only, up to 6 + color?: string // theme ink, or LCD ink when lcd + cellSize?: number // 12 CSS px + speed?: number // 14 cells per second + maxLength?: number // 32 + grid?: boolean // true, faint unlit cells + lcd?: boolean // false, green Nokia screen + playable?: boolean // true, arrow keys / WASD / swipe + score?: boolean // true, pellets eaten, top left + smooth?: boolean // true, glide between cells + theme?: "light" | "dark" | "auto" + onEat?: (left: number, total: number) => void + onClear?: () => void + onPlayChange?: (playing: boolean) => void + label?: string // "404, page not found" + className?: string +} +``` + +`LIGHT_COLOR` `#18181B`, `DARK_COLOR` `#E4E4E7`, `LCD_SCREEN` `#C7F0D8`, `LCD_INK` `#43523D`. Crashing into a wall or itself while playing blinks the snake and restarts autopilot. + ## dither-scan ```ts diff --git a/skills/23rd/components.md b/skills/23rd/components.md index 6d8304c..161e849 100644 --- a/skills/23rd/components.md +++ b/skills/23rd/components.md @@ -382,6 +382,34 @@ export function NotFound() { Pitfalls: `interactive` hides the system cursor while the fireball is active (`onHideCursor` exists on the vanilla options; the React wrapper owns that state and does not expose the callback as a prop). Ink follows theme: `#18181B` light, `#E4E4E7` dark. The glyph string is the 404 itself; there is no `text` prop. +## snake-404 + +Page. A dot-matrix "404" where every dot is a food pellet. A Nokia-style snake slides in and eats its way through the digits on autopilot; each meal rides down the body as a bulge, and the digits pop back once the last pellet is gone. + +Best fit: the not-found route, full viewport, when the page should be a small game. `code="500"` works for other error pages. + +Not this: a 404 that reacts to the cursor without a game (`dithered-404`). + +Deps: none. Key props: `code` ("404"), `color`, `cellSize` (12), `speed` (14), `maxLength` (32), `grid` (true), `lcd` (false), `playable` (true), `score` (true), `smooth` (true), `theme`. + +```tsx +"use client" +import { Snake404 } from "@/components/ui/snake-404" + +export function NotFound() { + return ( +
+ +
+

This page could not be found.

+
+
+ ) +} +``` + +Pitfalls: overlays must be `pointer-events-none` or clicks never focus the field. With `playable` the root is focusable and swallows arrow keys and WASD only while focused; Esc or blur hands back to autopilot. `playable` also sets `touch-none` so swipes steer instead of scrolling. Only digits 0–9 are drawn. `lcd` paints the canvas green, so `color` defaults to LCD ink there. + ## dither-scan AI component. An image that starts as a 1-bit ordered dither in theme ink and paper. A diagonal scan line sweeps it `passes` times; each pass shrinks the cells and brings back tones and color, and the last leaves the source image. diff --git a/skills/23rd/recipes.md b/skills/23rd/recipes.md index bd81917..e8fe12e 100644 --- a/skills/23rd/recipes.md +++ b/skills/23rd/recipes.md @@ -74,7 +74,7 @@ export function Story() { ## 404 -Use `dithered-404` on the not-found route. Do not also mount `shader-anime-fire`. +Use `dithered-404` on the not-found route. Do not also mount `shader-anime-fire`. For a playable page, swap in `snake-404` with the same shell and keep the overlay `pointer-events-none`. ```tsx "use client"