diff --git a/_games/gravedigger-dark-ritual.md b/_games/gravedigger-dark-ritual.md index 245f78c..b9393e4 100644 --- a/_games/gravedigger-dark-ritual.md +++ b/_games/gravedigger-dark-ritual.md @@ -5,7 +5,7 @@ coverImage: /assets/blog/gravedigger/cover.png bannerImage: /assets/blog/gravedigger/hero.png ogImage: url: /assets/blog/gravedigger/cover.png -releaseDate: "TBD" +date: "TBD" ctas: - label: "Play the Prototype" url: "https://dvdagames.itch.io/dark-ritual" diff --git a/_projects/hex-flower-engine.md b/_projects/hex-flower-engine.md new file mode 100644 index 0000000..704dd83 --- /dev/null +++ b/_projects/hex-flower-engine.md @@ -0,0 +1,24 @@ +--- +title: "Hex Flower Engine" +excerpt: An interactive implementation of Goblin's Henchman's Hex Flower Engine in React, using the better random number generator from our TypeScript dice rolling library for rolls that are more fairly distributed. +coverImage: /assets/blog/hex-flower-engine/hex-flower.png +bannerImage: /assets/blog/hex-flower-engine/hex-flower.gif +ogImage: + url: /assets/blog/hex-flower-engine/hex-flower.gif +date: 2020-07-11 +ctas: + - label: "Run the Engine" + url: "https://dvdagames.github.io/react-hex-flower-engine/" +price: "FREE" +--- +The [Hex Flower Engine](https://goblinshenchman.wordpress.com/2018/10/25/2d6-hex-power-flower/) is an ingenious invention from [Goblin's Henchman](https://goblinshenchman.wordpress.com/) that gives Game Masters (GMs) and Dungeon Masters (DMs) a way to generate random results that are more predictable and feel more realistic than a simple table or a single die roll. + +> A versatile game engine using 2D6 and a 19-Hex Flower (it’s like a random table, but with a memory). + +It relies on rolling `2d6` (two six-sided dice) and using the results to decide which direction to move in a grid of 19 hexagons laid out in an even larger hexagon. + +[Goblin's Henchman has written way more than I ever could describing the various use cases for and ideas behind the engine](https://goblinshenchman.wordpress.com/category/hex-flower/), so I'll leave that to them. + +This particular implementation of their ideas is a simple React-based web application hosted on GitHub Pages. The code is entirely open source, so you can easily fork it to tweak to your liking, or submit an Issue or Pull Request for some feature you would like to see. + +This was selfishly created for my own use as a Tempest Cleric in a D&D campaign where I wanted to know if there was ever an existing storm that I could use to power up my Call Lightning spell. The DM generously created a version of the Hex Flower Engine for us to use, so I built the campaign an interactive digital version we could rely on each session. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 167d2c0..e925b19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -755,9 +755,9 @@ "dev": true }, "node_modules/date-fns": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.3.1.tgz", - "integrity": "sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" diff --git a/public/assets/blog/hex-flower-engine/hex-flower.gif b/public/assets/blog/hex-flower-engine/hex-flower.gif new file mode 100644 index 0000000..192eb9c Binary files /dev/null and b/public/assets/blog/hex-flower-engine/hex-flower.gif differ diff --git a/public/assets/blog/hex-flower-engine/hex-flower.png b/public/assets/blog/hex-flower-engine/hex-flower.png new file mode 100644 index 0000000..3cbb8a3 Binary files /dev/null and b/public/assets/blog/hex-flower-engine/hex-flower.png differ diff --git a/src/app/_components/date-formatter.tsx b/src/app/_components/date-formatter.tsx index 10f7278..5b3d7ab 100644 --- a/src/app/_components/date-formatter.tsx +++ b/src/app/_components/date-formatter.tsx @@ -1,4 +1,4 @@ -import { parseISO, format } from "date-fns"; +import { format } from "date-fns/format"; type Props = { dateString: string; @@ -6,11 +6,13 @@ type Props = { const DateFormatter = ({ dateString }: Props) => { try { - const date = parseISO(dateString); + const date = format(dateString, "yyyy-MM-dd"); - return ; + return ; } catch (e) { - return {dateString}; + console.log("ERROR: cannot parse date"); + console.error(e); + return {dateString.toString()}; } }; diff --git a/src/app/_components/game-header.tsx b/src/app/_components/game-header.tsx index f4cb518..55c3039 100644 --- a/src/app/_components/game-header.tsx +++ b/src/app/_components/game-header.tsx @@ -1,7 +1,7 @@ import CoverImage from "./cover-image"; import DateFormatter from "./date-formatter"; import { PostTitle } from "@/app/_components/post-title"; -import { CTA } from "@/interfaces/game"; +import { CTA } from "@/interfaces/cta"; type Props = { title: string; diff --git a/src/app/_components/game-preview.tsx b/src/app/_components/game-preview.tsx index 4e2914f..e506519 100644 --- a/src/app/_components/game-preview.tsx +++ b/src/app/_components/game-preview.tsx @@ -1,5 +1,4 @@ -import { type Author } from "@/interfaces/author"; -import { type CTA } from "@/interfaces/game"; +import { type CTA } from "@/interfaces/cta"; import Link from "next/link"; import CoverImage from "./cover-image"; import DateFormatter from "./date-formatter"; diff --git a/src/app/_components/nav.tsx b/src/app/_components/nav.tsx index a04b233..939746e 100644 --- a/src/app/_components/nav.tsx +++ b/src/app/_components/nav.tsx @@ -18,7 +18,9 @@ export function Nav({ includeHome = true }: NavProps): React.ReactElement {
  • Games
  • -
  • Projects
  • +
  • + Projects +
  • About
  • diff --git a/src/app/_components/project-preview.tsx b/src/app/_components/project-preview.tsx new file mode 100644 index 0000000..d9e4d41 --- /dev/null +++ b/src/app/_components/project-preview.tsx @@ -0,0 +1,43 @@ +import { type CTA } from "@/interfaces/cta"; +import Link from "next/link"; +import CoverImage from "./cover-image"; +import DateFormatter from "./date-formatter"; + +type Props = { + title: string; + coverImage: string; + releaseDate: string; + excerpt: string; + slug: string; + price: string; + ctas: CTA[]; +}; + +export function ProjectPreview({ title, coverImage, releaseDate, excerpt, slug, price, ctas }: Props) { + return ( +
    +

    + + {title} + +

    +
    + +
    +
    + Release Date: +
    + {typeof ctas !== "undefined" && ( +
    + {ctas.length > 0 && + ctas.map((cta) => ( + + {cta.label} + + ))} +
    + )} +

    {excerpt}

    +
    + ); +} diff --git a/src/app/games/[slug]/page.tsx b/src/app/games/[slug]/page.tsx index 49cbd05..35a791d 100644 --- a/src/app/games/[slug]/page.tsx +++ b/src/app/games/[slug]/page.tsx @@ -7,7 +7,7 @@ import Header from "../../_components/header"; import { PostBody } from "../../_components/post-body"; import { GameHeader } from "../../_components/game-header"; -export default async function Post({ params }: Params) { +export default async function Game({ params }: Params) { const game = getGameBySlug(params.slug); if (!game) { @@ -21,7 +21,7 @@ export default async function Post({ params }: Params) {
    - +
    diff --git a/src/app/games/page.tsx b/src/app/games/page.tsx index 3ad8cd8..0d3a47e 100644 --- a/src/app/games/page.tsx +++ b/src/app/games/page.tsx @@ -5,7 +5,7 @@ import Container from "../_components/container"; import Header from "../_components/header"; import { GamePreview } from "../_components/game-preview"; -export default async function Post() { +export default async function Games() { const games = getAllGames(); if (!games) { @@ -24,7 +24,7 @@ export default async function Post() { key={game.slug} title={game.title} coverImage={game.coverImage} - releaseDate={game.releaseDate} + releaseDate={game.date} ctas={game.ctas} excerpt={game.excerpt} slug={game.slug} @@ -53,3 +53,11 @@ export function generateMetadata(): Metadata { }, }; } + +export async function generateStaticParams() { + const games = getAllGames(); + + return games.map((game) => ({ + slug: game.slug, + })); +} \ No newline at end of file diff --git a/src/app/projects/[slug]/page.tsx b/src/app/projects/[slug]/page.tsx new file mode 100644 index 0000000..1f92308 --- /dev/null +++ b/src/app/projects/[slug]/page.tsx @@ -0,0 +1,61 @@ +import { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { getAllProjects, getProjectBySlug } from "../../../lib/api"; +import markdownToHtml from "../../../lib/markdownToHtml"; +import Container from "../../_components/container"; +import Header from "../../_components/header"; +import { PostBody } from "../../_components/post-body"; +import { GameHeader } from "../../_components/game-header"; + +export default async function Project({ params }: Params) { + const project = getProjectBySlug(params.slug); + + if (!project) { + return notFound(); + } + + const content = await markdownToHtml(project.content || ""); + + return ( +
    + +
    +
    + + +
    + +
    + ); +} + +type Params = { + params: { + slug: string; + }; +}; + +export function generateMetadata({ params }: Params): Metadata { + const project = getProjectBySlug(params.slug); + + if (!project) { + return notFound(); + } + + const title = `${project.title} | DVDA Games`; + + return { + openGraph: { + title, + images: [project.ogImage.url], + }, + }; +} + +export async function generateStaticParams() { + const projects = getAllProjects(); + + return projects.map((project) => ({ + slug: project.slug, + })); +} diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx new file mode 100644 index 0000000..9129556 --- /dev/null +++ b/src/app/projects/page.tsx @@ -0,0 +1,63 @@ +import { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { getAllProjects } from "../../lib/api"; +import Container from "../_components/container"; +import Header from "../_components/header"; +import { ProjectPreview } from "../_components/project-preview"; + +export default async function Projects() { + const projects = getAllProjects(); + + if (!projects) { + return notFound(); + } + + return ( +
    + +
    +

    Our Projects

    +

    Some open-source tools we've created for you to use for free.

    +
    + {projects.map((project) => ( + + ))} +
    + +
    + ); +} + +export function generateMetadata(): Metadata { + const projects = getAllProjects(); + + if (!projects) { + return notFound(); + } + + const title = `Our Projects | DVDA Games`; + + return { + openGraph: { + title, + images: projects.map((project) => project.ogImage.url), + }, + }; +} + +export async function generateStaticParams() { + const projects = getAllProjects(); + + return projects.map((project) => ({ + slug: project.slug, + })); +} diff --git a/src/interfaces/content.ts b/src/interfaces/content.ts new file mode 100644 index 0000000..990305a --- /dev/null +++ b/src/interfaces/content.ts @@ -0,0 +1,7 @@ +import type { Game } from "./game"; +import type { Post } from "./post"; +import type { Project } from "./project"; + +export type ContentType = "post" | "game" | "project"; + +export type Content = Post | Game | Project; diff --git a/src/interfaces/cta.ts b/src/interfaces/cta.ts new file mode 100644 index 0000000..26557a8 --- /dev/null +++ b/src/interfaces/cta.ts @@ -0,0 +1,4 @@ +export type CTA = { + label: string; + url: string; +}; diff --git a/src/interfaces/game.ts b/src/interfaces/game.ts index 3f52eac..19be1a0 100644 --- a/src/interfaces/game.ts +++ b/src/interfaces/game.ts @@ -1,12 +1,9 @@ -export type CTA = { - label: string; - url: string; -}; +import type { CTA } from "./cta"; export type Game = { slug: string; title: string; - releaseDate: string; + date: string; price: string; coverImage: string; bannerImage: string; @@ -17,4 +14,5 @@ export type Game = { ctas: CTA[]; content: string; featured?: boolean; + archived?: boolean; }; diff --git a/src/interfaces/post.ts b/src/interfaces/post.ts index 1ca88ba..2df252e 100644 --- a/src/interfaces/post.ts +++ b/src/interfaces/post.ts @@ -13,4 +13,5 @@ export type Post = { content: string; preview?: boolean; featured?: boolean; + archived?: boolean; }; diff --git a/src/interfaces/project.ts b/src/interfaces/project.ts new file mode 100644 index 0000000..a43ce2b --- /dev/null +++ b/src/interfaces/project.ts @@ -0,0 +1,19 @@ +import type { CTA } from "./cta"; + +export type Project = { + slug: string; + title: string; + url: string; + date: string; + price: string; + coverImage: string; + bannerImage: string; + excerpt: string; + ogImage: { + url: string; + }; + ctas: CTA[]; + content: string; + featured?: boolean; + archived?: boolean; +}; diff --git a/src/lib/api.ts b/src/lib/api.ts index 60b09d8..c04c57e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,72 +1,86 @@ -import { Post } from "@/interfaces/post"; -import { Game } from "@/interfaces/game"; import fs from "fs"; import matter from "gray-matter"; import { join } from "path"; +import type { Post } from "@/interfaces/post"; +import type { Game } from "@/interfaces/game"; +import type { Project } from "@/interfaces/project"; +import type { ContentType, Content } from "@/interfaces/content"; + const postsDirectory = join(process.cwd(), "_posts"); const gamesDirectory = join(process.cwd(), "_games"); +const projectsDirectory = join(process.cwd(), "_projects"); -export function getPostSlugs() { - return fs.readdirSync(postsDirectory); -} +export function getContentDirectory(contentType: ContentType = "post"): string { + if (contentType === "game") { + return gamesDirectory; + } else if (contentType === "project") { + return projectsDirectory; + } -export function getGameSlugs() { - return fs.readdirSync(gamesDirectory); + return postsDirectory; } -export function getPostBySlug(slug: string): Post { - const realSlug = slug.replace(/\.md$/, ""); - const fullPath = join(postsDirectory, `${realSlug}.md`); - const fileContents = fs.readFileSync(fullPath, "utf8"); - const { data, content } = matter(fileContents); +export function getContentSlugs(contentType: ContentType = "post"): string[] { + let directory = getContentDirectory(contentType); - if (typeof data?.date?.toISOString !== "undefined") { - data.date = data.date.toISOString(); - } - - return { ...data, slug: realSlug, content } as Post; + return fs.readdirSync(directory); } -export function getGameBySlug(slug: string): Game { +export function getContentBySlug(slug: string, contentType: ContentType = "post"): T { const realSlug = slug.replace(/\.md$/, ""); - const fullPath = join(gamesDirectory, `${realSlug}.md`); + const directory = getContentDirectory(contentType); + const fullPath = join(directory, `${realSlug}.md`); + const fileContents = fs.readFileSync(fullPath, "utf8"); const { data, content } = matter(fileContents); - return { ...data, slug: realSlug, content } as Game; + return { ...data, slug: realSlug, content } as T; } -export function getAllPosts(): Post[] { - const slugs = getPostSlugs(); +export function getAllContent(contentType: ContentType = "post", includeArchive = false): T[] { + const slugs = getContentSlugs(contentType); + + const allContent: Content[] = slugs.map((slug) => getContentBySlug(slug, contentType)); + + const featuredContent = allContent + .filter((item) => item.featured && !item.archived) + .sort((item1, item2) => (item1.date > item2.date ? -1 : 1)); - const featuredPosts = slugs - .map((slug) => getPostBySlug(slug)) - .filter((post) => post.featured) - .sort((post1, post2) => (post1.date > post2.date ? -1 : 1)); + let content = allContent.filter((item) => !item.featured && !item.archived); - const posts = slugs - .map((slug) => getPostBySlug(slug)) - .filter((post) => !post.featured) - // sort posts by date in descending order - .sort((post1, post2) => (post1.date > post2.date ? -1 : 1)); + let archive: Content[] = []; - return [...featuredPosts, ...posts]; + if (includeArchive) { + archive = allContent.filter((item) => typeof item?.archived !== "undefined" && item?.archived); + } + + // sort posts by date in descending order + content.sort((item1, item2) => (item1.date > item2.date ? -1 : 1)); + + return [...(featuredContent as T[]), ...(content as T[]), ...(archive as T[])]; +} + +export function getAllPosts(includeArchive = false): Post[] { + return getAllContent("post", includeArchive) as Post[]; +} + +export function getPostBySlug(slug: string): Post { + return getContentBySlug(slug, "post"); } -export function getAllGames(): Game[] { - const slugs = getGameSlugs(); +export function getAllGames(includeArchive = false): Game[] { + return getAllContent("game", includeArchive) as Game[]; +} - const featuredGames = slugs - .map((slug) => getGameBySlug(slug)) - .filter((game) => game.featured) - .sort((game1, game2) => (game1.releaseDate > game2.releaseDate ? -1 : 1)); +export function getGameBySlug(slug: string): Game { + return getContentBySlug(slug, "game"); +} - const games = slugs - .map((slug) => getGameBySlug(slug)) - .filter((game) => !game.featured) - // sort games by date in descending order - .sort((game1, game2) => (game1.releaseDate > game2.releaseDate ? -1 : 1)); +export function getAllProjects(includeArchive = false): Project[] { + return getAllContent("project", includeArchive) as Project[]; +} - return [...featuredGames, ...games]; +export function getProjectBySlug(slug: string): Project { + return getContentBySlug(slug, "project"); }