The remaster: a bridge, two bands, and a real modem
Adds the cel-shaded skin, the score and the sound, and puts the skin machinery back so the toggle under the frame has somewhere to go. The remaster is a bridge rather than a paper column: the ship down the left as meters and pips, a cel-shaded sensor grid in the middle, the log down the right. Both panels are devices -- break the short range sensors and the sector grid goes dark, break the computer and the chart does, and the chart draws what has been scanned rather than the galaxy as it is. Scans are drawn instead of printed, so the log gets the sentences and stops being eight rows of ASCII squeezed into a strip. One theme, two arrangements, so switching skin re-scores the piece rather than changing the record: brass, strings and timpani for the remaster, the same eight bars as a three-channel chiptune for 1971. Written in the idiom rather than quoted from it, for the same reason the game is not called what you expect -- NOTICE.md now argues both. Effects are the ship's: warp, beams, torpedo, hit, explode, klaxon, dock, and the print head on paper only. The boot dials in for real: Bell 103 at the real frequencies, which is what a 110-baud acoustic coupler sounded like and not the modem noise everybody remembers. The synth engine is the siblings', unforked. Tests cover the music the only way a test can: a mistyped note name does not throw, it silently removes a voice, so every step of every track is checked against the parser.
This commit is contained in:
+626
-6
@@ -1,13 +1,16 @@
|
||||
/* =====================================================================
|
||||
One skin so far, and it is the machine as it was.
|
||||
Two skins, one game. Layout is shared; every colour and every piece of
|
||||
chrome comes from a token that the skin swaps.
|
||||
|
||||
1971 is a printing terminal: black ink on fanfold paper, one weight, one
|
||||
family, no colour at all. Anything that wants to be emphatic has to do it
|
||||
with capitals or spacing, which is exactly the constraint the original was
|
||||
written under.
|
||||
|
||||
Everything the skin owns is a token, so that the remaster can swap the lot
|
||||
under `html[data-skin='modern']` without touching a rule below.
|
||||
The remaster is a lit bridge at night: deep blue glass, amber for
|
||||
everything that is yours, cyan for everything that will help you, red for
|
||||
everything that will not. Flat cel fills and one heavy outline on
|
||||
everything, the same house style the cave has.
|
||||
===================================================================== */
|
||||
|
||||
:root {
|
||||
@@ -28,12 +31,58 @@
|
||||
--field-bg: #f7f1e0;
|
||||
--field-fg: #241f1a;
|
||||
--body-font: ui-monospace, "DejaVu Sans Mono", "Cascadia Mono", "Courier New", monospace;
|
||||
/*
|
||||
* The transcript's font, which does not follow the skin.
|
||||
*
|
||||
* It is machine output, and the machine prints a short range scan as eight
|
||||
* rows of three-character columns that only line up in one width. Letting
|
||||
* the remaster's rounded face have the log turned the grid into confetti.
|
||||
* The bridge has a monospaced console; that is not an anachronism, it is
|
||||
* what consoles are.
|
||||
*/
|
||||
--mono: ui-monospace, "DejaVu Sans Mono", "Cascadia Mono", "Courier New", monospace;
|
||||
--line-height: 1.42;
|
||||
--letter-spacing: 0.02em;
|
||||
|
||||
/* Sensor tokens, unused by the teletype but defined so nothing is ever
|
||||
undefined when the skin flips mid-render. */
|
||||
--grid-bg: #0f1830;
|
||||
--grid-rule: #1e2c4c;
|
||||
/* A star is not the same colour as your ship. The first pass had both in
|
||||
amber and a sector read as seven of you. */
|
||||
--star: #f1eddc;
|
||||
--raider: #ff6b5d;
|
||||
--base: #5ad0ff;
|
||||
--ship: #ffc247;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html[data-skin='modern'] {
|
||||
/* --- now: the bridge ------------------------------------------------ */
|
||||
--ink: #e8f1ff;
|
||||
--ink-dim: #8ea6c8;
|
||||
--ink-bright: #ffffff;
|
||||
--accent: #ffc247;
|
||||
--warn: #ff6b5d;
|
||||
--outline: #070c18;
|
||||
--screen-bg: linear-gradient(175deg, #1a2647 0%, #101a35 55%, #070d1e 100%);
|
||||
--screen-radius: 26px;
|
||||
--paper-edge: transparent;
|
||||
--glow: 0 0 22px rgba(90, 208, 255, 0.16);
|
||||
--btn-radius: 999px;
|
||||
--btn-shadow: 4px 4px 0 var(--outline);
|
||||
--btn-bg: #e8f1ff;
|
||||
--btn-fg: #070c18;
|
||||
--field-bg: #e8f1ff;
|
||||
--field-fg: #070c18;
|
||||
--body-font: ui-rounded, "Nunito", "Avenir Next", "Segoe UI", system-ui, sans-serif;
|
||||
--line-height: 1.5;
|
||||
--letter-spacing: 0;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
Layout
|
||||
===================================================================== */
|
||||
@@ -61,6 +110,12 @@
|
||||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .bezel {
|
||||
background: linear-gradient(160deg, #16203c 0%, #0a1122 100%);
|
||||
border: 4px solid var(--outline);
|
||||
box-shadow: 0 22px 60px rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
/* The platen: the roller the paper comes over, with the feed knob at the end. */
|
||||
.platen {
|
||||
height: 16px;
|
||||
@@ -103,7 +158,7 @@
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: clamp(12px, 2.6vw, 26px) 34px;
|
||||
padding: clamp(12px, 2.6vw, 26px) clamp(12px, 2.6vw, 26px);
|
||||
color: var(--ink);
|
||||
font-family: var(--body-font);
|
||||
font-size: clamp(10px, 1.45vw, 14px);
|
||||
@@ -111,6 +166,12 @@
|
||||
letter-spacing: var(--letter-spacing);
|
||||
}
|
||||
|
||||
/* The paper has sprocket holes down both edges; the bridge does not. */
|
||||
:root:not([data-skin='modern']) .screen-inner {
|
||||
padding-left: 34px;
|
||||
padding-right: 34px;
|
||||
}
|
||||
|
||||
.screen-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -151,6 +212,15 @@
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .accent {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .warn {
|
||||
color: var(--warn);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* --- paper chrome ---------------------------------------------------- */
|
||||
|
||||
.sprockets {
|
||||
@@ -186,6 +256,28 @@
|
||||
repeating-linear-gradient(90deg, rgba(0, 0, 0, 0.02) 0 1px, transparent 1px 4px);
|
||||
}
|
||||
|
||||
/* --- bridge chrome ---------------------------------------------------- */
|
||||
|
||||
/* Glass, not paper: a faint horizontal structure and a darkened rim. */
|
||||
.scanlines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.35;
|
||||
background-image: repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.035) 0 1px,
|
||||
transparent 1px 3px
|
||||
);
|
||||
}
|
||||
|
||||
.vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
box-shadow: inset 0 0 120px 34px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
The transcript
|
||||
===================================================================== */
|
||||
@@ -214,6 +306,7 @@
|
||||
.printed {
|
||||
white-space: pre;
|
||||
min-height: 1.2em;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
/* The print head: a block where the next character is about to land. */
|
||||
@@ -227,6 +320,484 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
The bridge
|
||||
|
||||
Three columns: the ship down the left, the sensors in the middle, the log
|
||||
down the right. A 4:3 screen is wide, and the paper skin's single column
|
||||
throws that width away -- which is fine for paper, since paper is a column,
|
||||
and wrong for a screen.
|
||||
===================================================================== */
|
||||
|
||||
/*
|
||||
* The middle column is nearly twice either side, and the rows stretch rather
|
||||
* than hugging the top. Both of those are the same decision: the sensors are
|
||||
* what the game is, so they get the space, and the two panels are read at a
|
||||
* glance rather than studied. Aligning to the start left a third of the
|
||||
* screen empty under everything, which is the one thing a bridge should
|
||||
* never look like.
|
||||
*/
|
||||
.bridge {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.76fr) minmax(0, 1.95fr) minmax(0, 0.98fr);
|
||||
gap: clamp(8px, 1.4vw, 16px);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* A phone is a column whatever the skin, and the sensors go first because
|
||||
they are what the game is. */
|
||||
@media (max-width: 720px) {
|
||||
.bridge {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas: 'map' 'ship' 'log';
|
||||
}
|
||||
|
||||
.tactical {
|
||||
grid-area: map;
|
||||
}
|
||||
|
||||
.ship-panel {
|
||||
grid-area: ship;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
grid-area: log;
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 9px 11px;
|
||||
border: 2px solid var(--outline);
|
||||
border-radius: 14px;
|
||||
background: rgba(12, 20, 42, 0.72);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-size: 0.82em;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.panel-label {
|
||||
font-size: 0.72em;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
/* --- the ship --------------------------------------------------------- */
|
||||
|
||||
.condition {
|
||||
align-self: flex-start;
|
||||
padding: 2px 12px;
|
||||
border: 2px solid var(--outline);
|
||||
border-radius: 999px;
|
||||
font-size: 0.78em;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--outline);
|
||||
background: var(--ink-dim);
|
||||
}
|
||||
|
||||
.cond-green {
|
||||
background: #6fe39a;
|
||||
}
|
||||
|
||||
.cond-yellow {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.cond-red {
|
||||
background: var(--warn);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.cond-docked {
|
||||
background: var(--base);
|
||||
}
|
||||
|
||||
.meter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.meter-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.meter-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 10px;
|
||||
border: 2px solid var(--outline);
|
||||
border-radius: 999px;
|
||||
background: #0b1224;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
transition: width 220ms ease;
|
||||
}
|
||||
|
||||
.bar-energy span {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.bar-shields span {
|
||||
background: var(--base);
|
||||
}
|
||||
|
||||
.readout {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 1px 10px;
|
||||
margin: 0;
|
||||
font-size: 0.86em;
|
||||
}
|
||||
|
||||
.readout dt {
|
||||
color: var(--ink-dim);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.readout dd {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.readout dd.big {
|
||||
color: var(--accent);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.readout dd.low {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.torps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.pip {
|
||||
width: 8px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--outline);
|
||||
border-radius: 3px;
|
||||
background: #0d1730;
|
||||
}
|
||||
|
||||
.pip.on {
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
.damage-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.all-well {
|
||||
margin: 0;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.damage-list ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.damage-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.damage-list .days {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* --- the log ---------------------------------------------------------- */
|
||||
|
||||
.log-panel {
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* The log gives up width to the map and stays readable on the strength of
|
||||
line height rather than size. */
|
||||
.log-lines {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 0.8em;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.log-lines {
|
||||
max-height: 9em;
|
||||
}
|
||||
}
|
||||
|
||||
.log-line {
|
||||
margin: 0;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.log-fire {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.log-hit {
|
||||
color: var(--warn);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.log-damage {
|
||||
color: #ff9f6b;
|
||||
}
|
||||
|
||||
.log-move {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.log-win {
|
||||
color: #6fe39a;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.log-end {
|
||||
color: var(--warn);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
The sensors
|
||||
|
||||
Two panels, and each one is a device: break the short range sensors and
|
||||
the sector grid goes dark, break the computer and the chart does. See
|
||||
TacticalView.tsx, which will not draw anything the transcript has not
|
||||
already been told.
|
||||
===================================================================== */
|
||||
|
||||
.tactical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 2px 12px;
|
||||
font-size: 0.85em;
|
||||
letter-spacing: 0.08em;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.tab.on {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
/*
|
||||
* The map takes the whole cell it is given, in both directions. An SVG with a
|
||||
* viewBox letterboxes itself, so this is safe: it grows to whichever of the
|
||||
* two runs out first and stays square, which is why the column can be sized
|
||||
* in fractions rather than in vh.
|
||||
*/
|
||||
.grid-map {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.grid-map {
|
||||
flex: none;
|
||||
width: min(100%, 62vh);
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-bg {
|
||||
fill: var(--grid-bg);
|
||||
stroke: var(--outline);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.grid-bg.dead {
|
||||
fill: #0a0f1c;
|
||||
}
|
||||
|
||||
.dead-label {
|
||||
fill: var(--warn);
|
||||
font-family: var(--body-font);
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
text-anchor: middle;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.rule {
|
||||
stroke: var(--grid-rule);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
/* Cel shading: one flat fill, one heavy outline, and a highlight that is a
|
||||
second flat fill rather than a gradient. */
|
||||
.mark path,
|
||||
.mark circle,
|
||||
.mark ellipse {
|
||||
stroke: var(--outline);
|
||||
stroke-width: 2.5;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.mark .lit {
|
||||
stroke: none;
|
||||
fill: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.mark-star .lit {
|
||||
fill: #ffffff;
|
||||
}
|
||||
|
||||
.mark-star circle {
|
||||
fill: var(--star);
|
||||
}
|
||||
|
||||
.mark-raider path {
|
||||
fill: var(--raider);
|
||||
}
|
||||
|
||||
.mark-base path {
|
||||
fill: var(--base);
|
||||
}
|
||||
|
||||
.mark-base .lit {
|
||||
fill: #07243a;
|
||||
}
|
||||
|
||||
.mark-ship .saucer {
|
||||
fill: var(--ship);
|
||||
}
|
||||
|
||||
.mark-ship .hull {
|
||||
fill: #d99a1f;
|
||||
}
|
||||
|
||||
.mark-ship .ring {
|
||||
fill: none;
|
||||
stroke: var(--ship);
|
||||
stroke-width: 1.5;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.mark-ship.red .ring {
|
||||
stroke: var(--warn);
|
||||
stroke-width: 2.5;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* --- the chart -------------------------------------------------------- */
|
||||
|
||||
.quad rect {
|
||||
fill: #16203c;
|
||||
stroke: var(--outline);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.quad text {
|
||||
fill: var(--ink-dim);
|
||||
font-family: var(--body-font);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
text-anchor: middle;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.quad.unknown rect {
|
||||
fill: #0d1428;
|
||||
}
|
||||
|
||||
.quad.unknown text {
|
||||
fill: #40527a;
|
||||
}
|
||||
|
||||
.quad.hostile rect {
|
||||
fill: #4a1f24;
|
||||
}
|
||||
|
||||
.quad.hostile text {
|
||||
fill: var(--warn);
|
||||
}
|
||||
|
||||
.quad.has-base rect {
|
||||
stroke: var(--base);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
/* Where you are, in the colour everything of yours is drawn in. It wins
|
||||
over the hostile fill on purpose: you already know there are raiders here,
|
||||
the scan is printed above. */
|
||||
.quad.here rect {
|
||||
fill: var(--ship);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.quad.here text {
|
||||
fill: var(--outline);
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
The prompt
|
||||
===================================================================== */
|
||||
@@ -278,6 +849,18 @@
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .statusbar .senses {
|
||||
color: var(--warn);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .banner {
|
||||
color: var(--accent);
|
||||
-webkit-text-stroke: 3px var(--outline);
|
||||
paint-order: stroke fill;
|
||||
text-shadow: 5px 5px 0 var(--outline);
|
||||
}
|
||||
|
||||
.banner {
|
||||
margin: 0;
|
||||
font-family: var(--body-font);
|
||||
@@ -336,11 +919,43 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .btn {
|
||||
transition:
|
||||
transform 110ms ease,
|
||||
box-shadow 110ms ease;
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .btn:hover:not(:disabled) {
|
||||
transform: translate(-2px, -2px);
|
||||
box-shadow: 6px 6px 0 var(--outline);
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .btn:active:not(:disabled) {
|
||||
transform: translate(2px, 2px);
|
||||
box-shadow: 2px 2px 0 var(--outline);
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .tab,
|
||||
html[data-skin='modern'] .btn-ghost {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .tab:hover:not(:disabled),
|
||||
html[data-skin='modern'] .btn-ghost:hover:not(:disabled) {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #241f1a;
|
||||
color: #f3ecd9;
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--outline);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--ink-dim);
|
||||
@@ -387,10 +1002,15 @@
|
||||
|
||||
/*
|
||||
* The way back. The arrow is a pseudo-element rather than part of the label so
|
||||
* that it can be the right arrow for the machine you are looking at: the two
|
||||
* ASCII characters a printing terminal could actually strike.
|
||||
* that it can be the right arrow for the machine you are looking at: a chevron
|
||||
* on the bridge, and the two ASCII characters a printing terminal could
|
||||
* actually strike on the paper skin.
|
||||
*/
|
||||
.controls .back::before {
|
||||
content: '\2039\00a0';
|
||||
}
|
||||
|
||||
:root:not([data-skin='modern']) .controls .back::before {
|
||||
content: '<-\00a0';
|
||||
}
|
||||
|
||||
|
||||
+140
-7
@@ -1,10 +1,14 @@
|
||||
import { useCallback, useEffect, useRef, useState, useReducer } from 'react'
|
||||
import { DEEP_END, DEEP_PATROL, DEEP_TITLE } from './audio/deepspace'
|
||||
import { END_TUNE, PATROL_TUNE, TITLE_TUNE } from './audio/tunes'
|
||||
import { synth } from './audio/synth'
|
||||
import { HAIL_TEXT } from './game/constants'
|
||||
import { makePatrol, randomSeed, shortRangeScan, step } from './game/engine'
|
||||
import { fetchScores, submitScores, type Score } from './game/highscores'
|
||||
import { makeRng, type Rng } from './game/rng'
|
||||
import { currentCaptain, initialState, reducer } from './game/reducer'
|
||||
import type { Command, Line } from './game/types'
|
||||
import { useSkin } from './skin'
|
||||
import { Btn, Fit, Frame } from './components/Frame'
|
||||
import { BridgeScreen } from './components/BridgeScreen'
|
||||
import {
|
||||
@@ -40,6 +44,10 @@ export default function App() {
|
||||
const [seed] = useState(randomSeed)
|
||||
const [state, dispatch] = useReducer(reducer, seed, initialState)
|
||||
const rng = useRef<Rng>(makeRng(seed))
|
||||
const [music, setMusic] = useState(true)
|
||||
const [sfx, setSfx] = useState(true)
|
||||
const started = useRef(false)
|
||||
const { skin, toggle: toggleSkin } = useSkin()
|
||||
const [scores, setScores] = useState<Score[]>([])
|
||||
const [boardState, setBoardState] = useState<BoardState>('loading')
|
||||
const [freshScores, setFreshScores] = useState<string[]>([])
|
||||
@@ -48,6 +56,50 @@ export default function App() {
|
||||
|
||||
const who = currentCaptain(state)
|
||||
|
||||
// ------------------------------------------------------------ audio glue
|
||||
|
||||
const wake = useCallback(() => {
|
||||
if (started.current) return
|
||||
started.current = true
|
||||
synth.ensure()
|
||||
synth.setMusic(music)
|
||||
synth.setSfx(sfx)
|
||||
}, [music, sfx])
|
||||
|
||||
/**
|
||||
* 1971 gets the chiptune it never had; the remaster gets the orchestra.
|
||||
*
|
||||
* It is the same theme in both, in the same key -- see `audio/tunes.ts`.
|
||||
* Switching skin mid-patrol re-scores the piece rather than changing the
|
||||
* record, which is the one thing that makes the toggle feel like a remaster
|
||||
* rather than a second game.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!started.current) return
|
||||
const front =
|
||||
state.phase === 'title' || state.phase === 'instructions' || state.phase === 'setup'
|
||||
const set =
|
||||
skin === 'modern'
|
||||
? { title: DEEP_TITLE, patrol: DEEP_PATROL, end: DEEP_END }
|
||||
: { title: TITLE_TUNE, patrol: PATROL_TUNE, end: END_TUNE }
|
||||
const tune =
|
||||
state.phase === 'gameover' || state.phase === 'report'
|
||||
? set.end
|
||||
: front
|
||||
? set.title
|
||||
: set.patrol
|
||||
synth.playTune(tune, false)
|
||||
}, [state.phase, skin])
|
||||
|
||||
useEffect(() => {
|
||||
synth.setMusic(music)
|
||||
}, [music])
|
||||
useEffect(() => {
|
||||
synth.setSfx(sfx)
|
||||
}, [sfx])
|
||||
|
||||
const blip = useCallback(() => synth.blip(), [])
|
||||
|
||||
const loadBoard = useCallback(async () => {
|
||||
setBoardState('loading')
|
||||
try {
|
||||
@@ -90,6 +142,28 @@ export default function App() {
|
||||
const command = (cmd: Command, echo: Line[]) => {
|
||||
if (!state.patrol) return
|
||||
const { patrol, lines } = step(state.patrol, cmd, rng.current)
|
||||
|
||||
/*
|
||||
* The sound belongs to what happened, so it is chosen from the lines the
|
||||
* engine produced rather than guessed at from the command. Firing the
|
||||
* beams and hitting something with them are two different noises, and
|
||||
* only the engine knows whether the second one happened.
|
||||
*/
|
||||
const said = (t: string) => lines.some((l) => l.text.includes(t))
|
||||
|
||||
if (cmd.type === 'nav') synth.warp()
|
||||
else if (cmd.type === 'beams') synth.beams()
|
||||
else if (cmd.type === 'torpedo') synth.torpedo()
|
||||
|
||||
if (said('COMES APART') || said('DIRECT HIT') || said('IS GONE')) synth.explode()
|
||||
if (said('CONDITION RED')) synth.alert()
|
||||
if (said('UNIT HIT')) synth.hit()
|
||||
if (said('DAMAGED.')) synth.damaged()
|
||||
if (said('MOORED')) synth.dock()
|
||||
|
||||
if (patrol.outcome === 'mission-complete') synth.fanfare()
|
||||
else if (patrol.outcome) synth.sad()
|
||||
|
||||
dispatch({ type: 'ADVANCE', patrol, lines: [...echo, ...lines] })
|
||||
}
|
||||
|
||||
@@ -132,6 +206,7 @@ export default function App() {
|
||||
}, [state.phase, state.captains, post])
|
||||
|
||||
const restart = () => {
|
||||
synth.select()
|
||||
const s = randomSeed()
|
||||
rng.current = makeRng(s)
|
||||
posted.current = false
|
||||
@@ -140,6 +215,8 @@ export default function App() {
|
||||
}
|
||||
|
||||
const showScores = () => {
|
||||
wake()
|
||||
synth.select()
|
||||
dispatch({ type: 'SHOW_SCORES' })
|
||||
if (boardState !== 'ready') void loadBoard()
|
||||
}
|
||||
@@ -149,7 +226,7 @@ export default function App() {
|
||||
const moreToCome = state.captains.some((c) => !c.done && c.id !== who?.id)
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="app" onPointerDown={wake} onKeyDown={wake}>
|
||||
<Frame
|
||||
footer={
|
||||
<div className="controls">
|
||||
@@ -157,6 +234,37 @@ export default function App() {
|
||||
<a className="btn btn-ghost back" href={GAMES_URL} title="The rest of the games">
|
||||
GAMES
|
||||
</a>
|
||||
<Btn
|
||||
kind="ghost"
|
||||
onClick={() => {
|
||||
wake()
|
||||
setMusic((m) => !m)
|
||||
}}
|
||||
title="Background music"
|
||||
>
|
||||
MUSIC {music ? 'ON' : 'OFF'}
|
||||
</Btn>
|
||||
<Btn
|
||||
kind="ghost"
|
||||
onClick={() => {
|
||||
wake()
|
||||
setSfx((v) => !v)
|
||||
}}
|
||||
title="Sound effects"
|
||||
>
|
||||
SOUND {sfx ? 'ON' : 'OFF'}
|
||||
</Btn>
|
||||
<Btn
|
||||
kind="ghost"
|
||||
onClick={() => {
|
||||
wake()
|
||||
synth.select()
|
||||
toggleSkin()
|
||||
}}
|
||||
title={skin === 'teletype' ? 'Switch to the remaster' : 'Switch to the 1971 terminal'}
|
||||
>
|
||||
{skin === 'teletype' ? 'REMASTER' : '1971'}
|
||||
</Btn>
|
||||
<Btn kind="ghost" onClick={restart} title="Abandon this session">
|
||||
NEW PATROL
|
||||
</Btn>
|
||||
@@ -178,8 +286,16 @@ export default function App() {
|
||||
<Fit>
|
||||
{state.phase === 'title' && (
|
||||
<TitleGate
|
||||
onStart={() => dispatch({ type: 'SHOW_SETUP' })}
|
||||
onInstructions={() => dispatch({ type: 'SHOW_INSTRUCTIONS' })}
|
||||
onStart={() => {
|
||||
wake()
|
||||
synth.select()
|
||||
dispatch({ type: 'SHOW_SETUP' })
|
||||
}}
|
||||
onInstructions={() => {
|
||||
wake()
|
||||
synth.select()
|
||||
dispatch({ type: 'SHOW_INSTRUCTIONS' })
|
||||
}}
|
||||
onScores={showScores}
|
||||
/>
|
||||
)}
|
||||
@@ -189,7 +305,14 @@ export default function App() {
|
||||
)}
|
||||
|
||||
{state.phase === 'setup' && (
|
||||
<SetupScreen onStart={(names) => dispatch({ type: 'START', names })} />
|
||||
<SetupScreen
|
||||
onStart={(names) => {
|
||||
wake()
|
||||
synth.select()
|
||||
dispatch({ type: 'START', names })
|
||||
}}
|
||||
onBlip={blip}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(state.phase === 'patrol' || state.phase === 'resolve') && state.patrol && who && (
|
||||
@@ -201,6 +324,7 @@ export default function App() {
|
||||
over={state.phase === 'resolve'}
|
||||
onCommand={command}
|
||||
onPrint={(lines) => dispatch({ type: 'PRINT', lines })}
|
||||
onBlip={blip}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -209,7 +333,10 @@ export default function App() {
|
||||
patrol={state.patrol}
|
||||
captain={state.captains[who.id]!}
|
||||
moreToCome={moreToCome}
|
||||
onNext={() => dispatch({ type: 'NEXT_TURN' })}
|
||||
onNext={() => {
|
||||
synth.select()
|
||||
dispatch({ type: 'NEXT_TURN' })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -226,8 +353,14 @@ export default function App() {
|
||||
scores={scores}
|
||||
state={boardState}
|
||||
highlight={freshScores}
|
||||
onBack={() => dispatch({ type: 'CLOSE_SCORES' })}
|
||||
onRetry={() => void loadBoard()}
|
||||
onBack={() => {
|
||||
synth.select()
|
||||
dispatch({ type: 'CLOSE_SCORES' })
|
||||
}}
|
||||
onRetry={() => {
|
||||
synth.select()
|
||||
void loadBoard()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Fit>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { SKIN_KEY, SkinContext, readSkin, writeSkin, type Skin, type SkinApi } from './skin'
|
||||
|
||||
export function SkinProvider({ children }: { children: ReactNode }) {
|
||||
const [skin, setSkinState] = useState<Skin>(readSkin)
|
||||
|
||||
const setSkin = useCallback((s: Skin) => {
|
||||
setSkinState(s)
|
||||
writeSkin(s)
|
||||
}, [])
|
||||
|
||||
// The attribute lives on <html> so the page background can follow the skin.
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.skin = skin
|
||||
}, [skin])
|
||||
|
||||
// Keep sibling tabs in the same browser in step.
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === SKIN_KEY || e.key === null) setSkinState(readSkin())
|
||||
}
|
||||
window.addEventListener('storage', onStorage)
|
||||
return () => window.removeEventListener('storage', onStorage)
|
||||
}, [])
|
||||
|
||||
const value = useMemo<SkinApi>(
|
||||
() => ({ skin, setSkin, toggle: () => setSkin(skin === 'teletype' ? 'modern' : 'teletype') }),
|
||||
[skin, setSkin],
|
||||
)
|
||||
|
||||
return <SkinContext value={value}>{children}</SkinContext>
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { Tune } from './synth'
|
||||
|
||||
/**
|
||||
* The remaster's score, and what it is and is not.
|
||||
*
|
||||
* It is *not* the television theme, or any part of it, or a paraphrase of
|
||||
* one. The reason this game is called Long Range Scan at all is that the
|
||||
* setting's names belong to somebody, and it would be a strange sort of care
|
||||
* to rename the enemy and then quote sixteen bars of the music. Every note
|
||||
* below was written for this project.
|
||||
*
|
||||
* What it *is* is the idiom, which belongs to nobody: brass in a major key,
|
||||
* a melody built on rising fourths and fifths that holds its top note far
|
||||
* longer than a pop tune would dare, strings underneath moving one chord a
|
||||
* bar, and timpani. That is how film and television have said "space, and it
|
||||
* matters" since the 1950s, and it predates all of them -- it is Holst, and
|
||||
* before him it is Wagner. Nobody owns a perfect fifth.
|
||||
*
|
||||
* The instruments are the same six voices the sibling games use, leant on
|
||||
* differently:
|
||||
*
|
||||
* - **brass** is a detuned saw with the filter closing slightly across
|
||||
* each note, which is what a horn does on the attack;
|
||||
* - **strings** are a quiet pulse-50 playing actual chords -- the engine
|
||||
* stacks them with slashes -- held the whole bar with gate 1;
|
||||
* - **timpani** is the kit's kick, played sparingly enough to read as a
|
||||
* drum somebody is hitting rather than a beat.
|
||||
*
|
||||
* Eighths, eight to a bar, D major, and slow enough to be serious.
|
||||
*/
|
||||
|
||||
const bar = (...steps: string[]) => steps
|
||||
|
||||
/** A chord held for a whole bar. */
|
||||
const held = (chord: string) => [chord, '=', '=', '=', '=', '=', '=', '=']
|
||||
|
||||
/**
|
||||
* The theme.
|
||||
*
|
||||
* Eight bars. It opens on a pickup a fifth below the tonic and holds the
|
||||
* arrival for most of a bar, climbs to the high G in bar three and sits
|
||||
* there, then comes down. The second half is the same shape starting a
|
||||
* fourth lower, so it can climb again and land properly.
|
||||
*/
|
||||
export const DEEP_TITLE: Tune = {
|
||||
bpm: 72,
|
||||
stepsPerBeat: 2,
|
||||
tracks: [
|
||||
{
|
||||
wave: 'saw',
|
||||
gain: 0.13,
|
||||
gate: 0.96,
|
||||
detune: 6,
|
||||
filter: { from: 1500, to: 900, q: 1.2 },
|
||||
notes: [
|
||||
...bar('.', '.', '.', 'A4', 'D5', '=', '=', '='),
|
||||
...bar('D5', '=', '=', '=', 'E5', '.', 'F#5', '.'),
|
||||
...bar('G5', '=', '=', '=', '=', '=', '.', '.'),
|
||||
...bar('F#5', '.', 'E5', '.', 'D5', '=', '=', '.'),
|
||||
...bar('.', '.', '.', 'A4', 'B4', '=', '=', '='),
|
||||
...bar('B4', '=', '=', '.', 'C#5', '.', 'D5', '.'),
|
||||
...bar('E5', '=', '=', '=', 'D5', '.', 'C#5', '.'),
|
||||
...bar('D5', '=', '=', '=', '=', '=', '=', '.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
// Strings. One chord a bar, and the whole harmonic argument is here:
|
||||
// D, D, G, D, Bm, G, A, D.
|
||||
wave: 'pulse50',
|
||||
gain: 0.045,
|
||||
gate: 1,
|
||||
notes: [
|
||||
...held('D4/F#4/A4'),
|
||||
...held('D4/F#4/A4'),
|
||||
...held('G3/B3/D4'),
|
||||
...held('D4/F#4/A4'),
|
||||
...held('B3/D4/F#4'),
|
||||
...held('G3/B3/D4'),
|
||||
...held('A3/C#4/E4'),
|
||||
...held('D4/F#4/A4'),
|
||||
],
|
||||
},
|
||||
{
|
||||
wave: 'triangle',
|
||||
gain: 0.24,
|
||||
gate: 1,
|
||||
notes: [
|
||||
...held('D2'),
|
||||
...held('D2'),
|
||||
...held('G1'),
|
||||
...held('D2'),
|
||||
...held('B1'),
|
||||
...held('G1'),
|
||||
...held('A1'),
|
||||
...held('D2'),
|
||||
],
|
||||
},
|
||||
{
|
||||
// Timpani. Two strokes a bar where the melody is holding, a run of
|
||||
// four where it is coming home, and nothing at all in between.
|
||||
wave: 'noise',
|
||||
gain: 0.4,
|
||||
notes: [
|
||||
...bar('K', '.', '.', '.', 'K', '.', '.', '.'),
|
||||
...bar('.', '.', '.', '.', '.', '.', '.', '.'),
|
||||
...bar('K', '.', '.', '.', 'K', '.', '.', '.'),
|
||||
...bar('.', '.', '.', '.', '.', '.', '.', '.'),
|
||||
...bar('K', '.', '.', '.', 'K', '.', '.', '.'),
|
||||
...bar('.', '.', '.', '.', '.', '.', '.', '.'),
|
||||
...bar('K', '.', 'K', '.', 'K', '.', 'K', '.'),
|
||||
...bar('K', '.', '.', '.', '.', '.', '.', '.'),
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* On patrol.
|
||||
*
|
||||
* The theme is not here, deliberately. What plays while you are counting
|
||||
* quadrants has to be furniture -- you are reading a chart over the top of
|
||||
* it, and a melody would be unbearable by the fourth warp. So: the same
|
||||
* harmony, moving half as fast, with one distant horn call every second bar
|
||||
* to say the theme is still out there.
|
||||
*/
|
||||
export const DEEP_PATROL: Tune = {
|
||||
bpm: 66,
|
||||
stepsPerBeat: 2,
|
||||
tracks: [
|
||||
{
|
||||
wave: 'saw',
|
||||
gain: 0.055,
|
||||
gate: 0.9,
|
||||
detune: 5,
|
||||
filter: { from: 1100, to: 700, q: 1.2 },
|
||||
notes: [
|
||||
...bar('.', '.', '.', '.', '.', '.', 'A4', '.'),
|
||||
...bar('D5', '=', '=', '=', '.', '.', '.', '.'),
|
||||
...bar('.', '.', '.', '.', '.', '.', 'F#4', '.'),
|
||||
...bar('B4', '=', '=', '=', '.', '.', '.', '.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
wave: 'pulse50',
|
||||
gain: 0.04,
|
||||
gate: 1,
|
||||
notes: [
|
||||
...held('D4/F#4/A4'),
|
||||
...held('D4/G4/A4'),
|
||||
...held('B3/D4/F#4'),
|
||||
...held('G3/B3/D4'),
|
||||
],
|
||||
},
|
||||
{
|
||||
wave: 'triangle',
|
||||
gain: 0.22,
|
||||
gate: 1,
|
||||
notes: [...held('D2'), ...held('D2'), ...held('B1'), ...held('G1')],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* The end of it, either way.
|
||||
*
|
||||
* A rising line over G, A, Bm, D -- the lift rather than the fanfare,
|
||||
* because it plays over the standings and half the captains reading them
|
||||
* did not make it back.
|
||||
*/
|
||||
export const DEEP_END: Tune = {
|
||||
bpm: 72,
|
||||
stepsPerBeat: 2,
|
||||
tracks: [
|
||||
{
|
||||
wave: 'saw',
|
||||
gain: 0.12,
|
||||
gate: 0.96,
|
||||
detune: 6,
|
||||
filter: { from: 1500, to: 900, q: 1.2 },
|
||||
notes: [
|
||||
...bar('D5', '=', '=', '=', '=', '=', '=', '.'),
|
||||
...bar('E5', '=', '=', '=', '=', '=', '=', '.'),
|
||||
...bar('F#5', '=', '=', '=', '=', '=', 'G5', '.'),
|
||||
...bar('A5', '=', '=', '=', '=', '=', '=', '.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
wave: 'pulse50',
|
||||
gain: 0.05,
|
||||
gate: 1,
|
||||
notes: [
|
||||
...held('G3/B3/D4'),
|
||||
...held('A3/C#4/E4'),
|
||||
...held('B3/D4/F#4'),
|
||||
...held('D4/F#4/A4'),
|
||||
],
|
||||
},
|
||||
{
|
||||
wave: 'triangle',
|
||||
gain: 0.26,
|
||||
gate: 1,
|
||||
notes: [...held('G1'), ...held('A1'), ...held('B1'), ...held('D2')],
|
||||
},
|
||||
{
|
||||
wave: 'noise',
|
||||
gain: 0.36,
|
||||
notes: [
|
||||
...bar('K', '.', '.', '.', '.', '.', '.', '.'),
|
||||
...bar('K', '.', '.', '.', '.', '.', '.', '.'),
|
||||
...bar('K', '.', '.', '.', 'K', '.', '.', '.'),
|
||||
...bar('K', '.', 'K', '.', 'K', '.', '.', '.'),
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,776 @@
|
||||
/**
|
||||
* The synth, shared with its siblings.
|
||||
*
|
||||
* This engine is the one written for the lemonade stand and carried into the
|
||||
* cave game -- same author, same licence, and deliberately not forked: a
|
||||
* scheduler, six voices and a small kit are not worth having three of. What
|
||||
* is written *for* it here is new, and lives in `tunes.ts` and `deepspace.ts`
|
||||
* alongside the effects below.
|
||||
*
|
||||
* A very small chiptune engine: pulse waves for voices, filtered white noise
|
||||
* for percussion, and a look-ahead scheduler so patterns stay in time even
|
||||
* when React is busy re-rendering.
|
||||
*
|
||||
* Shared with the lemonade stand, note for note -- same author, same licence,
|
||||
* and a synth is not the part of a game worth writing twice. What is not
|
||||
* shared is everything under "sfx" below: a cave needs wings, a fall and a
|
||||
* growl, and it needs a Teletype.
|
||||
*/
|
||||
|
||||
const NOTE_INDEX: Record<string, number> = {
|
||||
C: 0, 'C#': 1, D: 2, 'D#': 3, E: 4, F: 5,
|
||||
'F#': 6, G: 7, 'G#': 8, A: 9, 'A#': 10, B: 11,
|
||||
}
|
||||
|
||||
/** "C#4" -> Hz. A4 = 440. */
|
||||
export function noteToFreq(note: string): number {
|
||||
const m = /^([A-G]#?)(-?\d)$/.exec(note)
|
||||
if (!m) return 0
|
||||
const semis = NOTE_INDEX[m[1]] + (Number(m[2]) + 1) * 12
|
||||
return 440 * Math.pow(2, (semis - 69) / 12)
|
||||
}
|
||||
|
||||
export type Wave = 'pulse12' | 'pulse25' | 'pulse50' | 'triangle' | 'saw' | 'noise'
|
||||
|
||||
export interface Track {
|
||||
wave: Wave
|
||||
gain: number
|
||||
/**
|
||||
* One entry per step. '.' rest, '=' sustain previous, otherwise a note.
|
||||
* Slashes stack notes into a chord: "F4/A4/C5". On a noise track the
|
||||
* letter picks the drum: K kick, S snare, C clap, H closed hat, O open hat.
|
||||
*/
|
||||
notes: string[]
|
||||
/** Sweeping lowpass, the whole point of a funk bass. */
|
||||
filter?: { from: number; to: number; q?: number }
|
||||
/** Fraction of the note's length actually sounded; low values are stabs. */
|
||||
gate?: number
|
||||
/** Cents, for a fatter unison. */
|
||||
detune?: number
|
||||
}
|
||||
|
||||
export interface Tune {
|
||||
bpm: number
|
||||
stepsPerBeat: number
|
||||
tracks: Track[]
|
||||
/** 0 is straight, ~0.15 is a light funk shuffle. Delays every other step. */
|
||||
swing?: number
|
||||
}
|
||||
|
||||
/** Fourier series for a pulse wave of the given duty cycle. */
|
||||
function pulseWave(ctx: AudioContext, duty: number, harmonics = 24): PeriodicWave {
|
||||
const real = new Float32Array(harmonics + 1)
|
||||
const imag = new Float32Array(harmonics + 1)
|
||||
for (let n = 1; n <= harmonics; n++) {
|
||||
imag[n] = (2 / (n * Math.PI)) * Math.sin(n * Math.PI * duty)
|
||||
}
|
||||
return ctx.createPeriodicWave(real, imag, { disableNormalization: false })
|
||||
}
|
||||
|
||||
/**
|
||||
* The dial-in, as a schedule rather than a number buried in the audio.
|
||||
*
|
||||
* The boot screen prints what is happening while it happens, so both have to
|
||||
* agree about when each stage starts -- and the only way to keep two clocks in
|
||||
* step is to have one clock. All values are seconds from the moment of DIAL.
|
||||
*/
|
||||
export const DIAL_UP = {
|
||||
/** 5551971. The last four are the year, which is the only joke in here. */
|
||||
number: [5, 5, 5, 1, 9, 7, 1],
|
||||
dialTone: 0.62,
|
||||
ringAt: 2.0,
|
||||
ring: 1.0,
|
||||
answerAt: 3.35,
|
||||
answer: 0.95,
|
||||
total: 5.5,
|
||||
} as const
|
||||
|
||||
export class Synth {
|
||||
private ctx: AudioContext | null = null
|
||||
private master!: GainNode
|
||||
private musicBus!: GainNode
|
||||
private sfxBus!: GainNode
|
||||
private waves: Partial<Record<Wave, PeriodicWave>> = {}
|
||||
private noiseBuffer!: AudioBuffer
|
||||
|
||||
private tune: Tune | null = null
|
||||
private step = 0
|
||||
private nextStepTime = 0
|
||||
private timer: number | null = null
|
||||
|
||||
musicOn = true
|
||||
sfxOn = true
|
||||
|
||||
/** Must be called from a user gesture the first time. */
|
||||
ensure(): AudioContext {
|
||||
if (this.ctx) {
|
||||
if (this.ctx.state === 'suspended') void this.ctx.resume()
|
||||
return this.ctx
|
||||
}
|
||||
const ctx = new AudioContext()
|
||||
this.ctx = ctx
|
||||
this.master = ctx.createGain()
|
||||
this.master.gain.value = 0.5
|
||||
this.master.connect(ctx.destination)
|
||||
|
||||
this.musicBus = ctx.createGain()
|
||||
this.musicBus.gain.value = 0.55
|
||||
this.musicBus.connect(this.master)
|
||||
|
||||
this.sfxBus = ctx.createGain()
|
||||
this.sfxBus.gain.value = 0.9
|
||||
this.sfxBus.connect(this.master)
|
||||
|
||||
this.waves.pulse12 = pulseWave(ctx, 0.125)
|
||||
this.waves.pulse25 = pulseWave(ctx, 0.25)
|
||||
this.waves.pulse50 = pulseWave(ctx, 0.5)
|
||||
|
||||
const len = Math.floor(ctx.sampleRate * 1.5)
|
||||
const buf = ctx.createBuffer(1, len, ctx.sampleRate)
|
||||
const data = buf.getChannelData(0)
|
||||
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1
|
||||
this.noiseBuffer = buf
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
setMusic(on: boolean) {
|
||||
this.musicOn = on
|
||||
if (!this.ctx) return
|
||||
this.musicBus.gain.setTargetAtTime(on ? 0.55 : 0, this.ctx.currentTime, 0.05)
|
||||
}
|
||||
|
||||
setSfx(on: boolean) {
|
||||
this.sfxOn = on
|
||||
if (!this.ctx) return
|
||||
this.sfxBus.gain.setTargetAtTime(on ? 0.9 : 0, this.ctx.currentTime, 0.02)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- voices
|
||||
|
||||
private voice(
|
||||
dest: AudioNode,
|
||||
wave: Wave,
|
||||
freq: number,
|
||||
at: number,
|
||||
dur: number,
|
||||
gain: number,
|
||||
opts: { filter?: Track['filter']; detune?: number } = {},
|
||||
) {
|
||||
const ctx = this.ensure()
|
||||
const osc = ctx.createOscillator()
|
||||
if (wave === 'triangle') osc.type = 'triangle'
|
||||
else if (wave === 'saw') osc.type = 'sawtooth'
|
||||
else osc.setPeriodicWave(this.waves[wave] ?? this.waves.pulse50!)
|
||||
osc.frequency.setValueAtTime(freq, at)
|
||||
if (opts.detune) osc.detune.setValueAtTime(opts.detune, at)
|
||||
|
||||
const env = ctx.createGain()
|
||||
const peak = Math.max(0.0001, gain)
|
||||
env.gain.setValueAtTime(0.0001, at)
|
||||
env.gain.exponentialRampToValueAtTime(peak, at + 0.008)
|
||||
env.gain.setValueAtTime(peak, at + Math.max(0.02, dur * 0.6))
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, at + dur)
|
||||
|
||||
let node: AudioNode = osc
|
||||
if (opts.filter) {
|
||||
const lp = ctx.createBiquadFilter()
|
||||
lp.type = 'lowpass'
|
||||
lp.Q.value = opts.filter.q ?? 6
|
||||
lp.frequency.setValueAtTime(opts.filter.from, at)
|
||||
lp.frequency.exponentialRampToValueAtTime(
|
||||
Math.max(60, opts.filter.to),
|
||||
at + Math.max(0.05, dur),
|
||||
)
|
||||
osc.connect(lp)
|
||||
node = lp
|
||||
}
|
||||
|
||||
node.connect(env).connect(dest)
|
||||
osc.start(at)
|
||||
osc.stop(at + dur + 0.02)
|
||||
}
|
||||
|
||||
/** A small kit: pitched-sine kick, noise-and-tone snare, clap, two hats. */
|
||||
private drum(dest: AudioNode, kind: string, at: number, gain: number) {
|
||||
const ctx = this.ensure()
|
||||
|
||||
if (kind === 'K') {
|
||||
const osc = ctx.createOscillator()
|
||||
osc.type = 'sine'
|
||||
osc.frequency.setValueAtTime(130, at)
|
||||
osc.frequency.exponentialRampToValueAtTime(42, at + 0.11)
|
||||
const env = ctx.createGain()
|
||||
env.gain.setValueAtTime(gain * 1.5, at)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, at + 0.24)
|
||||
osc.connect(env).connect(dest)
|
||||
osc.start(at)
|
||||
osc.stop(at + 0.26)
|
||||
return
|
||||
}
|
||||
|
||||
const noise = ctx.createBufferSource()
|
||||
noise.buffer = this.noiseBuffer
|
||||
const filter = ctx.createBiquadFilter()
|
||||
const env = ctx.createGain()
|
||||
let dur = 0.05
|
||||
|
||||
if (kind === 'S' || kind === 'C') {
|
||||
filter.type = 'bandpass'
|
||||
filter.frequency.value = kind === 'S' ? 1900 : 1300
|
||||
filter.Q.value = kind === 'S' ? 0.9 : 2.4
|
||||
dur = kind === 'S' ? 0.16 : 0.1
|
||||
if (kind === 'S') {
|
||||
// A little body under the crack.
|
||||
const tone = ctx.createOscillator()
|
||||
tone.type = 'triangle'
|
||||
tone.frequency.setValueAtTime(210, at)
|
||||
tone.frequency.exponentialRampToValueAtTime(150, at + 0.09)
|
||||
const tenv = ctx.createGain()
|
||||
tenv.gain.setValueAtTime(gain * 0.6, at)
|
||||
tenv.gain.exponentialRampToValueAtTime(0.0001, at + 0.1)
|
||||
tone.connect(tenv).connect(dest)
|
||||
tone.start(at)
|
||||
tone.stop(at + 0.12)
|
||||
}
|
||||
} else {
|
||||
filter.type = 'highpass'
|
||||
filter.frequency.value = 7200
|
||||
dur = kind === 'O' ? 0.22 : 0.035
|
||||
}
|
||||
|
||||
env.gain.setValueAtTime(gain, at)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, at + dur)
|
||||
noise.connect(filter).connect(env).connect(dest)
|
||||
noise.start(at)
|
||||
noise.stop(at + dur + 0.02)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- sequencer
|
||||
|
||||
playTune(tune: Tune, restart = true) {
|
||||
this.ensure()
|
||||
if (this.tune === tune && this.timer !== null && !restart) return
|
||||
this.stopTune()
|
||||
this.tune = tune
|
||||
this.step = 0
|
||||
this.nextStepTime = this.ctx!.currentTime + 0.08
|
||||
this.timer = window.setInterval(() => this.schedule(), 25)
|
||||
}
|
||||
|
||||
stopTune() {
|
||||
if (this.timer !== null) window.clearInterval(this.timer)
|
||||
this.timer = null
|
||||
this.tune = null
|
||||
}
|
||||
|
||||
get playing() {
|
||||
return this.timer !== null
|
||||
}
|
||||
|
||||
private schedule() {
|
||||
const ctx = this.ctx
|
||||
const tune = this.tune
|
||||
if (!ctx || !tune) return
|
||||
const stepDur = 60 / tune.bpm / tune.stepsPerBeat
|
||||
const length = Math.max(...tune.tracks.map((t) => t.notes.length))
|
||||
const swing = tune.swing ?? 0
|
||||
|
||||
while (this.nextStepTime < ctx.currentTime + 0.2) {
|
||||
// A shuffle pushes every other step late without moving the downbeats.
|
||||
const at = this.nextStepTime + (this.step % 2 === 1 ? swing * stepDur : 0)
|
||||
|
||||
for (const track of tune.tracks) {
|
||||
const note = track.notes[this.step % track.notes.length]
|
||||
if (!note || note === '.' || note === '=') continue
|
||||
|
||||
// A note runs until the next step that is not a sustain marker.
|
||||
let held = 1
|
||||
for (let i = 1; i < length; i++) {
|
||||
if (track.notes[(this.step + i) % track.notes.length] === '=') held++
|
||||
else break
|
||||
}
|
||||
const dur = held * stepDur * (track.gate ?? 0.95)
|
||||
|
||||
if (track.wave === 'noise') {
|
||||
this.drum(this.musicBus, note, at, track.gain)
|
||||
continue
|
||||
}
|
||||
|
||||
for (const part of note.split('/')) {
|
||||
const f = noteToFreq(part)
|
||||
if (!f) continue
|
||||
this.voice(this.musicBus, track.wave, f, at, dur, track.gain, {
|
||||
filter: track.filter,
|
||||
detune: track.detune,
|
||||
})
|
||||
}
|
||||
}
|
||||
this.nextStepTime += stepDur
|
||||
this.step = (this.step + 1) % length
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ sfx
|
||||
|
||||
private seq(notes: [string, number][], wave: Wave = 'pulse25', gain = 0.22) {
|
||||
const ctx = this.ensure()
|
||||
let t = ctx.currentTime + 0.01
|
||||
for (const [note, dur] of notes) {
|
||||
if (note !== '.') this.voice(this.sfxBus, wave, noteToFreq(note), t, dur, gain)
|
||||
t += dur
|
||||
}
|
||||
}
|
||||
|
||||
blip() {
|
||||
this.seq([['E5', 0.05]], 'pulse12', 0.15)
|
||||
}
|
||||
|
||||
select() {
|
||||
this.seq([['C5', 0.05], ['G5', 0.09]], 'pulse25', 0.18)
|
||||
}
|
||||
|
||||
/** A room that will not take you: the listing just said NOT POSSIBLE. */
|
||||
reject() {
|
||||
this.seq([['A3', 0.09], ['E3', 0.16]], 'saw', 0.2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Warp. The engines wind up, hold, and drop you somewhere else.
|
||||
*
|
||||
* A detuned pair of saws swept up through a closing lowpass, with the
|
||||
* filter doing most of the work -- an oscillator sweep alone reads as a
|
||||
* slide whistle, and the difference between those two things is the whole
|
||||
* character of the sound.
|
||||
*/
|
||||
warp(sectors = 8) {
|
||||
const ctx = this.ensure()
|
||||
const at = ctx.currentTime + 0.01
|
||||
const dur = Math.min(1.1, 0.35 + sectors * 0.02)
|
||||
|
||||
const lp = ctx.createBiquadFilter()
|
||||
lp.type = 'lowpass'
|
||||
lp.Q.value = 6
|
||||
lp.frequency.setValueAtTime(220, at)
|
||||
lp.frequency.exponentialRampToValueAtTime(2400, at + dur * 0.6)
|
||||
lp.frequency.exponentialRampToValueAtTime(300, at + dur)
|
||||
|
||||
const env = ctx.createGain()
|
||||
env.gain.setValueAtTime(0.0001, at)
|
||||
env.gain.exponentialRampToValueAtTime(0.22, at + 0.08)
|
||||
env.gain.setValueAtTime(0.22, at + dur * 0.7)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, at + dur)
|
||||
lp.connect(env).connect(this.sfxBus)
|
||||
|
||||
for (const detune of [-9, 9]) {
|
||||
const osc = ctx.createOscillator()
|
||||
osc.type = 'sawtooth'
|
||||
osc.detune.value = detune
|
||||
osc.frequency.setValueAtTime(72, at)
|
||||
osc.frequency.exponentialRampToValueAtTime(148, at + dur * 0.6)
|
||||
osc.frequency.exponentialRampToValueAtTime(96, at + dur)
|
||||
osc.connect(lp)
|
||||
osc.start(at)
|
||||
osc.stop(at + dur + 0.05)
|
||||
}
|
||||
}
|
||||
|
||||
/** The beams: energy leaving the ship, all of it at once, downward. */
|
||||
beams() {
|
||||
const ctx = this.ensure()
|
||||
const at = ctx.currentTime + 0.01
|
||||
|
||||
const band = ctx.createBiquadFilter()
|
||||
band.type = 'bandpass'
|
||||
band.Q.value = 5
|
||||
band.frequency.setValueAtTime(3200, at)
|
||||
band.frequency.exponentialRampToValueAtTime(420, at + 0.5)
|
||||
|
||||
const env = ctx.createGain()
|
||||
env.gain.setValueAtTime(0.0001, at)
|
||||
env.gain.exponentialRampToValueAtTime(0.26, at + 0.02)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, at + 0.55)
|
||||
band.connect(env).connect(this.sfxBus)
|
||||
|
||||
const osc = ctx.createOscillator()
|
||||
osc.type = 'sawtooth'
|
||||
osc.frequency.setValueAtTime(880, at)
|
||||
osc.frequency.exponentialRampToValueAtTime(180, at + 0.5)
|
||||
osc.connect(band)
|
||||
osc.start(at)
|
||||
osc.stop(at + 0.6)
|
||||
|
||||
const hiss = ctx.createBufferSource()
|
||||
hiss.buffer = this.noiseBuffer
|
||||
hiss.connect(band)
|
||||
hiss.start(at)
|
||||
hiss.stop(at + 0.6)
|
||||
}
|
||||
|
||||
/** A torpedo away: the tube thumps, then something small leaves fast. */
|
||||
torpedo() {
|
||||
const ctx = this.ensure()
|
||||
const at = ctx.currentTime + 0.01
|
||||
|
||||
const thump = ctx.createOscillator()
|
||||
thump.type = 'sine'
|
||||
thump.frequency.setValueAtTime(160, at)
|
||||
thump.frequency.exponentialRampToValueAtTime(48, at + 0.14)
|
||||
const tenv = ctx.createGain()
|
||||
tenv.gain.setValueAtTime(0.3, at)
|
||||
tenv.gain.exponentialRampToValueAtTime(0.0001, at + 0.16)
|
||||
thump.connect(tenv).connect(this.sfxBus)
|
||||
thump.start(at)
|
||||
thump.stop(at + 0.18)
|
||||
|
||||
const src = ctx.createBufferSource()
|
||||
src.buffer = this.noiseBuffer
|
||||
const band = ctx.createBiquadFilter()
|
||||
band.type = 'bandpass'
|
||||
band.Q.value = 8
|
||||
band.frequency.setValueAtTime(700, at + 0.04)
|
||||
band.frequency.exponentialRampToValueAtTime(3400, at + 0.42)
|
||||
const env = ctx.createGain()
|
||||
env.gain.setValueAtTime(0.0001, at + 0.04)
|
||||
env.gain.exponentialRampToValueAtTime(0.13, at + 0.1)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, at + 0.45)
|
||||
src.connect(band).connect(env).connect(this.sfxBus)
|
||||
src.start(at)
|
||||
src.stop(at + 0.5)
|
||||
}
|
||||
|
||||
/** Something comes apart. Noise, opened wide and then closed down on. */
|
||||
explode() {
|
||||
const ctx = this.ensure()
|
||||
const at = ctx.currentTime + 0.01
|
||||
|
||||
const src = ctx.createBufferSource()
|
||||
src.buffer = this.noiseBuffer
|
||||
src.playbackRate.value = 0.7
|
||||
|
||||
const lp = ctx.createBiquadFilter()
|
||||
lp.type = 'lowpass'
|
||||
lp.Q.value = 1.5
|
||||
lp.frequency.setValueAtTime(4200, at)
|
||||
lp.frequency.exponentialRampToValueAtTime(180, at + 0.9)
|
||||
|
||||
const env = ctx.createGain()
|
||||
env.gain.setValueAtTime(0.0001, at)
|
||||
env.gain.exponentialRampToValueAtTime(0.34, at + 0.03)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, at + 0.95)
|
||||
|
||||
src.connect(lp).connect(env).connect(this.sfxBus)
|
||||
src.start(at)
|
||||
src.stop(at + 1)
|
||||
|
||||
// The body of it: a low sine dropping away underneath the noise.
|
||||
const boom = ctx.createOscillator()
|
||||
boom.type = 'sine'
|
||||
boom.frequency.setValueAtTime(110, at)
|
||||
boom.frequency.exponentialRampToValueAtTime(34, at + 0.6)
|
||||
const benv = ctx.createGain()
|
||||
benv.gain.setValueAtTime(0.26, at)
|
||||
benv.gain.exponentialRampToValueAtTime(0.0001, at + 0.7)
|
||||
boom.connect(benv).connect(this.sfxBus)
|
||||
boom.start(at)
|
||||
boom.stop(at + 0.75)
|
||||
}
|
||||
|
||||
/** Taking a hit. A hard knock on the hull and the shields ringing after. */
|
||||
hit() {
|
||||
const ctx = this.ensure()
|
||||
const at = ctx.currentTime + 0.01
|
||||
|
||||
const knock = ctx.createBufferSource()
|
||||
knock.buffer = this.noiseBuffer
|
||||
knock.playbackRate.value = 0.5
|
||||
const lp = ctx.createBiquadFilter()
|
||||
lp.type = 'lowpass'
|
||||
lp.frequency.setValueAtTime(1400, at)
|
||||
lp.frequency.exponentialRampToValueAtTime(200, at + 0.25)
|
||||
const env = ctx.createGain()
|
||||
env.gain.setValueAtTime(0.26, at)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, at + 0.3)
|
||||
knock.connect(lp).connect(env).connect(this.sfxBus)
|
||||
knock.start(at)
|
||||
knock.stop(at + 0.32)
|
||||
|
||||
const ring = ctx.createOscillator()
|
||||
ring.type = 'triangle'
|
||||
ring.frequency.setValueAtTime(520, at)
|
||||
ring.frequency.exponentialRampToValueAtTime(360, at + 0.35)
|
||||
const renv = ctx.createGain()
|
||||
renv.gain.setValueAtTime(0.0001, at)
|
||||
renv.gain.exponentialRampToValueAtTime(0.09, at + 0.02)
|
||||
renv.gain.exponentialRampToValueAtTime(0.0001, at + 0.38)
|
||||
ring.connect(renv).connect(this.sfxBus)
|
||||
ring.start(at)
|
||||
ring.stop(at + 0.4)
|
||||
}
|
||||
|
||||
/**
|
||||
* The klaxon. Two tones, alternating, and no attempt at subtlety -- this
|
||||
* plays when you come out of warp into a quadrant with something in it,
|
||||
* which is the one moment the game wants you to look up.
|
||||
*/
|
||||
alert() {
|
||||
const ctx = this.ensure()
|
||||
const at = ctx.currentTime + 0.01
|
||||
for (const [i, f] of [740, 560, 740, 560].entries()) {
|
||||
const t = at + i * 0.17
|
||||
const osc = ctx.createOscillator()
|
||||
osc.type = 'square'
|
||||
osc.frequency.value = f
|
||||
const lp = ctx.createBiquadFilter()
|
||||
lp.type = 'lowpass'
|
||||
lp.frequency.value = 1800
|
||||
const env = ctx.createGain()
|
||||
env.gain.setValueAtTime(0.0001, t)
|
||||
env.gain.exponentialRampToValueAtTime(0.13, t + 0.02)
|
||||
env.gain.setValueAtTime(0.13, t + 0.12)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, t + 0.16)
|
||||
osc.connect(lp).connect(env).connect(this.sfxBus)
|
||||
osc.start(t)
|
||||
osc.stop(t + 0.18)
|
||||
}
|
||||
}
|
||||
|
||||
/** A device going. A short buzz falling off a cliff. */
|
||||
damaged() {
|
||||
this.seq([['D#4', 0.09], ['C#4', 0.09], ['A3', 0.22]], 'saw', 0.16)
|
||||
}
|
||||
|
||||
/** Mooring. Two clean tones up, and the clunk of being made fast. */
|
||||
dock() {
|
||||
this.seq([['G4', 0.1], ['C5', 0.1], ['.', 0.05], ['C3', 0.22]], 'triangle', 0.2)
|
||||
}
|
||||
|
||||
/** The kill. */
|
||||
fanfare() {
|
||||
this.seq(
|
||||
[['C5', 0.11], ['E5', 0.11], ['G5', 0.11], ['C6', 0.11], ['G5', 0.11], ['C6', 0.4]],
|
||||
'pulse25',
|
||||
0.2,
|
||||
)
|
||||
}
|
||||
|
||||
sad() {
|
||||
this.seq([['G4', 0.12], ['F#4', 0.12], ['F4', 0.12], ['E4', 0.4]], 'triangle', 0.22)
|
||||
}
|
||||
|
||||
/** The Teletype's key-click. */
|
||||
beep() {
|
||||
this.seq([['A5', 0.06]], 'pulse50', 0.14)
|
||||
}
|
||||
|
||||
/**
|
||||
* The print head.
|
||||
*
|
||||
* An ASR-33 printed ten characters a second and you could hear every one of
|
||||
* them: a solenoid thump with a little bandpassed clatter on top. Called
|
||||
* once per character while a line is being typed out, so the transcript
|
||||
* sounds like it is being printed rather than appearing.
|
||||
*/
|
||||
print() {
|
||||
const ctx = this.ensure()
|
||||
const at = ctx.currentTime + 0.001
|
||||
|
||||
const thump = ctx.createOscillator()
|
||||
thump.type = 'square'
|
||||
thump.frequency.setValueAtTime(320 + Math.random() * 90, at)
|
||||
const tenv = ctx.createGain()
|
||||
tenv.gain.setValueAtTime(0.05, at)
|
||||
tenv.gain.exponentialRampToValueAtTime(0.0001, at + 0.02)
|
||||
thump.connect(tenv).connect(this.sfxBus)
|
||||
thump.start(at)
|
||||
thump.stop(at + 0.03)
|
||||
|
||||
const clack = ctx.createBufferSource()
|
||||
clack.buffer = this.noiseBuffer
|
||||
clack.playbackRate.value = 0.8 + Math.random() * 0.5
|
||||
const band = ctx.createBiquadFilter()
|
||||
band.type = 'bandpass'
|
||||
band.frequency.value = 2600
|
||||
band.Q.value = 1.2
|
||||
const cenv = ctx.createGain()
|
||||
cenv.gain.setValueAtTime(0.06, at)
|
||||
cenv.gain.exponentialRampToValueAtTime(0.0001, at + 0.018)
|
||||
clack.connect(band).connect(cenv).connect(this.sfxBus)
|
||||
clack.start(at)
|
||||
clack.stop(at + 0.03)
|
||||
}
|
||||
|
||||
/** Carriage return and line feed: the head slams back, the platen turns. */
|
||||
carriage() {
|
||||
const ctx = this.ensure()
|
||||
const at = ctx.currentTime + 0.005
|
||||
const src = ctx.createBufferSource()
|
||||
src.buffer = this.noiseBuffer
|
||||
src.playbackRate.value = 0.55
|
||||
const band = ctx.createBiquadFilter()
|
||||
band.type = 'bandpass'
|
||||
band.frequency.setValueAtTime(1500, at)
|
||||
band.frequency.exponentialRampToValueAtTime(600, at + 0.13)
|
||||
band.Q.value = 0.8
|
||||
const env = ctx.createGain()
|
||||
env.gain.setValueAtTime(0.11, at)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, at + 0.15)
|
||||
src.connect(band).connect(env).connect(this.sfxBus)
|
||||
src.start(at)
|
||||
src.stop(at + 0.17)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialling in, properly.
|
||||
*
|
||||
* The sound everybody means by "modem" is a V.32/V.34 handshake from the
|
||||
* 1990s -- the ascending probe tones and the long grinding echo-cancel
|
||||
* training. None of that existed in 1971 and none of it could have: this
|
||||
* game was played on a printing terminal at 110 baud through an acoustic
|
||||
* coupler,
|
||||
* two rubber cups you pushed a telephone handset into. What that sounded
|
||||
* like is Bell 103, and Bell 103 is a much quieter animal.
|
||||
*
|
||||
* So this is the real sequence, in order, at the real frequencies:
|
||||
*
|
||||
* 1. Dial tone. North American precise tone plan: 350 Hz + 440 Hz.
|
||||
* 2. Dialling. Touch-Tone, which existed from 1963 -- each digit is one
|
||||
* row tone and one column tone from the DTMF grid, together.
|
||||
* 3. Ringback. 440 Hz + 480 Hz, the same pair the far end hears as ring.
|
||||
* 4. The answer tone. The far modem puts up its mark frequency, 2225 Hz,
|
||||
* and holds it while the near end decides it has found a friend.
|
||||
* 5. Data, both ways at once, which is the part people forget. Bell 103
|
||||
* is full duplex on two separate channels: the originating end keys
|
||||
* 1070 Hz for a space and 1270 Hz for a mark, the answering end 2025
|
||||
* and 2225. Both are chattering simultaneously and the beating
|
||||
* between them is most of the texture.
|
||||
*
|
||||
* Everything runs through a 300-3400 Hz telephone band, because that is all
|
||||
* a phone line passes, with a peak around 900 Hz and a good deal of hiss for
|
||||
* the two rubber cups and the room they are sitting in.
|
||||
*/
|
||||
dialUp(): () => void {
|
||||
const ctx = this.ensure()
|
||||
const t0 = ctx.currentTime + 0.03
|
||||
const stop: { stop: (when?: number) => void }[] = []
|
||||
|
||||
// --- the telephone line itself ------------------------------------
|
||||
const line = ctx.createGain()
|
||||
line.gain.value = 1
|
||||
const hp = ctx.createBiquadFilter()
|
||||
hp.type = 'highpass'
|
||||
hp.frequency.value = 300
|
||||
const lp = ctx.createBiquadFilter()
|
||||
lp.type = 'lowpass'
|
||||
lp.frequency.value = 3400
|
||||
// The handset in the cups: a resonance, not a clean channel.
|
||||
const cup = ctx.createBiquadFilter()
|
||||
cup.type = 'peaking'
|
||||
cup.frequency.value = 900
|
||||
cup.Q.value = 1.1
|
||||
cup.gain.value = 5
|
||||
line.connect(hp).connect(lp).connect(cup).connect(this.sfxBus)
|
||||
|
||||
/** A pure tone on the line for a fixed window. */
|
||||
const tone = (freq: number, from: number, to: number, gain: number) => {
|
||||
const osc = ctx.createOscillator()
|
||||
osc.type = 'sine'
|
||||
osc.frequency.value = freq
|
||||
const env = ctx.createGain()
|
||||
env.gain.setValueAtTime(0.0001, from)
|
||||
env.gain.exponentialRampToValueAtTime(gain, from + 0.012)
|
||||
env.gain.setValueAtTime(gain, to - 0.012)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, to)
|
||||
osc.connect(env).connect(line)
|
||||
osc.start(from)
|
||||
osc.stop(to + 0.02)
|
||||
stop.push(osc)
|
||||
}
|
||||
|
||||
// --- 1. dial tone --------------------------------------------------
|
||||
const DIAL_END = t0 + DIAL_UP.dialTone
|
||||
tone(350, t0, DIAL_END, 0.075)
|
||||
tone(440, t0, DIAL_END, 0.075)
|
||||
|
||||
// --- 2. Touch-Tone dialling ----------------------------------------
|
||||
const ROW = [941, 697, 697, 697, 770, 770, 770, 852, 852, 852]
|
||||
const COL = [1336, 1209, 1336, 1477, 1209, 1336, 1477, 1209, 1336, 1477]
|
||||
let at = DIAL_END + 0.12
|
||||
for (const digit of DIAL_UP.number) {
|
||||
const end = at + 0.09
|
||||
tone(ROW[digit]!, at, end, 0.085)
|
||||
tone(COL[digit]!, at, end, 0.085)
|
||||
at = end + 0.055
|
||||
}
|
||||
|
||||
// --- 3. ringback ---------------------------------------------------
|
||||
const RING_START = t0 + DIAL_UP.ringAt
|
||||
const RING_END = RING_START + DIAL_UP.ring
|
||||
tone(440, RING_START, RING_END, 0.07)
|
||||
tone(480, RING_START, RING_END, 0.07)
|
||||
|
||||
// --- 4. the far end answers ----------------------------------------
|
||||
const ANSWER_START = t0 + DIAL_UP.answerAt
|
||||
const ANSWER_END = ANSWER_START + DIAL_UP.answer
|
||||
tone(2225, ANSWER_START, ANSWER_END, 0.055)
|
||||
|
||||
// --- 5. both ends talking at once ----------------------------------
|
||||
/*
|
||||
* One oscillator per direction, keyed bit by bit. 110 baud is 9.09 ms a
|
||||
* bit, and the bits are random because what is actually going down the
|
||||
* line at this point is a login banner nobody has typed yet -- scrambled
|
||||
* to the ear either way.
|
||||
*/
|
||||
const DATA_START = ANSWER_END - 0.1
|
||||
const DATA_END = t0 + DIAL_UP.total
|
||||
const BIT = 1 / 110
|
||||
|
||||
const channel = (mark: number, space: number, gain: number) => {
|
||||
const osc = ctx.createOscillator()
|
||||
osc.type = 'sine'
|
||||
osc.frequency.setValueAtTime(mark, DATA_START)
|
||||
for (let t = DATA_START; t < DATA_END; t += BIT) {
|
||||
osc.frequency.setValueAtTime(Math.random() < 0.5 ? mark : space, t)
|
||||
}
|
||||
const env = ctx.createGain()
|
||||
env.gain.setValueAtTime(0.0001, DATA_START)
|
||||
env.gain.exponentialRampToValueAtTime(gain, DATA_START + 0.05)
|
||||
env.gain.setValueAtTime(gain, DATA_END - 0.08)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, DATA_END)
|
||||
osc.connect(env).connect(line)
|
||||
osc.start(DATA_START)
|
||||
osc.stop(DATA_END + 0.02)
|
||||
stop.push(osc)
|
||||
}
|
||||
|
||||
channel(1270, 1070, 0.05) // originating end: the coupler on this desk
|
||||
channel(2225, 2025, 0.042) // answering end: the machine at the other
|
||||
|
||||
// --- line noise, under all of it -----------------------------------
|
||||
const hiss = ctx.createBufferSource()
|
||||
hiss.buffer = this.noiseBuffer
|
||||
hiss.loop = true
|
||||
const hissGain = ctx.createGain()
|
||||
hissGain.gain.setValueAtTime(0.012, t0)
|
||||
// The line gets noisier once it is carrying something.
|
||||
hissGain.gain.setValueAtTime(0.012, DATA_START)
|
||||
hissGain.gain.linearRampToValueAtTime(0.03, DATA_START + 0.2)
|
||||
hissGain.gain.linearRampToValueAtTime(0.0001, DATA_END)
|
||||
hiss.connect(hissGain).connect(line)
|
||||
hiss.start(t0)
|
||||
hiss.stop(DATA_END + 0.05)
|
||||
stop.push(hiss)
|
||||
|
||||
return () => {
|
||||
for (const node of stop) {
|
||||
try {
|
||||
node.stop()
|
||||
} catch {
|
||||
// Already stopped; nothing to do.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const synth = new Synth()
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DEEP_END, DEEP_PATROL, DEEP_TITLE } from './deepspace'
|
||||
import { noteToFreq, type Tune } from './synth'
|
||||
import { END_TUNE, PATROL_TUNE, TITLE_TUNE } from './tunes'
|
||||
|
||||
/**
|
||||
* The music cannot be listened to by a test, but it can be proved to be
|
||||
* playable, which is the half that fails silently.
|
||||
*
|
||||
* A mistyped note name does not throw: `noteToFreq` returns 0, the oscillator
|
||||
* is set to 0 Hz, and that voice simply is not there for the rest of the
|
||||
* piece. You would have to notice a missing inner part by ear, in a tune you
|
||||
* have heard forty times while testing something else. So every step of every
|
||||
* track is checked here instead.
|
||||
*/
|
||||
|
||||
const KIT = new Set(['K', 'S', 'C', 'H', 'O'])
|
||||
const TUNES: [string, Tune][] = [
|
||||
['TITLE_TUNE', TITLE_TUNE],
|
||||
['PATROL_TUNE', PATROL_TUNE],
|
||||
['END_TUNE', END_TUNE],
|
||||
['DEEP_TITLE', DEEP_TITLE],
|
||||
['DEEP_PATROL', DEEP_PATROL],
|
||||
['DEEP_END', DEEP_END],
|
||||
]
|
||||
|
||||
describe.each(TUNES)('%s', (_name, tune) => {
|
||||
it('names a note the synth can find, on every step of every track', () => {
|
||||
for (const track of tune.tracks) {
|
||||
for (const step of track.notes) {
|
||||
if (step === '.' || step === '=') continue
|
||||
if (track.wave === 'noise') {
|
||||
expect(KIT).toContain(step)
|
||||
continue
|
||||
}
|
||||
// Chords are stacked with slashes; every voice in one has to parse.
|
||||
for (const note of step.split('/')) {
|
||||
expect(noteToFreq(note), `${note} in ${step}`).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('is the same length on every track, so the loop does not drift', () => {
|
||||
const lengths = new Set(tune.tracks.map((t) => t.notes.length))
|
||||
expect(lengths.size).toBe(1)
|
||||
})
|
||||
|
||||
it('starts on a note rather than a tie', () => {
|
||||
// '=' sustains the previous step, and there is no previous step at zero.
|
||||
for (const track of tune.tracks) expect(track.notes[0]).not.toBe('=')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { Tune } from './synth'
|
||||
|
||||
/**
|
||||
* The 1971 skin's music, and a word about what it is honestly claiming.
|
||||
*
|
||||
* The original was silent. It ran on a mainframe through a printing
|
||||
* terminal, and a printing terminal's only instrument is its own print head
|
||||
* -- which this game does play, in `synth.print()`. So a chiptune here is not
|
||||
* a restoration of anything, and is not pretending to be.
|
||||
*
|
||||
* What it is instead: the music the game would have got when it followed
|
||||
* everybody home. The listing spread through Creative Computing and was typed
|
||||
* into every machine of the next decade, and somebody porting a space game to
|
||||
* a home computer in 1981 had three channels and a noise generator and every
|
||||
* intention of sounding like a film.
|
||||
*
|
||||
* So this is the same theme the remaster plays -- same key, same eight bars,
|
||||
* same chords underneath -- arranged for what that machine actually had. The
|
||||
* remaster is a remaster of the music too, which is the point: switching skin
|
||||
* re-scores the piece rather than changing the record.
|
||||
*
|
||||
* See `deepspace.ts` for the theme itself and for why an idiom is not a
|
||||
* quotation. Eighths, eight to a bar, D major.
|
||||
*/
|
||||
|
||||
const bar = (...notes: string[]) => notes
|
||||
|
||||
/** The theme, on the loudest voice the machine has. */
|
||||
export const TITLE_TUNE: Tune = {
|
||||
bpm: 132,
|
||||
stepsPerBeat: 2,
|
||||
tracks: [
|
||||
{
|
||||
wave: 'pulse25',
|
||||
gain: 0.16,
|
||||
notes: [
|
||||
...bar('.', '.', '.', 'A4', 'D5', '=', '=', '='),
|
||||
...bar('D5', '=', '=', '=', 'E5', '.', 'F#5', '.'),
|
||||
...bar('G5', '=', '=', '=', '=', '=', '.', '.'),
|
||||
...bar('F#5', '.', 'E5', '.', 'D5', '=', '=', '.'),
|
||||
...bar('.', '.', '.', 'A4', 'B4', '=', '=', '='),
|
||||
...bar('B4', '=', '=', '.', 'C#5', '.', 'D5', '.'),
|
||||
...bar('E5', '=', '=', '=', 'D5', '.', 'C#5', '.'),
|
||||
...bar('D5', '=', '=', '=', '=', '=', '=', '.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
/*
|
||||
* The strings, as a home computer had to do them: no chord voice, so
|
||||
* the chord is arpeggiated fast underneath and your ear assembles it.
|
||||
* This is the single most 1981 thing in the repository.
|
||||
*/
|
||||
wave: 'pulse12',
|
||||
gain: 0.05,
|
||||
notes: [
|
||||
...bar('D4', 'F#4', 'A4', 'F#4', 'D4', 'F#4', 'A4', 'F#4'),
|
||||
...bar('D4', 'F#4', 'A4', 'F#4', 'D4', 'F#4', 'A4', 'F#4'),
|
||||
...bar('G3', 'B3', 'D4', 'B3', 'G3', 'B3', 'D4', 'B3'),
|
||||
...bar('D4', 'F#4', 'A4', 'F#4', 'D4', 'F#4', 'A4', 'F#4'),
|
||||
...bar('B3', 'D4', 'F#4', 'D4', 'B3', 'D4', 'F#4', 'D4'),
|
||||
...bar('G3', 'B3', 'D4', 'B3', 'G3', 'B3', 'D4', 'B3'),
|
||||
...bar('A3', 'C#4', 'E4', 'C#4', 'A3', 'C#4', 'E4', 'C#4'),
|
||||
...bar('D4', 'F#4', 'A4', 'F#4', 'D4', 'F#4', 'A4', 'F#4'),
|
||||
],
|
||||
},
|
||||
{
|
||||
wave: 'triangle',
|
||||
gain: 0.21,
|
||||
notes: [
|
||||
...bar('D2', '.', 'D2', '.', 'A2', '.', 'D2', '.'),
|
||||
...bar('D2', '.', 'D2', '.', 'A2', '.', 'D2', '.'),
|
||||
...bar('G1', '.', 'G1', '.', 'D2', '.', 'G1', '.'),
|
||||
...bar('D2', '.', 'D2', '.', 'A2', '.', 'D2', '.'),
|
||||
...bar('B1', '.', 'B1', '.', 'F#2', '.', 'B1', '.'),
|
||||
...bar('G1', '.', 'G1', '.', 'D2', '.', 'G1', '.'),
|
||||
...bar('A1', '.', 'A1', '.', 'E2', '.', 'A1', '.'),
|
||||
...bar('D2', '.', 'A1', '.', 'D2', '=', '=', '.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
// The timpani, as noise, because that is what there was.
|
||||
wave: 'noise',
|
||||
gain: 0.34,
|
||||
notes: [
|
||||
...bar('K', '.', 'H', '.', 'K', '.', 'H', '.'),
|
||||
...bar('.', '.', 'H', '.', '.', '.', 'H', '.'),
|
||||
...bar('K', '.', 'H', '.', 'K', '.', 'H', '.'),
|
||||
...bar('.', '.', 'H', '.', '.', '.', 'H', 'H'),
|
||||
...bar('K', '.', 'H', '.', 'K', '.', 'H', '.'),
|
||||
...bar('.', '.', 'H', '.', '.', '.', 'H', '.'),
|
||||
...bar('K', '.', 'K', '.', 'K', '.', 'K', '.'),
|
||||
...bar('K', '.', '.', '.', 'S', '.', '.', '.'),
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* On patrol. Four bars, almost empty, with the horn call from the theme
|
||||
* every second bar and nothing else. Anything more would be unbearable by
|
||||
* the fourth warp.
|
||||
*/
|
||||
export const PATROL_TUNE: Tune = {
|
||||
bpm: 112,
|
||||
stepsPerBeat: 2,
|
||||
tracks: [
|
||||
{
|
||||
wave: 'pulse50',
|
||||
gain: 0.07,
|
||||
notes: [
|
||||
...bar('.', '.', '.', '.', '.', '.', 'A4', '.'),
|
||||
...bar('D5', '=', '=', '=', '.', '.', '.', '.'),
|
||||
...bar('.', '.', '.', '.', '.', '.', 'F#4', '.'),
|
||||
...bar('B4', '=', '=', '=', '.', '.', '.', '.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
wave: 'triangle',
|
||||
gain: 0.18,
|
||||
notes: [
|
||||
...bar('D2', '=', '=', '=', '.', '.', 'D2', '.'),
|
||||
...bar('D2', '=', '=', '=', '.', '.', 'A1', '.'),
|
||||
...bar('B1', '=', '=', '=', '.', '.', 'B1', '.'),
|
||||
...bar('G1', '=', '=', '=', '.', '.', 'G1', '.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
wave: 'noise',
|
||||
gain: 0.15,
|
||||
notes: Array.from({ length: 4 }, () =>
|
||||
bar('.', '.', 'H', '.', '.', '.', 'H', '.'),
|
||||
).flat(),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/** The end of it, either way: the same rising cadence the remaster ends on. */
|
||||
export const END_TUNE: Tune = {
|
||||
bpm: 120,
|
||||
stepsPerBeat: 2,
|
||||
tracks: [
|
||||
{
|
||||
wave: 'pulse25',
|
||||
gain: 0.16,
|
||||
notes: [
|
||||
...bar('D5', '=', '=', '=', '=', '=', '=', '.'),
|
||||
...bar('E5', '=', '=', '=', '=', '=', '=', '.'),
|
||||
...bar('F#5', '=', '=', '=', '=', '=', 'G5', '.'),
|
||||
...bar('A5', '=', '=', '=', '=', '=', '=', '.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
wave: 'pulse12',
|
||||
gain: 0.05,
|
||||
notes: [
|
||||
...bar('G3', 'B3', 'D4', 'B3', 'G3', 'B3', 'D4', 'B3'),
|
||||
...bar('A3', 'C#4', 'E4', 'C#4', 'A3', 'C#4', 'E4', 'C#4'),
|
||||
...bar('B3', 'D4', 'F#4', 'D4', 'B3', 'D4', 'F#4', 'D4'),
|
||||
...bar('D4', 'F#4', 'A4', 'F#4', 'D4', 'F#4', 'A4', 'F#4'),
|
||||
],
|
||||
},
|
||||
{
|
||||
wave: 'triangle',
|
||||
gain: 0.22,
|
||||
notes: [
|
||||
...bar('G1', '=', '=', '=', 'G1', '.', '.', '.'),
|
||||
...bar('A1', '=', '=', '=', 'A1', '.', '.', '.'),
|
||||
...bar('B1', '=', '=', '=', 'B1', '.', '.', '.'),
|
||||
...bar('D2', '=', '=', '=', '=', '=', '=', '.'),
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { condition, daysLeft } from '../game/engine'
|
||||
import type { Command, Line, Patrol } from '../game/types'
|
||||
import { useSkin } from '../skin'
|
||||
import { LogPanel } from './LogPanel'
|
||||
import { ShipPanel } from './ShipPanel'
|
||||
import { TacticalView } from './TacticalView'
|
||||
import { Teletype } from './Teletype'
|
||||
|
||||
/**
|
||||
@@ -11,6 +15,18 @@ import { Teletype } from './Teletype'
|
||||
* because a printing terminal has no form to fill in and no way back to a
|
||||
* field you have already left. That is kept exactly, and it is why this
|
||||
* component is a tiny state machine rather than a set of inputs.
|
||||
*
|
||||
* Both skins get that same one-line console, and it is the same console:
|
||||
* whatever the screen around it looks like, the game is played by answering
|
||||
* one question at a time.
|
||||
*
|
||||
* What changes is everything else. The paper is a single column because a
|
||||
* roll of paper is a single column. The remaster is a bridge, which means
|
||||
* three: the ship down the left, the sensors in the middle, the log down the
|
||||
* right. The first draft put the sensors in the middle of a paper-shaped
|
||||
* column with the transcript squeezed underneath, and it was unreadable for
|
||||
* exactly the reason you would expect -- a four-by-three screen is wide, and
|
||||
* a column throws the width away.
|
||||
*/
|
||||
type Ask = 'command' | 'nav-course' | 'nav-warp' | 'tor-course' | 'beams' | 'shields'
|
||||
|
||||
@@ -43,6 +59,7 @@ export function BridgeScreen({
|
||||
over,
|
||||
onCommand,
|
||||
onPrint,
|
||||
onBlip,
|
||||
}: {
|
||||
patrol: Patrol
|
||||
transcript: Line[]
|
||||
@@ -51,7 +68,10 @@ export function BridgeScreen({
|
||||
over: boolean
|
||||
onCommand: (cmd: Command, echo: Line[]) => void
|
||||
onPrint: (lines: Line[]) => void
|
||||
onBlip: () => void
|
||||
}) {
|
||||
const { skin } = useSkin()
|
||||
const [panel, setPanel] = useState<'sector' | 'galaxy'>('sector')
|
||||
const [ask, setAsk] = useState<Ask>('command')
|
||||
const [course, setCourse] = useState(0)
|
||||
const [text, setText] = useState('')
|
||||
@@ -103,6 +123,11 @@ export function BridgeScreen({
|
||||
return
|
||||
}
|
||||
|
||||
// Asking for a scan should show you that scan. The panel follows the
|
||||
// command rather than making the player find the right tab afterwards.
|
||||
if (cmd === 'LRS' || cmd === 'COM') setPanel('galaxy')
|
||||
if (cmd === 'SRS') setPanel('sector')
|
||||
|
||||
const simple: Record<string, Command> = {
|
||||
SRS: { type: 'srs' },
|
||||
LRS: { type: 'lrs' },
|
||||
@@ -128,6 +153,7 @@ export function BridgeScreen({
|
||||
break
|
||||
case 'nav-warp':
|
||||
onCommand({ type: 'nav', course, warp: value }, [echo(answer)])
|
||||
setPanel('sector')
|
||||
setAsk('command')
|
||||
break
|
||||
case 'tor-course':
|
||||
@@ -147,26 +173,39 @@ export function BridgeScreen({
|
||||
|
||||
return (
|
||||
<div className="console">
|
||||
<Teletype lines={transcript} />
|
||||
{skin === 'modern' ? (
|
||||
<div className="bridge">
|
||||
<ShipPanel patrol={patrol} captainName={captainName} />
|
||||
<TacticalView patrol={patrol} panel={panel} onPanel={setPanel} />
|
||||
<LogPanel lines={transcript} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Teletype lines={transcript} clatter />
|
||||
|
||||
{/*
|
||||
A concession, and the only one: the machine printed the ship's numbers
|
||||
beside a short range scan and nowhere else, so the 1971 way to know
|
||||
your energy was to remember it or spend a command asking. That is fine
|
||||
on paper you can hold and unkind on a phone, where the answer has
|
||||
already scrolled. The scan is still the only place the *galaxy*
|
||||
appears; this strip only ever says what is true of the ship.
|
||||
*/}
|
||||
<div className="statusbar">
|
||||
<span>{captainName}</span>
|
||||
<span>STARDATE {patrol.day.toFixed(1)}</span>
|
||||
<span>DAYS {daysLeft(patrol).toFixed(1)}</span>
|
||||
<span>ENERGY {Math.round(patrol.energy)}</span>
|
||||
<span>SHIELDS {Math.round(patrol.shields)}</span>
|
||||
<span>TORP {patrol.torpedoes}</span>
|
||||
<span>RAIDERS {patrol.raidersLeft}</span>
|
||||
<span className={condition(patrol) === 'RED' ? 'senses' : ''}>{condition(patrol)}</span>
|
||||
</div>
|
||||
{/*
|
||||
A concession, and the only one on this skin: the machine printed
|
||||
the ship's numbers beside a short range scan and nowhere else, so
|
||||
the 1971 way to know your energy was to remember it or spend a
|
||||
command asking. That is fine on paper you can hold and unkind on a
|
||||
phone, where the answer has already scrolled. The scan is still
|
||||
the only place the *galaxy* appears; this strip only ever says
|
||||
what is true of the ship.
|
||||
*/}
|
||||
<div className="statusbar">
|
||||
<span>{captainName}</span>
|
||||
<span>STARDATE {patrol.day.toFixed(1)}</span>
|
||||
<span>DAYS {daysLeft(patrol).toFixed(1)}</span>
|
||||
<span>ENERGY {Math.round(patrol.energy)}</span>
|
||||
<span>SHIELDS {Math.round(patrol.shields)}</span>
|
||||
<span>TORP {patrol.torpedoes}</span>
|
||||
<span>RAIDERS {patrol.raidersLeft}</span>
|
||||
<span className={condition(patrol) === 'RED' ? 'senses' : ''}>
|
||||
{condition(patrol)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/*
|
||||
The prompt never goes away while the patrol is running, even mid-print.
|
||||
@@ -207,7 +246,10 @@ export function BridgeScreen({
|
||||
key={id}
|
||||
className="btn btn-ghost key"
|
||||
title={what}
|
||||
onClick={() => submit(id)}
|
||||
onClick={() => {
|
||||
onBlip()
|
||||
submit(id)
|
||||
}}
|
||||
>
|
||||
{id}
|
||||
</button>
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useSkin } from '../skin'
|
||||
|
||||
/**
|
||||
* The thing the game is happening inside.
|
||||
*
|
||||
* 1971 is a printing terminal on the end of a telephone line, so the frame is
|
||||
* a sheet of fanfold paper coming out of a machine: sprocket holes down both
|
||||
* edges, a platen at the top, and no screen anywhere. When the remaster
|
||||
* arrives it will bring its own frame and everything inside will be laid out
|
||||
* identically -- only the box will change.
|
||||
* Both skins get a frame, and the frames are not the same object. 1971 is a
|
||||
* printing terminal on the end of a telephone line, so it is a sheet of
|
||||
* fanfold paper coming out of a machine: sprocket holes down both edges, a
|
||||
* platen at the top, and no screen anywhere. The remaster is a lit bridge, so
|
||||
* it is a screen in a housing with the glass curving away at the edges.
|
||||
* Everything inside is laid out identically -- only the box changes.
|
||||
*/
|
||||
export function Frame({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
|
||||
const { skin } = useSkin()
|
||||
return (
|
||||
<div className="cabinet">
|
||||
<div className="bezel">
|
||||
<div className="platen" aria-hidden />
|
||||
{skin === 'teletype' && <div className="platen" aria-hidden />}
|
||||
<div className="screen">
|
||||
<div className="screen-inner">{children}</div>
|
||||
<div className="sprockets sprockets-left" aria-hidden />
|
||||
<div className="sprockets sprockets-right" aria-hidden />
|
||||
<div className="paper-grain" aria-hidden />
|
||||
{skin === 'teletype' ? (
|
||||
<>
|
||||
<div className="sprockets sprockets-left" aria-hidden />
|
||||
<div className="sprockets sprockets-right" aria-hidden />
|
||||
<div className="paper-grain" aria-hidden />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="scanlines" aria-hidden />
|
||||
<div className="vignette" aria-hidden />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{footer}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { Line } from '../game/types'
|
||||
|
||||
/**
|
||||
* The remaster's right-hand column: what the machine has said.
|
||||
*
|
||||
* Two deliberate differences from the paper, and they are the whole reason
|
||||
* the remaster is readable:
|
||||
*
|
||||
* **The scans are not in here.** A short range scan is eight rows of
|
||||
* three-character columns and a long range scan is a nine-cell block of
|
||||
* digits; on paper that is the only way to show a grid, and on a screen it is
|
||||
* a picture of a grid rendered in text next to an actual grid. So `scan`
|
||||
* lines are drawn by `TacticalView` instead. Both skins are told exactly the
|
||||
* same things -- one of them draws the part that is a diagram.
|
||||
*
|
||||
* **Nothing types.** The print head is 1971's, and it is the right sound and
|
||||
* the right speed for paper. Here the lines land, because you are reading
|
||||
* this column while looking at the grid next to it, and a line that arrives
|
||||
* one character at a time in the corner of your eye is a distraction rather
|
||||
* than a machine.
|
||||
*/
|
||||
export function LogPanel({ lines }: { lines: Line[] }) {
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const shown = lines.filter((l) => l.kind !== 'scan')
|
||||
|
||||
useEffect(() => {
|
||||
box.current?.scrollTo({ top: box.current.scrollHeight, behavior: 'smooth' })
|
||||
}, [shown.length])
|
||||
|
||||
return (
|
||||
<aside className="panel log-panel">
|
||||
<h2 className="panel-title">LOG</h2>
|
||||
<div className="log-lines" ref={box} aria-live="polite">
|
||||
{shown.map((l, i) => (
|
||||
<p className={`log-line log-${l.kind}`} key={i}>
|
||||
{l.text}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { DEVICE_NAMES, START_ENERGY, START_TORPEDOES } from '../game/constants'
|
||||
import { condition, daysLeft } from '../game/engine'
|
||||
import { DEVICES, type Patrol } from '../game/types'
|
||||
|
||||
/**
|
||||
* The remaster's left-hand column: everything true of the ship.
|
||||
*
|
||||
* On paper this was a strip of text down the side of a short range scan,
|
||||
* eight facts on eight rows, because that is what a printing terminal could
|
||||
* do. Given a screen and no printer, the two numbers you actually watch --
|
||||
* energy and shields -- become bars, because a bar answers "how much is
|
||||
* left" without being read, and that is the question you ask forty times a
|
||||
* patrol.
|
||||
*
|
||||
* Nothing here is new information. It is the same eight facts, plus the
|
||||
* damage report, which in 1971 cost you a command to ask for and here does
|
||||
* not.
|
||||
*/
|
||||
export function ShipPanel({ patrol, captainName }: { patrol: Patrol; captainName: string }) {
|
||||
const cond = condition(patrol)
|
||||
const broken = DEVICES.filter((d) => patrol.damage[d] > 0)
|
||||
|
||||
return (
|
||||
<aside className="panel ship-panel">
|
||||
<h2 className="panel-title">{captainName}</h2>
|
||||
|
||||
<div className={`condition cond-${cond.toLowerCase()}`}>{cond}</div>
|
||||
|
||||
<Bar label="ENERGY" value={patrol.energy} of={START_ENERGY} kind="energy" />
|
||||
<Bar label="SHIELDS" value={patrol.shields} of={START_ENERGY} kind="shields" />
|
||||
|
||||
<dl className="readout">
|
||||
<dt>STARDATE</dt>
|
||||
<dd>{patrol.day.toFixed(1)}</dd>
|
||||
<dt>DAYS LEFT</dt>
|
||||
<dd className={daysLeft(patrol) < 5 ? 'low' : ''}>{daysLeft(patrol).toFixed(1)}</dd>
|
||||
<dt>QUADRANT</dt>
|
||||
<dd>
|
||||
{patrol.quadrant.row + 1},{patrol.quadrant.col + 1}
|
||||
</dd>
|
||||
<dt>SECTOR</dt>
|
||||
<dd>
|
||||
{patrol.sector.row + 1},{patrol.sector.col + 1}
|
||||
</dd>
|
||||
<dt>RAIDERS</dt>
|
||||
<dd className="big">{patrol.raidersLeft}</dd>
|
||||
</dl>
|
||||
|
||||
{/* Torpedoes are countable, so they are counted rather than measured. */}
|
||||
<div className="torps" title={`${patrol.torpedoes} torpedoes`}>
|
||||
<span className="panel-label">TORPEDOES</span>
|
||||
<span className="pips" aria-label={`${patrol.torpedoes} torpedoes`}>
|
||||
{Array.from({ length: START_TORPEDOES }, (_, i) => (
|
||||
<i key={i} className={i < patrol.torpedoes ? 'pip on' : 'pip'} />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="damage-list">
|
||||
<span className="panel-label">DAMAGE</span>
|
||||
{broken.length === 0 ? (
|
||||
<p className="all-well">ALL SYSTEMS ANSWER</p>
|
||||
) : (
|
||||
<ul>
|
||||
{broken.map((d) => (
|
||||
<li key={d}>
|
||||
<span>{DEVICE_NAMES[d]}</span>
|
||||
<span className="days">{patrol.damage[d]!.toFixed(1)}d</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function Bar({
|
||||
label,
|
||||
value,
|
||||
of,
|
||||
kind,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
of: number
|
||||
kind: string
|
||||
}) {
|
||||
const pct = Math.max(0, Math.min(100, (value / of) * 100))
|
||||
return (
|
||||
<div className="meter">
|
||||
<div className="meter-head">
|
||||
<span className="panel-label">{label}</span>
|
||||
<span className="meter-value">{Math.round(value)}</span>
|
||||
</div>
|
||||
<div
|
||||
className={`bar bar-${kind}`}
|
||||
role="meter"
|
||||
aria-valuenow={Math.round(value)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={of}
|
||||
aria-label={label}
|
||||
>
|
||||
<span style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { GALAXY, SECTORS } from '../game/constants'
|
||||
import { censusCode } from '../game/galaxy'
|
||||
import { condition } from '../game/engine'
|
||||
import type { Patrol } from '../game/types'
|
||||
|
||||
/**
|
||||
* The remaster: the sensors, drawn.
|
||||
*
|
||||
* Every mark here is generated from the game state -- there is no artwork
|
||||
* file in this repository and there is not going to be one. Cel shading in
|
||||
* practice means flat fills, one heavy outline, and a highlight that is a
|
||||
* second flat fill rather than a gradient, which is a style that happens to
|
||||
* suit a thing made entirely of `<circle>` and `<path>`.
|
||||
*
|
||||
* The hard rule is that this component may not show the player anything the
|
||||
* teletype has not already offered, and here that rule has teeth, because
|
||||
* these two panels *are* two of the ship's devices:
|
||||
*
|
||||
* - the sector grid is the short range sensors. Break them and it goes
|
||||
* dark, exactly as SRS refuses to print;
|
||||
* - the chart is the computer, and it draws `patrol.chart` -- what has
|
||||
* been scanned -- rather than `patrol.galaxy`, which is the truth. An
|
||||
* unscanned quadrant is three dots on both skins.
|
||||
*
|
||||
* Drawing the truth would make the remaster an easier game than the
|
||||
* original, and they are meant to be the same game.
|
||||
*/
|
||||
|
||||
const CELL = 40
|
||||
const HALF = CELL / 2
|
||||
|
||||
export function TacticalView({
|
||||
patrol,
|
||||
panel,
|
||||
onPanel,
|
||||
}: {
|
||||
patrol: Patrol
|
||||
panel: 'sector' | 'galaxy'
|
||||
onPanel: (p: 'sector' | 'galaxy') => void
|
||||
}) {
|
||||
return (
|
||||
<div className="tactical">
|
||||
<div className="panel-tabs">
|
||||
<button
|
||||
className={`btn btn-ghost tab ${panel === 'sector' ? 'on' : ''}`}
|
||||
onClick={() => onPanel('sector')}
|
||||
>
|
||||
SECTOR
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-ghost tab ${panel === 'galaxy' ? 'on' : ''}`}
|
||||
onClick={() => onPanel('galaxy')}
|
||||
>
|
||||
GALAXY
|
||||
</button>
|
||||
</div>
|
||||
{panel === 'sector' ? <SectorGrid patrol={patrol} /> : <GalaxyGrid patrol={patrol} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The quadrant you are standing in, at sector resolution. */
|
||||
function SectorGrid({ patrol }: { patrol: Patrol }) {
|
||||
const size = CELL * SECTORS
|
||||
|
||||
if (patrol.damage.srs > 0) {
|
||||
return <Dark label="SHORT RANGE SENSORS ARE OUT" size={size} />
|
||||
}
|
||||
|
||||
const at = (c: { row: number; col: number }) => ({
|
||||
x: c.col * CELL + HALF,
|
||||
y: c.row * CELL + HALF,
|
||||
})
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="grid-map"
|
||||
viewBox={`-6 -6 ${size + 12} ${size + 12}`}
|
||||
role="img"
|
||||
aria-label={`Sector scan of quadrant ${patrol.quadrant.row + 1},${patrol.quadrant.col + 1}. You are in sector ${patrol.sector.row + 1},${patrol.sector.col + 1}.`}
|
||||
>
|
||||
<rect className="grid-bg" x="-6" y="-6" width={size + 12} height={size + 12} rx="12" />
|
||||
<Rules n={SECTORS} size={size} />
|
||||
|
||||
{patrol.stars.map((s, i) => {
|
||||
const { x, y } = at(s)
|
||||
return (
|
||||
<g className="mark mark-star" key={`s${i}`}>
|
||||
<circle cx={x} cy={y} r="9" />
|
||||
{/* The highlight is a second flat fill, not a gradient. */}
|
||||
<circle className="lit" cx={x - 3} cy={y - 3.5} r="3.2" />
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{patrol.base && (
|
||||
<g className="mark mark-base" transform={`translate(${at(patrol.base).x} ${at(patrol.base).y})`}>
|
||||
<path d="M0 -12 L10.4 -6 L10.4 6 L0 12 L-10.4 6 L-10.4 -6 Z" />
|
||||
<circle className="lit" cx="0" cy="0" r="4.4" />
|
||||
</g>
|
||||
)}
|
||||
|
||||
{patrol.raiders.map((k, i) => {
|
||||
const { x, y } = at(k.at)
|
||||
return (
|
||||
<g className="mark mark-raider" key={`r${i}`} transform={`translate(${x} ${y})`}>
|
||||
{/* A dart, turned to face the ship, so a quadrant reads as a
|
||||
fight rather than as a list of objects. Which way a raider is
|
||||
pointing is decoration -- it has no facing in the rules -- but
|
||||
it is decoration that tells the truth: it is coming for you. */}
|
||||
<path
|
||||
d="M15 0 L-9 -11 L-4 0 L-9 11 Z"
|
||||
transform={`rotate(${angle(k.at, patrol.sector)})`}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/*
|
||||
Your ship, pointing up and staying that way. The first draft drew a
|
||||
saucer with a hull under it and at this size it read as a hamburger:
|
||||
two stacked shapes of similar width, the lower one darker. An
|
||||
arrowhead has one silhouette and survives being sixteen pixels tall,
|
||||
which is the only test that matters here.
|
||||
*/}
|
||||
<g
|
||||
className={`mark mark-ship ${condition(patrol) === 'RED' ? 'red' : ''}`}
|
||||
transform={`translate(${at(patrol.sector).x} ${at(patrol.sector).y})`}
|
||||
>
|
||||
<circle className="ring" r="17" />
|
||||
<path className="hull" d="M0 -15 L12 12 L0 5.5 L-12 12 Z" />
|
||||
<path className="lit" d="M0 -15 L0 5.5 L-12 12 Z" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** The chart: what has been scanned, three digits at a time. */
|
||||
function GalaxyGrid({ patrol }: { patrol: Patrol }) {
|
||||
const size = CELL * GALAXY
|
||||
|
||||
if (patrol.damage.computer > 0) {
|
||||
return <Dark label="THE COMPUTER IS DOWN" size={size} />
|
||||
}
|
||||
|
||||
const cells = []
|
||||
for (let row = 0; row < GALAXY; row++) {
|
||||
for (let col = 0; col < GALAXY; col++) {
|
||||
const census = patrol.chart[row]![col]!
|
||||
const here = row === patrol.quadrant.row && col === patrol.quadrant.col
|
||||
const classes = [
|
||||
'quad',
|
||||
census === null ? 'unknown' : '',
|
||||
census && census.raiders > 0 ? 'hostile' : '',
|
||||
census?.base ? 'has-base' : '',
|
||||
here ? 'here' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
cells.push(
|
||||
<g className={classes} key={`${row}-${col}`}>
|
||||
<rect x={col * CELL + 2} y={row * CELL + 2} width={CELL - 4} height={CELL - 4} rx="5" />
|
||||
<text x={col * CELL + HALF} y={row * CELL + HALF + 4}>
|
||||
{census === null ? '···' : censusCode(census)}
|
||||
</text>
|
||||
</g>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="grid-map"
|
||||
viewBox={`-6 -6 ${size + 12} ${size + 12}`}
|
||||
role="img"
|
||||
aria-label={`Galaxy chart. You are in quadrant ${patrol.quadrant.row + 1},${patrol.quadrant.col + 1}. ${patrol.raidersLeft} raiders remain.`}
|
||||
>
|
||||
<rect className="grid-bg" x="-6" y="-6" width={size + 12} height={size + 12} rx="12" />
|
||||
{cells}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** A device that is out. The panel is the device, so it goes dark with it. */
|
||||
function Dark({ label, size }: { label: string; size: number }) {
|
||||
return (
|
||||
<svg
|
||||
className="grid-map"
|
||||
viewBox={`-6 -6 ${size + 12} ${size + 12}`}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
>
|
||||
<rect className="grid-bg dead" x="-6" y="-6" width={size + 12} height={size + 12} rx="12" />
|
||||
<text className="dead-label" x={size / 2} y={size / 2}>
|
||||
{label}
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Rules({ n, size }: { n: number; size: number }) {
|
||||
const lines = []
|
||||
for (let i = 1; i < n; i++) {
|
||||
lines.push(<line className="rule" key={`h${i}`} x1="0" y1={i * CELL} x2={size} y2={i * CELL} />)
|
||||
lines.push(<line className="rule" key={`v${i}`} x1={i * CELL} y1="0" x2={i * CELL} y2={size} />)
|
||||
}
|
||||
return <g>{lines}</g>
|
||||
}
|
||||
|
||||
/** Degrees from a raider towards the ship, for the dart to point along. */
|
||||
function angle(from: { row: number; col: number }, to: { row: number; col: number }): number {
|
||||
return (Math.atan2(to.row - from.row, to.col - from.col) * 180) / Math.PI
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { synth } from '../audio/synth'
|
||||
import type { Line } from '../game/types'
|
||||
|
||||
/**
|
||||
@@ -14,10 +15,15 @@ import type { Line } from '../game/types'
|
||||
* A scan is exempt. Eight rows of it at reading speed is most of a minute,
|
||||
* and the original's printer was hammering all eight out while the captain
|
||||
* was already deciding -- so the grid lands whole and the sentences type.
|
||||
*
|
||||
* The print head is played per character, which at this rate is a lot of
|
||||
* one-shot oscillators -- that is why `synth.print()` is as small as it is.
|
||||
* Only the 1971 skin plays it: the remaster has a screen, and a screen does
|
||||
* not clatter.
|
||||
*/
|
||||
const CPS = 60 // Faster than a real ASR-33. A real one is 10, and it is a lot.
|
||||
|
||||
export function Teletype({ lines }: { lines: Line[] }) {
|
||||
export function Teletype({ lines, clatter = false }: { lines: Line[]; clatter?: boolean }) {
|
||||
const [printed, setPrinted] = useState<string[]>([])
|
||||
const [partial, setPartial] = useState('')
|
||||
const done = useRef(0)
|
||||
@@ -49,8 +55,10 @@ export function Teletype({ lines }: { lines: Line[] }) {
|
||||
const timer = window.setInterval(() => {
|
||||
at += 1
|
||||
setPartial(text.slice(0, at))
|
||||
if (clatter && synth.sfxOn) synth.print()
|
||||
if (at >= text.length) {
|
||||
window.clearInterval(timer)
|
||||
if (clatter && synth.sfxOn) synth.carriage()
|
||||
done.current += 1
|
||||
setPartial('')
|
||||
setPrinted((p) => [...p, text])
|
||||
@@ -59,7 +67,7 @@ export function Teletype({ lines }: { lines: Line[] }) {
|
||||
|
||||
return () => window.clearInterval(timer)
|
||||
// `printed` is the trigger: finishing one line starts the next.
|
||||
}, [lines, printed.length])
|
||||
}, [lines, printed.length, clatter])
|
||||
|
||||
// The paper feeds up; you always read at the bottom.
|
||||
useEffect(() => {
|
||||
|
||||
+41
-16
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { DIAL_UP, synth } from '../audio/synth'
|
||||
import { MAX_CAPTAINS, MAX_NAME, START_ENERGY, START_TORPEDOES } from '../game/constants'
|
||||
import { comparePatrols, outcomeText } from '../game/engine'
|
||||
import type { Score } from '../game/highscores'
|
||||
@@ -21,36 +22,41 @@ import { Btn, Line } from './Frame'
|
||||
* The lemonade stand loads from cassette because that is how an Atari got its
|
||||
* programs. This game is eight years older than that machine and did not live
|
||||
* on a machine you owned at all -- it lived on a mainframe at the end of a
|
||||
* telephone line, which you called. So the boot is a call, and it is the same
|
||||
* gesture the cave game opens with for the same reason.
|
||||
* telephone line, which you called. So the boot is a real Bell 103 call, and
|
||||
* pushing the handset into the coupler is the gesture that unlocks the audio:
|
||||
* browsers want a gesture before they will make a sound, and that happens to
|
||||
* be exactly the gesture the game already wanted.
|
||||
*
|
||||
* There is no sound yet. When the remaster lands it brings the synth with it,
|
||||
* and this schedule is already shaped to be played rather than only printed.
|
||||
* The stages are printed as they happen, from the same schedule the audio is
|
||||
* built on, so what is on the paper is what is on the line. See DIAL_UP.
|
||||
*/
|
||||
const STAGES: [number, string][] = [
|
||||
[0, 'DIAL TONE'],
|
||||
[1.1, 'DIALLING'],
|
||||
[2.6, 'RINGING'],
|
||||
[4.2, 'ANSWER TONE'],
|
||||
[5.2, 'CARRIER -- 110 BAUD, FULL DUPLEX'],
|
||||
[6.0, 'LOGIN ACCEPTED'],
|
||||
[DIAL_UP.dialTone, 'DIALLING 555-1971'],
|
||||
[DIAL_UP.ringAt, 'RINGING'],
|
||||
[DIAL_UP.answerAt, 'ANSWER TONE 2225 HZ'],
|
||||
[DIAL_UP.answerAt + DIAL_UP.answer, 'CARRIER -- 110 BAUD, FULL DUPLEX'],
|
||||
]
|
||||
|
||||
const BOOT_TOTAL = 6.8
|
||||
|
||||
export function BootScreen({ onLoaded }: { onLoaded: () => void }) {
|
||||
const [dialling, setDialling] = useState(false)
|
||||
const [stage, setStage] = useState(-1)
|
||||
const stop = useRef<(() => void) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialling) return
|
||||
stop.current = synth.dialUp()
|
||||
|
||||
const timers = STAGES.map(([at], i) => window.setTimeout(() => setStage(i), at * 1000))
|
||||
const done = window.setTimeout(onLoaded, BOOT_TOTAL * 1000)
|
||||
const done = window.setTimeout(() => {
|
||||
stop.current?.()
|
||||
onLoaded()
|
||||
}, DIAL_UP.total * 1000)
|
||||
|
||||
return () => {
|
||||
for (const t of timers) window.clearTimeout(t)
|
||||
window.clearTimeout(done)
|
||||
stop.current?.()
|
||||
}
|
||||
}, [dialling, onLoaded])
|
||||
|
||||
@@ -62,7 +68,13 @@ export function BootScreen({ onLoaded }: { onLoaded: () => void }) {
|
||||
<Line>THE PROGRAM IS NOT ON THIS MACHINE. IT IS ON SOMEBODY ELSE'S.</Line>
|
||||
<Line className="dim">PUT THE HANDSET IN THE COUPLER AND CALL IT.</Line>
|
||||
<Line> </Line>
|
||||
<Btn kind="primary" onClick={() => setDialling(true)}>
|
||||
<Btn
|
||||
kind="primary"
|
||||
onClick={() => {
|
||||
synth.ensure()
|
||||
setDialling(true)
|
||||
}}
|
||||
>
|
||||
DIAL
|
||||
</Btn>
|
||||
</div>
|
||||
@@ -150,7 +162,13 @@ export function InstructionsScreen({ onDone }: { onDone: () => void }) {
|
||||
|
||||
// ------------------------------------------------------------------ setup
|
||||
|
||||
export function SetupScreen({ onStart }: { onStart: (names: string[]) => void }) {
|
||||
export function SetupScreen({
|
||||
onStart,
|
||||
onBlip,
|
||||
}: {
|
||||
onStart: (names: string[]) => void
|
||||
onBlip: () => void
|
||||
}) {
|
||||
const [names, setNames] = useState<string[]>([''])
|
||||
|
||||
const set = (i: number, v: string) =>
|
||||
@@ -177,7 +195,14 @@ export function SetupScreen({ onStart }: { onStart: (names: string[]) => void })
|
||||
<Line> </Line>
|
||||
<div className="prompt-row">
|
||||
{names.length < MAX_CAPTAINS && (
|
||||
<Btn onClick={() => setNames((n) => [...n, ''])}>ONE MORE</Btn>
|
||||
<Btn
|
||||
onClick={() => {
|
||||
onBlip()
|
||||
setNames((n) => [...n, ''])
|
||||
}}
|
||||
>
|
||||
ONE MORE
|
||||
</Btn>
|
||||
)}
|
||||
<Btn kind="primary" onClick={() => onStart(names)}>
|
||||
CAST OFF
|
||||
|
||||
@@ -62,7 +62,7 @@ async function call(path: string, init?: RequestInit): Promise<unknown> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...init,
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) },
|
||||
headers: { 'content-type': 'application/json', ...init?.headers },
|
||||
})
|
||||
const body: unknown = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
|
||||
+13
-2
@@ -23,8 +23,9 @@ body,
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* The room the machine is standing in: a terminal room in a university
|
||||
basement, lit by the ceiling and nothing else. */
|
||||
/* The room the machine is standing in. 1971 is a terminal room in a
|
||||
university basement, lit by the ceiling and nothing else; the remaster is a
|
||||
bridge at night, lit by its own instruments. */
|
||||
body {
|
||||
background: radial-gradient(120% 80% at 50% 0%, #5a5a5a 0%, #3a3a3a 55%, #232323 100%);
|
||||
color: #241f1a;
|
||||
@@ -33,6 +34,16 @@ body {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: clamp(8px, 2vw, 28px);
|
||||
transition: background 400ms ease;
|
||||
}
|
||||
|
||||
html[data-skin='modern'] body {
|
||||
background:
|
||||
radial-gradient(80% 55% at 18% 0%, rgba(90, 208, 255, 0.14) 0%, transparent 60%),
|
||||
radial-gradient(70% 55% at 86% 18%, rgba(255, 194, 71, 0.12) 0%, transparent 55%),
|
||||
radial-gradient(130% 95% at 50% 100%, #131e3c 0%, #0b1226 55%, #04070f 100%);
|
||||
color: #e8f1ff;
|
||||
font-family: ui-rounded, "Nunito", "Avenir Next", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
+4
-9
@@ -2,17 +2,12 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
/**
|
||||
* There is one skin so far, and it is the machine as it was. The remaster
|
||||
* will bring a provider and a toggle with it; until then the attribute is set
|
||||
* once here so the stylesheet has something to key off and nothing has to
|
||||
* pretend there is a choice.
|
||||
*/
|
||||
document.documentElement.dataset.skin = 'teletype'
|
||||
import { SkinProvider } from './SkinProvider.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<SkinProvider>
|
||||
<App />
|
||||
</SkinProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
/**
|
||||
* Two skins over one game. "teletype" is 1971 as it actually was -- a
|
||||
* printing terminal, no screen, no colour and no sound but the print head.
|
||||
* "modern" is the same patrol with the sensors drawn. The rules never differ
|
||||
* between them, and neither does a single word the machine prints.
|
||||
*
|
||||
* Worth saying why this is a printing terminal rather than a CRT, since the
|
||||
* lemonade stand's period skin is an Atari television. This game is eight
|
||||
* years older than that machine. It was written for a timesharing system and
|
||||
* played on paper, which is why it asks you to remember things: there is no
|
||||
* screen to look back at, only what you have already torn off.
|
||||
*/
|
||||
export type Skin = 'teletype' | 'modern'
|
||||
|
||||
export const SKIN_KEY = 'scan.skin.v1'
|
||||
|
||||
export function readSkin(): Skin {
|
||||
try {
|
||||
return window.localStorage.getItem(SKIN_KEY) === 'teletype' ? 'teletype' : 'modern'
|
||||
} catch {
|
||||
return 'modern'
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSkin(s: Skin): void {
|
||||
try {
|
||||
window.localStorage.setItem(SKIN_KEY, s)
|
||||
} catch {
|
||||
// A skin that does not stick is a small loss; carry on.
|
||||
}
|
||||
}
|
||||
|
||||
export interface SkinApi {
|
||||
skin: Skin
|
||||
setSkin: (s: Skin) => void
|
||||
toggle: () => void
|
||||
}
|
||||
|
||||
export const SkinContext = createContext<SkinApi>({
|
||||
skin: 'modern',
|
||||
setSkin: () => {},
|
||||
toggle: () => {},
|
||||
})
|
||||
|
||||
export const useSkin = () => useContext(SkinContext)
|
||||
Reference in New Issue
Block a user