Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/easter-egg/man.ogg
Binary file not shown.
Binary file added public/easter-egg/snd_egg.wav
Binary file not shown.
Binary file added public/easter-egg/snd_text.wav
Binary file not shown.
Binary file added public/easter-egg/tree.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions src/app/egg/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <ForgottenManRoom />
}
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -75,6 +76,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
disableTransitionOnChange
>
<div className="relative isolate flex min-h-screen w-full flex-col items-center justify-start overflow-clip">
<ForgottenManTrigger />
<Background />
<Header />
{children}
Expand Down
71 changes: 71 additions & 0 deletions src/components/easter-egg/constants.ts
Original file line number Diff line number Diff line change
@@ -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.)"]
121 changes: 121 additions & 0 deletions src/components/easter-egg/dialogue-box.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span aria-hidden="true" className="grid">
<span className="col-start-1 row-start-1 bg-clip-text text-transparent" style={SHADOW_STYLE}>
{children}
</span>
<span className="col-start-1 row-start-1">{children}</span>
</span>
)
}

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 (
<ShadowedText>
{text.slice(0, typed)}
<span className="invisible">{text.slice(typed)}</span>
</ShadowedText>
)
}

export function DialogueBox({ state, dispatch }: { state: DialogueState; dispatch: Dispatch<DialogueAction> }) {
const { open } = state
if (!open) return null

const text = stepText(open.step)
const choices = open.typed >= text.length ? stepChoices(open.step) : null

return (
<section
aria-label="Dialogo"
className={cn(pixelFont.className, "-translate-x-1/2 absolute left-1/2 bg-black text-white")}
style={
{
"--px": BOX_PX,
bottom: `calc(${px(CORNER_OFFSET)} + env(safe-area-inset-bottom))`,
width: `min(${px(288)}, calc(100vw - ${px(2 * CORNER_OFFSET)}))`,
minHeight: px(76),
border: `${px(2)} solid ${COLORS.border}`,
boxShadow: `inset 0 0 0 ${px(1)} ${COLORS.borderInner}`,
padding: `${px(10)} ${px(4)} ${px(10)} ${px(11)}`,
fontSize: px(8),
lineHeight: px(LINE_HEIGHT),
} as CSSProperties
}
>
{CORNERS.map((position) => (
<PixelSprite
key={position}
rows={CORNER_ROWS}
palette={CORNER_PALETTE}
className={cn("absolute", position)}
style={{ width: px(CORNER_SIZE), height: px(CORNER_SIZE), margin: px(-(CORNER_OFFSET + 2)) }}
/>
))}

<p className="sr-only" aria-live="polite">
{text}
</p>
<div className="wrap-break-word grid" style={{ gridTemplateColumns: `${px(16)} 1fr` }}>
<ShadowedText>*</ShadowedText>
<TypedText text={text} typed={open.typed} />
</div>

{choices && (
<div className="grid grid-cols-2" style={{ marginTop: px(6) }}>
{choices.map((choice, i) => (
<button
key={choice.label}
type="button"
className="flex items-center justify-center"
style={{ gap: px(4) }}
onPointerEnter={() => dispatch({ type: "select", option: i })}
onClick={(event) => {
event.stopPropagation()
dispatch({ type: "choose", option: i })
}}
>
<PixelSprite
rows={SOUL_ROWS}
palette={SOUL_PALETTE}
className={cn(open.selected !== i && "invisible")}
style={{ width: px(9), height: px(8) }}
/>
<ShadowedText>{choice.label}</ShadowedText>
</button>
))}
</div>
)}
</section>
)
}
75 changes: 75 additions & 0 deletions src/components/easter-egg/dialogue.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
7 changes: 7 additions & 0 deletions src/components/easter-egg/font.ts
Original file line number Diff line number Diff line change
@@ -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"],
})
39 changes: 39 additions & 0 deletions src/components/easter-egg/pixel-sprite.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { CSSProperties, ReactElement } from "react"

type PixelSpriteProps = {
rows: readonly string[]
palette: Record<string, string>
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(<rect key={`${x}-${y}`} x={x} y={y} width={end - x} height={1} fill={fill} />)
x = end
}
})

return (
<svg
viewBox={`0 0 ${width} ${rows.length}`}
shapeRendering="crispEdges"
aria-hidden="true"
className={className}
style={style}
>
{rects}
</svg>
)
}
Loading
Loading