Skip to content
Merged
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
11 changes: 10 additions & 1 deletion apps/game/src/components/home/play-poster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Reveal } from "@/components/ui/reveal"
import { useIsDesktop } from "@/hooks/use-is-desktop"
import { COMPUTER_GAME, usePlayActions } from "@/hooks/use-play-actions"
import { BOT_LEVELS } from "@/i18n/bots"
import { DEFAULT_LANGUAGE } from "@/i18n/locale-path"

/**
* The front door for anyone without an account: what the game is on one side,
Expand Down Expand Up @@ -40,7 +41,7 @@ const TEXT_LINK_CLASS =
"inline-flex min-h-11 min-w-11 items-center justify-center rounded-sm px-1.5 lg:px-2.5 text-brand-lighter underline underline-offset-[3px] transition-colors duration-200 ease-out hover:text-brand-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"

export function PlayPoster() {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const { playOnline, playOffline } = usePlayActions()
//the eight faces are eight pictures, and a phone never shows them: mounted
//behind `hidden` they are eight requests spent on nothing
Expand Down Expand Up @@ -109,6 +110,14 @@ export function PlayPoster() {
two. One label per link rather than a short and a long one swapped
by width: both would be in the served text. */}
<p className="flex flex-wrap items-center justify-center text-sm text-faint max-lg:mt-2 lg:mt-4 lg:-ms-2.5 lg:justify-start">
{i18n.language === DEFAULT_LANGUAGE && (
<>
<Link to="/how-to-play" className={TEXT_LINK_CLASS}>
{t("common:home.how_to_play_link")}
</Link>
<span aria-hidden="true">·</span>
</>
)}
<Link to="/rules" className={TEXT_LINK_CLASS}>
{t("common:nav.rules")}
</Link>
Expand Down
1 change: 1 addition & 0 deletions apps/game/src/components/manual/related-guides.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"
/** The guides, in reading order, each named by its page's own heading. */
const GUIDES = [
["/rules", "game:rules.heading"],
["/how-to-play", "game:howto.heading"],
["/strategy", "game:strategy.heading"],
["/opening-moves", "game:openings.heading"],
["/starting-positions", "game:positions.heading"],
Expand Down
3 changes: 2 additions & 1 deletion apps/game/src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@
}
}
},
"pause_demo": "Pause demo"
"pause_demo": "Pause demo",
"how_to_play_link": "How to play"
},
"auth": {
"sign_in": "Sign in",
Expand Down
86 changes: 85 additions & 1 deletion apps/game/src/i18n/locales/en/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -516,5 +516,89 @@
"done": "Got it",
"skip": "Skip"
},
"related_guides": "Related guides"
"related_guides": "Related guides",
"howto": {
"title": "How to play",
"heading": "How to play Abalone",
"intro": "To play Abalone, take turns with your opponent and move one, two or three of your marbles one space. A longer line of your marbles can push a shorter line of theirs, and the first player to push six of their marbles off the board wins. Below are the rules in five steps and a first game you can play now, <offline>free against the computer</offline>. For the full detail, see the <rules>Abalone rules</rules>.",
"steps": {
"title": "The rules in five steps",
"setup": {
"title": "Start with 14 marbles each",
"body": "Each player has 14 marbles on a hexagon board. Black moves first, then you take turns. You cannot pass."
},
"move": {
"title": "Move up to three marbles",
"body": "On your turn, move one, two or three of your own marbles one space to a neighbouring space. Two or three marbles must sit in a straight line and move together."
},
"kinds": {
"title": "Choose an in-line or a side-step move",
"body": "An in-line move sends the group along the line it points. A side step slides the group sideways, and every space it moves into must be empty."
},
"push": {
"title": "Push with more marbles",
"body": "Move in line into your opponent's marbles to push them, if you have more marbles in the line than they do: 2 push 1, 3 push 1 and 3 push 2. The space behind them must be empty or off the board. Equal numbers never push."
},
"win": {
"title": "Push six marbles off to win",
"body": "A marble pushed past the edge is out of the game. The first player to push six of the opponent's marbles off wins."
}
},
"first_game": {
"title": "Your first game, move by move",
"intro": "Press the button below to open a game against the computer. This is how the first minutes go.",
"steps": {
"open": {
"title": "Open the game",
"body": "Press Play against the computer. The board opens ready to play, with no account and nothing to download."
},
"pick": {
"title": "Pick your marbles",
"body": "On your turn, tap up to three of your marbles that sit in a line."
},
"direction": {
"title": "Choose where they go",
"body": "Tap one of the highlighted spaces. Your marbles move there and the computer replies."
},
"stuck": {
"title": "Ask for help when you are stuck",
"body": "The Hint button shows a suggested move, and Undo takes back your last move."
},
"push": {
"title": "Look for your first push",
"body": "Once the armies touch, line up more marbles than your opponent has in a row and push. Keep your own marbles together so they cannot do the same to you."
}
}
},
"faq": {
"title": "Questions beginners ask",
"items": {
"start": {
"question": "How do you start a game of Abalone?",
"answer": "Each player puts 14 marbles on their side of the board. Black goes first, then you take turns, one move each."
},
"first_move": {
"question": "What is a good first move in Abalone?",
"answer": "Move a short row from the front toward the center and keep your marbles touching. Do not send one marble alone ahead of the rest."
},
"win": {
"question": "How do you win at Abalone?",
"answer": "Push six of your opponent's marbles off the edge of the board. Trapping marbles does not win the game."
},
"free": {
"question": "Can I learn to play Abalone online for free?",
"answer": "Yes. Play against the computer or share one screen with a friend in your browser, with no account and no download."
}
}
},
"try": {
"title": "Play your first game",
"more_reading": "Done with the basics? Read the <openings>opening moves for beginners</openings> and the <strategy>strategy tips</strategy>."
},
"play": {
"cta": "Play against the computer",
"more": "Other ways to play"
},
"rules_link": "New to the game? Follow <howto>how to play Abalone</howto> step by step."
}
}
1 change: 1 addition & 0 deletions apps/game/src/routing/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const routes = rootRoute([
layout("subpage", "layouts/subpage.layout.tsx", [
route("/rules", "pages/rules.page.tsx"),
route("/strategy", "pages/strategy.page.tsx"),
route("/how-to-play", "pages/how-to-play.page.tsx"),
route("/opening-moves", "pages/opening-moves.page.tsx"),
route("/play-online", "pages/play-online.page.tsx"),
route("/starting-positions", "pages/starting-positions.page.tsx"),
Expand Down
142 changes: 142 additions & 0 deletions apps/game/src/routing/pages/how-to-play.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { createFileRoute, Link } from "@tanstack/react-router"
import { Trans, useTranslation } from "react-i18next"
import type { ManualChapter } from "@/components/manual/chapter"
import { Chapter } from "@/components/manual/chapter"
import { FaqList } from "@/components/manual/faq-list"
import { ManualHero, ManualPage } from "@/components/manual/manual-page"
import { RelatedGuides } from "@/components/manual/related-guides"
import { PlayBlock } from "@/components/play-block"
import { SubpageHeader } from "@/components/ui/subpage-header"
import { COMPUTER_GAME } from "@/hooks/use-play-actions"
import { routeLanguage } from "@/routing/route-language"
import { seoPageHead } from "@/routing/seo-pages"

export const Route = createFileRoute("/_subpage/how-to-play")({
head: ({ match }) =>
seoPageHead("/how-to-play", routeLanguage(match.context)),
component: HowToPlayPage,
})

/**
* How to play, for somebody who has never played: the rules in five steps, a
* first game against the computer, and a few beginner questions. Built like the
* other guides, with link targets for each step (/how-to-play#push) that the
* HowTo markup points at (`structured-data.ts`).
*/

/** The rule steps, in the order the HowTo markup lists them. */
const RULE_STEPS = ["setup", "move", "kinds", "push", "win"] as const

const GAME_STEPS = ["open", "pick", "direction", "stuck", "push"] as const

const FAQ_ITEMS = ["start", "first_move", "win", "free"] as const

const TEXT_LINK_CLASS =
"font-semibold text-brand-light underline underline-offset-2 hover:text-white"

const STEP_LIST_CLASS =
"flex list-decimal flex-col gap-y-3 pl-6 marker:font-semibold marker:text-faint"

function HowToPlayPage() {
const { t } = useTranslation()

const chapters: ManualChapter[] = [
{ id: "rules-in-five-steps", title: t("game:howto.steps.title") },
{ id: "first-game", title: t("game:howto.first_game.title") },
{ id: "faq", title: t("game:howto.faq.title") },
{ id: "play", title: t("game:howto.try.title") },
]

const playBlock = (
<PlayBlock
cta={t("game:howto.play.cta")}
more={t("game:howto.play.more")}
/>
)

return (
<>
{/* The page's own heading is on a phone too, so the bar's title is a
label: one `<h1>` on the page. */}
<SubpageHeader title={t("game:howto.title")} titleAs="p" />

<ManualPage
title={t("game:howto.title")}
chapters={chapters}
hero={
<ManualHero
heading={t("game:howto.heading")}
intro={
<Trans
i18nKey="game:howto.intro"
components={{
offline: (
<Link {...COMPUTER_GAME} className={TEXT_LINK_CLASS} />
),
rules: <Link to="/rules" className={TEXT_LINK_CLASS} />,
}}
/>
}
actions={playBlock}
/>
}
>
<Chapter {...chapters[0]}>
<ol className={STEP_LIST_CLASS}>
{RULE_STEPS.map((step) => (
<li key={step} id={step} className="scroll-mt-6 pl-1">
<strong className="font-semibold text-white">
{t(`game:howto.steps.${step}.title`)}.
</strong>{" "}
{t(`game:howto.steps.${step}.body`)}
</li>
))}
</ol>
</Chapter>

<Chapter {...chapters[1]}>
<p>{t("game:howto.first_game.intro")}</p>
<ol className={STEP_LIST_CLASS}>
{GAME_STEPS.map((step) => (
<li key={step} className="pl-1">
<strong className="font-semibold text-white">
{t(`game:howto.first_game.steps.${step}.title`)}.
</strong>{" "}
{t(`game:howto.first_game.steps.${step}.body`)}
</li>
))}
</ol>
</Chapter>

<Chapter {...chapters[2]}>
<FaqList
items={FAQ_ITEMS.map((item) => ({
id: item,
question: t(`game:howto.faq.items.${item}.question`),
answer: t(`game:howto.faq.items.${item}.answer`),
}))}
/>
</Chapter>

<Chapter {...chapters[3]}>
{playBlock}
<p>
<Trans
i18nKey="game:howto.try.more_reading"
components={{
openings: (
<Link to="/opening-moves" className={TEXT_LINK_CLASS} />
),
strategy: (
<Link to="/strategy" className={TEXT_LINK_CLASS} />
),
}}
/>
</p>
</Chapter>

<RelatedGuides current="/how-to-play" />
</ManualPage>
</>
)
}
15 changes: 14 additions & 1 deletion apps/game/src/routing/pages/rules.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { RuleDiagram } from "@/components/rule-diagram"
import { SubpageHeader } from "@/components/ui/subpage-header"
import { useMarbleDesign } from "@/hooks/use-marble-design"
import { COMPUTER_GAME } from "@/hooks/use-play-actions"
import { DEFAULT_LANGUAGE } from "@/i18n/locale-path"
import type { Diagram } from "@/render/draw-diagram"
import { START } from "@/render/start-diagram"
import { routeLanguage } from "@/routing/route-language"
Expand Down Expand Up @@ -209,7 +210,7 @@ const FAQ_ITEMS = [
] as const

function RulesPage() {
const { t } = useTranslation()
const { t, i18n } = useTranslation()

// The diagrams are the player's own marbles, not a stock illustration.
const [marbleDesign] = useMarbleDesign()
Expand Down Expand Up @@ -471,6 +472,18 @@ function RulesPage() {
),
}))}
/>
{i18n.language === DEFAULT_LANGUAGE && (
<p>
<Trans
i18nKey="game:howto.rules_link"
components={{
howto: (
<Link to="/how-to-play" className={TEXT_LINK_CLASS} />
),
}}
/>
</p>
)}
<p>
<Trans
i18nKey="game:rules.faq.strategy_link"
Expand Down
1 change: 1 addition & 0 deletions apps/game/src/routing/seo-locales.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const BODY_COPY: Record<IndexablePath, [namespace: string, key: string]> =
"/rules": ["game", "rules"],
"/strategy": ["game", "strategy"],
"/offline": ["game", "offline"],
"/how-to-play": ["game", "howto"],
"/opening-moves": ["game", "openings"],
"/starting-positions": ["game", "positions"],
"/play-online": ["game", "playOnline"],
Expand Down
7 changes: 7 additions & 0 deletions apps/game/src/routing/seo-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ const EXPECTED_COPY = {
description:
"Seven Abalone strategy tips: control the center, keep groups of three, pick a safe opening and push at the right moment. Try them against the computer.",
},
"/how-to-play": {
title: "How to play Abalone: rules in 5 steps and a first game",
description:
"How to play Abalone: the rules in five plain steps, a first game move by move and how to win. Then play the computer free, no account needed.",
},
"/opening-moves": {
title: "Abalone opening moves: a guide for beginners",
description:
Expand Down Expand Up @@ -87,6 +92,7 @@ describe("SITEMAP", () => {
expect(Object.keys(SITEMAP).sort()).toEqual(
[
"/",
"/how-to-play",
"/opening-moves",
"/play-online",
"/rules",
Expand All @@ -104,6 +110,7 @@ describe("SITEMAP", () => {
"/rules": "2026-11-02",
"/strategy": "2026-11-02",
"/play-online": "2026-11-01",
"/how-to-play": "2026-11-02",
"/opening-moves": "2026-11-02",
"/starting-positions": "2026-11-02",
})
Expand Down
Loading
Loading