A game you could not leave

Two holes, and the same one twice. When the game ended, every panel in the
side column was gated on the phase and simply went: the header announced
the result and the page then sat there, with no way to start again and no
way off it short of the browser's back button. While a game was running
there was no way out either -- the only links off the app were on the
landing page, which you had already left.

So the end of a game now ends in a panel, where the orders used to be. It
says what the ending was from the seat the player was sitting in, which is
a different sentence to the header's: a draw the survivors agreed to and a
draw you were eliminated long before are the same result and not the same
afternoon. It is read from the same endingSounded() the closing music is
chosen from, so the words and the noise cannot disagree.

Under it, the two things left to do -- another game, or the rest of the
games. Play again resets the refs as well as the state, and that is the
part that would have bitten: `ended` is what stops the closing music
playing twice, and `talked` is keyed on the turn already negotiated, so a
stale note there would have had the second game opened in silence by six
powers who never spoke.

The ways out of a game still being played go in the header, because that
is the one part of the screen always on it -- the side column scrolls, and
on a narrow window it sits below a map most of a screen tall. Leaving is
asked twice like resigning beside it, for the same reason and in the same
place rather than in a browser dialog, and the armed wording says what is
actually lost rather than asking whether you are sure. The two
confirmations disarm each other, so half-pressing one and reaching for the
other does not find it already waiting on its second click.
This commit is contained in:
2026-09-09 13:14:58 -07:00
parent 104cbf0694
commit e299050ea4
2 changed files with 218 additions and 15 deletions
+74 -2
View File
@@ -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; }
+144 -13
View File
@@ -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<string[]>([])
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() {
<div className="app">
<header>
<h1>Great Powers</h1>
<button
type="button"
className="sound"
aria-pressed={sound}
onClick={() => {
const next = !sound
setSound(next)
if (next) wake()
}}
>
{sound ? 'Sound on' : 'Sound off'}
</button>
{/*
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.
*/}
<nav className="ways">
{game.phase !== 'over' && (
<button type="button" className="leave" onClick={leave}>
{leaving ? 'Leave — this game is lost' : 'Leave game'}
</button>
)}
{game.phase !== 'over' && (
/* Absolute, as on the landing: this is served from a subdirectory
in production and from the root in development, so the relative
answer points at the game itself in one of them. */
<a href="https://games.jcoffey.dev/">The rest of the games</a>
)}
<button
type="button"
className="sound"
aria-pressed={sound}
onClick={() => {
const next = !sound
setSound(next)
if (next) wake()
}}
>
{sound ? 'Sound on' : 'Sound off'}
</button>
</nav>
<p className="dim">
{game.phase === 'over'
? game.winner
@@ -527,6 +607,30 @@ export default function App() {
</div>
)}
{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.
*/
<div className="over">
<h2>{ENDINGS[ending].title}</h2>
<p>{ENDINGS[ending].said}</p>
<button type="button" className="again" onClick={restart}>
Play again
</button>
<p className="ways">
{/* Absolute for the same reason the landing's is: served from a
subdirectory in production and from the root in development. */}
<a href="https://games.jcoffey.dev/">The rest of the games</a>
</p>
</div>
)}
{game.phase === 'orders' && !quit && (
<OrderPanel
units={units}
@@ -551,6 +655,33 @@ export default function App() {
)
}
/**
* How the game ended, from the seat the player was sitting in.
*
* The header says what happened on the board and names the winner. This says
* what it was to be you, which is a different sentence -- a draw the survivors
* agreed to and a draw you were eliminated well before are the same result and
* not the same afternoon.
*/
const ENDINGS: Record<Ending, { title: string; said: string }> = {
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 elses 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