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);
|
padding: clamp(10px, 2vw, 22px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The title on the left, the sound on the right, the state of play under
|
/* The title on the left, the ways out on the right, the state of play under
|
||||||
both -- so the toggle is findable without taking a line of its own. */
|
both -- so none of them takes a line of its own. */
|
||||||
header {
|
header {
|
||||||
grid-area: head;
|
grid-area: head;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -27,6 +27,38 @@ header {
|
|||||||
column-gap: 12px;
|
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 {
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: clamp(1.4rem, 3.2vw, 2.2rem);
|
font-size: clamp(1.4rem, 3.2vw, 2.2rem);
|
||||||
@@ -520,3 +552,43 @@ header select {
|
|||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
color: #b99a4e;
|
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 { POWERS, PROVINCES, base, type Power } from './game/map'
|
||||||
import { canStep, validate, type Order, type Unit } from './game/orders'
|
import { canStep, validate, type Order, type Unit } from './game/orders'
|
||||||
import type { Proposal } from './game/press'
|
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 {
|
import {
|
||||||
convoyDestinations,
|
convoyDestinations,
|
||||||
convoyTargets,
|
convoyTargets,
|
||||||
@@ -56,6 +56,7 @@ export default function App() {
|
|||||||
const [started, setStarted] = useState(false)
|
const [started, setStarted] = useState(false)
|
||||||
const [said, setSaid] = useState<string[]>([])
|
const [said, setSaid] = useState<string[]>([])
|
||||||
const [sure, setSure] = useState(false)
|
const [sure, setSure] = useState(false)
|
||||||
|
const [leaving, setLeaving] = useState(false)
|
||||||
|
|
||||||
// ------------------------------------------------------------ audio glue
|
// ------------------------------------------------------------ audio glue
|
||||||
|
|
||||||
@@ -328,6 +329,7 @@ export default function App() {
|
|||||||
// one button here that cannot be taken back.
|
// one button here that cannot be taken back.
|
||||||
if (!sure) {
|
if (!sure) {
|
||||||
setSure(true)
|
setSure(true)
|
||||||
|
setLeaving(false)
|
||||||
if (synth.sfxOn) synth.reject()
|
if (synth.sfxOn) synth.reject()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -339,6 +341,7 @@ export default function App() {
|
|||||||
|
|
||||||
const askDraw = () => {
|
const askDraw = () => {
|
||||||
setSure(false)
|
setSure(false)
|
||||||
|
setLeaving(false)
|
||||||
if (synth.sfxOn) synth.telegraph(false)
|
if (synth.sfxOn) synth.telegraph(false)
|
||||||
const { game: next, verdicts } = askForDraw(game, power)
|
const { game: next, verdicts } = askForDraw(game, power)
|
||||||
setGame(next)
|
setGame(next)
|
||||||
@@ -347,6 +350,7 @@ export default function App() {
|
|||||||
|
|
||||||
const askConcede = () => {
|
const askConcede = () => {
|
||||||
setSure(false)
|
setSure(false)
|
||||||
|
setLeaving(false)
|
||||||
const leader = POWERS.reduce((best, p) =>
|
const leader = POWERS.reduce((best, p) =>
|
||||||
centreCount(own, p) > centreCount(own, best) ? p : best,
|
centreCount(own, p) > centreCount(own, best) ? p : best,
|
||||||
)
|
)
|
||||||
@@ -361,9 +365,61 @@ export default function App() {
|
|||||||
setBuilds([])
|
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 mine = [...units.entries()].filter(([, u]) => u.power === power)
|
||||||
const season = game.season === 'spring' ? 'Spring' : 'Autumn'
|
const season = game.season === 'spring' ? 'Spring' : 'Autumn'
|
||||||
const quit = game.resigned.includes(power) || game.out.includes(power)
|
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) {
|
if (!started) {
|
||||||
return (
|
return (
|
||||||
@@ -387,18 +443,42 @@ export default function App() {
|
|||||||
<div className="app">
|
<div className="app">
|
||||||
<header>
|
<header>
|
||||||
<h1>Great Powers</h1>
|
<h1>Great Powers</h1>
|
||||||
<button
|
{/*
|
||||||
type="button"
|
The ways out, kept in the header because that is the one part of
|
||||||
className="sound"
|
this screen that is always on it -- the side column scrolls, and on
|
||||||
aria-pressed={sound}
|
a narrow window it sits below a map most of a screen tall. A game
|
||||||
onClick={() => {
|
you cannot leave without the browser's back button is a game that
|
||||||
const next = !sound
|
has taken the page hostage.
|
||||||
setSound(next)
|
|
||||||
if (next) wake()
|
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.
|
||||||
{sound ? 'Sound on' : 'Sound off'}
|
*/}
|
||||||
</button>
|
<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">
|
<p className="dim">
|
||||||
{game.phase === 'over'
|
{game.phase === 'over'
|
||||||
? game.winner
|
? game.winner
|
||||||
@@ -527,6 +607,30 @@ export default function App() {
|
|||||||
</div>
|
</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 && (
|
{game.phase === 'orders' && !quit && (
|
||||||
<OrderPanel
|
<OrderPanel
|
||||||
units={units}
|
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 {
|
function coastOf(unit: Unit, province: string): string {
|
||||||
const coasts = PROVINCES[province]?.coasts
|
const coasts = PROVINCES[province]?.coasts
|
||||||
if (unit.type === 'army' || !coasts) return province
|
if (unit.type === 'army' || !coasts) return province
|
||||||
|
|||||||
Reference in New Issue
Block a user