diff --git a/public/easter-egg/man.ogg b/public/easter-egg/man.ogg new file mode 100644 index 0000000..3303b64 Binary files /dev/null and b/public/easter-egg/man.ogg differ diff --git a/public/easter-egg/snd_egg.wav b/public/easter-egg/snd_egg.wav new file mode 100644 index 0000000..0f356d9 Binary files /dev/null and b/public/easter-egg/snd_egg.wav differ diff --git a/public/easter-egg/snd_text.wav b/public/easter-egg/snd_text.wav new file mode 100644 index 0000000..dac4718 Binary files /dev/null and b/public/easter-egg/snd_text.wav differ diff --git a/public/easter-egg/tree.gif b/public/easter-egg/tree.gif new file mode 100644 index 0000000..a27de11 Binary files /dev/null and b/public/easter-egg/tree.gif differ diff --git a/src/app/egg/page.tsx b/src/app/egg/page.tsx new file mode 100644 index 0000000..3cb10ad --- /dev/null +++ b/src/app/egg/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next" +import { ForgottenManRoom } from "@/components/easter-egg/room" + +export const metadata: Metadata = { + title: { absolute: "???" }, + robots: { index: false, follow: false }, +} + +export default function EggPage() { + return +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 62e5cb9..a88527d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import { Background } from "@/components/background" import { ConditionalFooter } from "@/components/conditional-footer" +import { ForgottenManTrigger } from "@/components/easter-egg/trigger" import { Footer } from "@/components/footer" import { Header } from "@/components/header/header" import { ThemeProvider } from "@/components/theme-provider" @@ -75,6 +76,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac disableTransitionOnChange >
+
{children} diff --git a/src/components/easter-egg/constants.ts b/src/components/easter-egg/constants.ts new file mode 100644 index 0000000..432ed12 --- /dev/null +++ b/src/components/easter-egg/constants.ts @@ -0,0 +1,71 @@ +import { matchesRoutePrefix } from "@/utils/route-match" +import type { DialogueStep, NavCategory } from "./types" + +// omaggio al Forgotten Man / Egg di Deltarune: stanza nascosta raggiungibile solo andando avanti e indietro +export const FORGOTTEN_MAN_PATH = "/egg" +export const TRIGGER_CHANCE = 0.02 +export const OBTAINED_STORAGE_KEY = "polinetwork:forgotten-man-egg" +// separato dal precedente: si imposta solo se l'uovo viene accettato +export const RECEIVED_STORAGE_KEY = "polinetwork:forgotten-man-egg-received" + +const HOME_PATH = "/" +const GROUPS_PREFIX = "/groups" + +export function getCategory(pathname: string): NavCategory { + if (pathname === HOME_PATH) return "home" + if (matchesRoutePrefix(pathname, GROUPS_PREFIX)) return "groups" + return "other" +} + +const ASSETS_DIR = "/easter-egg" +export const MUSIC_SRC = { ogg: `${ASSETS_DIR}/man.ogg`, mp3: `${ASSETS_DIR}/man.mp3` } +export const MUSIC_VOLUME = 0.7 +export const TEXT_SOUND_SRC = `${ASSETS_DIR}/snd_text.wav` +export const TEXT_SOUND_VOLUME = 0.8 +export const EGG_SOUND_SRC = `${ASSETS_DIR}/snd_egg.wav` +export const EGG_SOUND_VOLUME = 0.8 + +// tutte le misure della stanza sono in "pixel di gioco" (--px) e in tile da 20 pixel di gioco +export const TILE = 20 +export const TREE = { + src: `${ASSETS_DIR}/tree.gif`, + // dimensioni native della gif e posizione rispetto all'angolo in alto a sinistra della stanza + width: 106, + height: 92, + left: 20, + top: -21, +} + +export const COLORS = { + floor: "#612c61", + border: "#ffffff", + borderInner: "#9da2c4", + sparkle: "#aaffe6", + shadowTop: "#4646b0", + shadowBottom: "#a7a7c9", + soul: "#ff0000", +} + +export const TYPE_DELAY = 35 +export const PUNCTUATION_DELAY = 200 +export const DIALOGUE_START_DELAY = 1500 + +export const DIALOGUE: DialogueStep[] = [ + "(C'è qualcuno dietro l'albero.)", + "Ciao.", + "Ti ho visto. Avanti e indietro. Avanti e indietro.", + "Anch'io facevo così, una volta.", + "Poi, un giorno, la pagina non si è più caricata.", + "E adesso sono qui.", + "Ho un uovo.", + { + text: "Vuoi l'uovo?", + choices: [ + { label: "Sì", reply: ["Bene.", { text: "(Hai ricevuto un Uovo.)", event: "receive-egg" }] }, + { label: "No", reply: ["..."] }, + ], + }, +] + +// ciò che compare interagendo di nuovo dopo la fine del dialogo +export const EPILOGUE: DialogueStep[] = ["(Non c'è nessuno dietro l'albero.)"] diff --git a/src/components/easter-egg/dialogue-box.tsx b/src/components/easter-egg/dialogue-box.tsx new file mode 100644 index 0000000..bca4117 --- /dev/null +++ b/src/components/easter-egg/dialogue-box.tsx @@ -0,0 +1,121 @@ +import type { CSSProperties, Dispatch, ReactNode } from "react" +import { cn } from "@/lib/utils" +import { COLORS } from "./constants" +import type { DialogueAction, DialogueState } from "./dialogue" +import { stepChoices, stepText } from "./dialogue" +import { pixelFont } from "./font" +import { PixelSprite } from "./pixel-sprite" +import { CORNER_OFFSET, CORNER_PALETTE, CORNER_ROWS, CORNER_SIZE, SOUL_PALETTE, SOUL_ROWS } from "./sprites" + +// box nativo di Deltarune: 288x76 pixel di gioco, scalato a multipli interi per tenere i pixel nitidi +const BOX_PX = "max(2px, min(4px, round(down, min(90vw / 288, 32svh / 76), 1px)))" +const LINE_HEIGHT = 18 + +const px = (n: number) => `calc(${n} * var(--px))` + +const CORNERS = [ + "top-0 left-0", + "top-0 right-0 -scale-x-100", + "bottom-0 left-0 -scale-y-100", + "right-0 bottom-0 -scale-100", +] as const + +// ombra blu sfumata dall'alto verso il basso di ogni riga, come nel gioco +const SHADOW_STYLE: CSSProperties = { + translate: `${px(1)} ${px(1)}`, + // backgroundImage: `linear-gradient(${COLORS.shadowTop} ${px(5)}, ${COLORS.shadowBottom} ${px(13)})`, + backgroundSize: `100% ${px(LINE_HEIGHT)}`, +} + +function ShadowedText({ children }: { children: ReactNode }) { + return ( + + ) +} + +function TypedText({ text, typed }: { text: string; typed: number }) { + // il testo ancora da scrivere occupa già il suo spazio, così le parole non saltano a capo mentre compaiono + return ( + + {text.slice(0, typed)} + {text.slice(typed)} + + ) +} + +export function DialogueBox({ state, dispatch }: { state: DialogueState; dispatch: Dispatch }) { + const { open } = state + if (!open) return null + + const text = stepText(open.step) + const choices = open.typed >= text.length ? stepChoices(open.step) : null + + return ( +
+ {CORNERS.map((position) => ( + + ))} + +

+ {text} +

+
+ * + +
+ + {choices && ( +
+ {choices.map((choice, i) => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/components/easter-egg/dialogue.ts b/src/components/easter-egg/dialogue.ts new file mode 100644 index 0000000..a15c2f8 --- /dev/null +++ b/src/components/easter-egg/dialogue.ts @@ -0,0 +1,75 @@ +import { DIALOGUE, EPILOGUE } from "./constants" +import type { DialogueStep } from "./types" + +type OpenDialogue = { step: DialogueStep; queue: DialogueStep[]; typed: number; selected: number } +export type DialogueState = { open: OpenDialogue | null; finished: boolean } +export type DialogueAction = + | { type: "start" } + | { type: "tick" } + | { type: "confirm" } + | { type: "skip" } + | { type: "move" } + | { type: "select"; option: number } + | { type: "choose"; option: number } + +export const initialDialogueState: DialogueState = { open: null, finished: false } + +export function stepText(step: DialogueStep): string { + return typeof step === "string" ? step : step.text +} + +export function stepChoices(step: DialogueStep) { + return typeof step !== "string" && "choices" in step ? step.choices : null +} + +export function stepEvent(step: DialogueStep) { + return typeof step !== "string" && "event" in step ? step.event : null +} + +function openNext(queue: DialogueStep[]): OpenDialogue | null { + const [step, ...rest] = queue + return step === undefined ? null : { step, queue: rest, typed: 0, selected: 0 } +} + +function isTyped(open: OpenDialogue): boolean { + return open.typed >= stepText(open.step).length +} + +export function dialogueReducer(state: DialogueState, action: DialogueAction): DialogueState { + const { open } = state + + if (!open) { + if (action.type === "start" && !state.finished) return { ...state, open: openNext(DIALOGUE) } + // a dialogo finito, interagire di nuovo mostra l'epilogo + if (action.type === "confirm" && state.finished) return { ...state, open: openNext(EPILOGUE) } + return state + } + + const choices = isTyped(open) ? stepChoices(open.step) : null + + switch (action.type) { + case "tick": + return isTyped(open) ? state : { ...state, open: { ...open, typed: open.typed + 1 } } + case "skip": + return { ...state, open: { ...open, typed: stepText(open.step).length } } + case "move": + if (!choices) return state + return { ...state, open: { ...open, selected: (open.selected + 1) % choices.length } } + case "select": + if (!choices) return state + return { ...state, open: { ...open, selected: action.option } } + case "choose": { + const choice = choices?.[action.option] + if (!choice) return state + return { ...state, open: openNext([...choice.reply, ...open.queue]) } + } + case "confirm": { + if (!isTyped(open)) return dialogueReducer(state, { type: "skip" }) + if (choices) return dialogueReducer(state, { type: "choose", option: open.selected }) + const next = openNext(open.queue) + return { open: next, finished: state.finished || !next } + } + case "start": + return state + } +} diff --git a/src/components/easter-egg/font.ts b/src/components/easter-egg/font.ts new file mode 100644 index 0000000..8f4db4b --- /dev/null +++ b/src/components/easter-egg/font.ts @@ -0,0 +1,7 @@ +import { Press_Start_2P } from "next/font/google" + +// stesse metriche del font dei dialoghi di Deltarune: glifi 8x8 a spaziatura fissa +export const pixelFont = Press_Start_2P({ + weight: "400", + subsets: ["latin"], +}) diff --git a/src/components/easter-egg/pixel-sprite.tsx b/src/components/easter-egg/pixel-sprite.tsx new file mode 100644 index 0000000..d2dc785 --- /dev/null +++ b/src/components/easter-egg/pixel-sprite.tsx @@ -0,0 +1,39 @@ +import type { CSSProperties, ReactElement } from "react" + +type PixelSpriteProps = { + rows: readonly string[] + palette: Record + className?: string + style?: CSSProperties +} + +/** Disegna pixel art da una mappa di caratteri: ogni carattere è un pixel, colorato secondo `palette`. */ +export function PixelSprite({ rows, palette, className, style }: PixelSpriteProps) { + const width = rows[0]?.length ?? 0 + const rects: ReactElement[] = [] + + rows.forEach((row, y) => { + let x = 0 + while (x < row.length) { + const char = row.charAt(x) + let end = x + 1 + while (end < row.length && row[end] === char) end++ + const fill = palette[char] + // biome-ignore lint/suspicious/noArrayIndexKey: lo sprite è statico, le coordinate sono un id stabile + if (fill) rects.push() + x = end + } + }) + + return ( + + ) +} diff --git a/src/components/easter-egg/room.tsx b/src/components/easter-egg/room.tsx new file mode 100644 index 0000000..0ecf4a8 --- /dev/null +++ b/src/components/easter-egg/room.tsx @@ -0,0 +1,225 @@ +"use client" + +import Image from "next/image" +import type { CSSProperties } from "react" +import { useEffect, useReducer, useRef, useSyncExternalStore } from "react" +import NotFound from "@/app/not-found" +import { + COLORS, + DIALOGUE_START_DELAY, + EGG_SOUND_SRC, + EGG_SOUND_VOLUME, + MUSIC_SRC, + MUSIC_VOLUME, + PUNCTUATION_DELAY, + TEXT_SOUND_SRC, + TEXT_SOUND_VOLUME, + TILE, + TREE, + TYPE_DELAY, +} from "./constants" +import { dialogueReducer, initialDialogueState, stepEvent, stepText } from "./dialogue" +import { DialogueBox } from "./dialogue-box" +import { hasRoomTicket, markEggReceived, subscribeRoomTicket } from "./storage" + +// la stanza è larga ~8 tile e alta ~9.5: scala a multipli interi finché ci sta nello schermo +const ROOM_PX = "max(1px, round(down, min(100vw / 170, 100svh / 190), 1px))" + +const tiles = (n: number) => `calc(${n * TILE} * var(--px))` +const px = (n: number) => `calc(${n} * var(--px))` + +// pavimento in tile, rispetto all'angolo in alto a sinistra della parte larga della stanza +const FLOOR = [ + { x: 1, y: 0, w: 4, h: 1 }, + { x: 0, y: 1, w: 6, h: 3 }, + { x: 1, y: 4, w: 4, h: 1 }, +] as const + +const PUNCTUATION = new Set([".", ",", "?", "!"]) +// come nel gioco, spazi e punteggiatura non fanno suono +const VOICED = /[\p{L}\p{N}]/u +const CONFIRM_KEYS = new Set(["Enter", " ", "z", "Z"]) +const SKIP_KEYS = new Set(["x", "X", "Shift"]) +const MOVE_KEYS = new Set(["ArrowLeft", "ArrowRight", "a", "A", "d", "D"]) + +export function ForgottenManRoom() { + // null durante l'SSR/idratazione, poi true solo se ci si arriva dal trigger in navigazione + const allowed = useSyncExternalStore(subscribeRoomTicket, hasRoomTicket, () => null) + + if (allowed === null) return
+ if (!allowed) return + return +} + +function Room() { + const [state, dispatch] = useReducer(dialogueReducer, initialDialogueState) + const { open } = state + const soundsRef = useRoomAudio() + const step = open?.step + + useEffect(() => { + const timeout = setTimeout(() => dispatch({ type: "start" }), DIALOGUE_START_DELAY) + return () => clearTimeout(timeout) + }, []) + + useEffect(() => { + if (!open) return + const text = stepText(open.step) + if (open.typed >= text.length) return + const delay = PUNCTUATION.has(text[open.typed - 1] ?? "") ? PUNCTUATION_DELAY : TYPE_DELAY + const timeout = setTimeout(() => { + if (VOICED.test(text.charAt(open.typed))) soundsRef.current?.text() + dispatch({ type: "tick" }) + }, delay) + return () => clearTimeout(timeout) + }, [open, soundsRef]) + + useEffect(() => { + if (!step || stepEvent(step) !== "receive-egg") return + markEggReceived() + soundsRef.current?.egg() + }, [step, soundsRef]) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.repeat) return + if (CONFIRM_KEYS.has(event.key)) dispatch({ type: "confirm" }) + else if (SKIP_KEYS.has(event.key)) dispatch({ type: "skip" }) + else if (MOVE_KEYS.has(event.key)) dispatch({ type: "move" }) + else return + event.preventDefault() + } + window.addEventListener("keydown", onKeyDown) + return () => window.removeEventListener("keydown", onKeyDown) + }, []) + + useEffect(() => { + const { overflow } = document.body.style + document.body.style.overflow = "hidden" + return () => { + document.body.style.overflow = overflow + } + }, []) + + return ( + // biome-ignore lint/a11y/useKeyWithClickEvents lint/a11y/noStaticElementInteractions: la tastiera è gestita a livello di window +
dispatch({ type: "confirm" })} + > +