diff --git a/src/App.css b/src/App.css index 92bbd8d..92cf44c 100644 --- a/src/App.css +++ b/src/App.css @@ -17,8 +17,8 @@ padding: clamp(10px, 2vw, 22px); } -/* The title on the left, the sound on the right, the state of play under - both -- so the toggle is findable without taking a line of its own. */ +/* The title on the left, the ways out on the right, the state of play under + both -- so none of them takes a line of its own. */ header { grid-area: head; display: grid; @@ -27,6 +27,38 @@ header { column-gap: 12px; } +/* + * Leaving, and the sound. Wrapping rather than shrinking, because on a narrow + * window the alternative is three items squeezing the title off its own line. + */ +header .ways { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: baseline; + gap: 6px 10px; +} + +header .ways a { + font-size: 0.85rem; + font-weight: 700; + color: #7fc4e8; +} + +/* Red like the resign button, and for the same reason: this one cannot be + taken back either. */ +.leave { + font: inherit; + font-weight: 700; + font-size: 0.85rem; + color: #ffb3a7; + background: #2c3b57; + border: 2px solid #2b2318; + border-radius: 6px; + padding: 3px 10px; + cursor: pointer; +} + h1 { margin: 0; font-size: clamp(1.4rem, 3.2vw, 2.2rem); @@ -520,3 +552,43 @@ header select { font-size: 0.76rem; color: #b99a4e; } + +/* --------------------------------------------------------- the ending */ + +/* + * The panel that ends the game. + * + * It takes the place the order panel had, which is where a player's eye + * already is when the last turn resolves, and it carries the only two things + * left to do: start another one, or leave. Bordered rather than merely + * spaced, because everything else in this column is a thing you are being + * asked to do and this one is a thing that has happened. + */ +.over { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + padding: 12px; + border: 2px solid #2b2318; + border-radius: 8px; + background: #22304a; +} + +.over h2 { margin: 0; font-size: 1rem; color: #ffd479; } +.over p { margin: 0; font-size: 0.84rem; line-height: 1.45; color: #cbd6e6; } + +.over .again { + font: inherit; + font-size: 0.9rem; + font-weight: 800; + color: #241a10; + background: #ffd479; + border: 2px solid #2b2318; + border-radius: 7px; + padding: 6px 16px; + cursor: pointer; +} + +.over .ways { font-size: 0.82rem; } +.over .ways a { color: #7fc4e8; } diff --git a/src/App.tsx b/src/App.tsx index 718ca0d..9bb61d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,7 +25,7 @@ import { reachableFrom } from './game/layout' import { POWERS, PROVINCES, base, type Power } from './game/map' import { canStep, validate, type Order, type Unit } from './game/orders' import type { Proposal } from './game/press' -import { endingSounded, mergeCues, type Cue } from './game/sound' +import { endingSounded, mergeCues, type Cue, type Ending } from './game/sound' import { convoyDestinations, convoyTargets, @@ -56,6 +56,7 @@ export default function App() { const [started, setStarted] = useState(false) const [said, setSaid] = useState([]) const [sure, setSure] = useState(false) + const [leaving, setLeaving] = useState(false) // ------------------------------------------------------------ audio glue @@ -328,6 +329,7 @@ export default function App() { // one button here that cannot be taken back. if (!sure) { setSure(true) + setLeaving(false) if (synth.sfxOn) synth.reject() return } @@ -339,6 +341,7 @@ export default function App() { const askDraw = () => { setSure(false) + setLeaving(false) if (synth.sfxOn) synth.telegraph(false) const { game: next, verdicts } = askForDraw(game, power) setGame(next) @@ -347,6 +350,7 @@ export default function App() { const askConcede = () => { setSure(false) + setLeaving(false) const leader = POWERS.reduce((best, p) => centreCount(own, p) > centreCount(own, best) ? p : best, ) @@ -361,9 +365,61 @@ export default function App() { setBuilds([]) } + /** + * Back to the landing page, with nothing carried over. + * + * The refs matter as much as the state does. `ended` is what stops the + * closing music playing twice, and `talked` is the turn the powers have + * already negotiated -- and a fresh game starts at the same turn as the + * last one did, so a stale note there would have the second game played in + * silence by six powers who never spoke. `woken` is deliberately left + * alone: the audio context is open and does not need opening again. + */ + const restart = useCallback(() => { + ended.current = false + talked.current = '' + setGame(newGame()) + setAsked([]) + setStep({ kind: 'idle' }) + setOrders(new Map()) + setRetreats(new Map()) + setBuilds([]) + setSaid([]) + setSure(false) + setLeaving(false) + setStarted(false) + if (synth.sfxOn) synth.tap() + }, []) + + /** + * Out of a game that is still being played. + * + * Asked twice, like the resign button it sits across the screen from, and + * for the same reason: it throws away a game that cannot be got back, and + * there is no browser dialog on the way to catch a misclick. The armed + * wording says what is actually lost rather than asking whether you are + * sure, which tells nobody anything. + * + * The two confirmations disarm each other. Half-pressing this and then + * reaching for Resign should not find Resign already waiting on its second + * click. + */ + const leave = () => { + if (!leaving) { + setLeaving(true) + setSure(false) + if (synth.sfxOn) synth.reject() + return + } + restart() + } + const mine = [...units.entries()].filter(([, u]) => u.power === power) const season = game.season === 'spring' ? 'Spring' : 'Autumn' const quit = game.resigned.includes(power) || game.out.includes(power) + // The same reading of the ending the closing music is chosen from, so the + // panel and the noise it arrives with are never telling different stories. + const ending = endingSounded(game.winner, game.out, power) if (!started) { return ( @@ -387,18 +443,42 @@ export default function App() {

Great Powers

- + {/* + The ways out, kept in the header because that is the one part of + this screen that is always on it -- the side column scrolls, and on + a narrow window it sits below a map most of a screen tall. A game + you cannot leave without the browser's back button is a game that + has taken the page hostage. + + There is nothing here once the game is over: the panel that replaces + the orders then offers both of these and a new game besides, and one + screen should have one place to look. + */} +

{game.phase === 'over' ? game.winner @@ -527,6 +607,30 @@ export default function App() {

)} + {game.phase === 'over' && ( + /* + * The way out. + * + * Every other phase ends in a button, and this one used to end in + * nothing at all: the result was announced in the header and the + * page then sat there, with no way to start again and no way off + * it short of the browser's own back button. A game that has + * finished has to say so and then let go. + */ +
+

{ENDINGS[ending].title}

+

{ENDINGS[ending].said}

+ +

+ {/* Absolute for the same reason the landing's is: served from a + subdirectory in production and from the root in development. */} + The rest of the games +

+
+ )} + {game.phase === 'orders' && !quit && ( = { + victory: { + title: 'The board is yours.', + said: 'Eighteen centres. Nobody has ever got there alone, so somebody helped you to it, and they will have noticed by now.', + }, + defeat: { + title: 'Beaten.', + said: 'Somebody else reached eighteen. That only ever happens because the rest of the board let it.', + }, + eliminated: { + title: 'Off the board.', + said: 'Your last centre went and the game carried on without you. Your centres are somebody else’s now.', + }, + armistice: { + title: 'The armistice.', + said: 'Nobody reached eighteen. What is left is divided between the powers still standing, and you are one of them.', + }, +} + function coastOf(unit: Unit, province: string): string { const coasts = PROVINCES[province]?.coasts if (unit.type === 'army' || !coasts) return province