Merge pull request #1 from Coffey-Labs/a-way-out
A game you could not leave
This commit is contained in:
+74
-2
@@ -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
@@ -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 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
|
||||
|
||||
Reference in New Issue
Block a user