diff --git a/README.md b/README.md index ea9fc0a..12b7850 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ off the bottom by a better one. | `src/game/constants.ts` | every number, and every word the machine says | | `src/game/galaxy.ts` | the compass, the scatter, laying out a quadrant | | `src/game/engine.ts` | the rules: warp, beams, torpedoes, being shot at | +| `src/game/aim.ts` | turning a click into a course and a warp factor | | `src/game/reducer.ts` | phases, and the only place a turn changes hands | | `src/audio/` | the synth, and the two bands written for it | | `src/components/` | the paper, the bridge, the console, and the screens either side | @@ -100,5 +101,5 @@ off the bottom by a better one. `npm test` proves the parts that are arithmetic rather than judgement: that the compass closes, that a scattered galaxy is always worth flying, that a warp stops at the rim and short of anything in the way, that every way a -patrol can end, ends it, and that every note in every tune is one the synth -can find. +patrol can end, ends it, that every click lands where it was aimed, and that +every note in every tune is one the synth can find. diff --git a/src/App.css b/src/App.css index 3bfb046..5f56780 100644 --- a/src/App.css +++ b/src/App.css @@ -692,6 +692,65 @@ html[data-skin='modern'] .head { stroke-width: 1.5; } +/* --- pointing --------------------------------------------------------- */ + +/* The click targets sit over everything and are invisible until hovered. */ +.cell { + fill: transparent; + cursor: pointer; +} + +.cell.on { + fill: rgba(255, 194, 71, 0.1); +} + +.cell-dead { + cursor: default; +} + +.cell-dead.on { + fill: rgba(255, 107, 93, 0.08); +} + +/* The course, drawn before it is flown. */ +.aim line { + stroke: var(--accent); + stroke-width: 2.5; + stroke-dasharray: 7 6; + stroke-linecap: round; + opacity: 0.75; +} + +.aim circle { + fill: none; + stroke: var(--accent); + stroke-width: 2.5; + opacity: 0.75; +} + +.aim-readout { + margin: 0; + min-height: 1.3em; + font-size: 0.78em; + font-weight: 800; + letter-spacing: 0.1em; + color: var(--ink-dim); + text-align: center; +} + +.quad { + cursor: pointer; +} + +.quad.here { + cursor: default; +} + +.quad.on rect { + stroke: var(--accent); + stroke-width: 3; +} + /* Cel shading: one flat fill, one heavy outline, and a highlight that is a second flat fill rather than a gradient. */ .mark path, @@ -826,6 +885,32 @@ html[data-skin='modern'] .head { gap: 2px 4px; } +.actions { + display: flex; + flex-direction: column; + gap: 4px; +} + +.action-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px 6px; +} + +.amounts { + padding-top: 4px; + border-top: 1px solid rgba(232, 241, 255, 0.14); +} + +.amounts .panel-label { + margin-right: 2px; +} + +.key.cancel { + color: var(--warn); +} + .key { padding: 3px 8px; font-size: 0.9em; diff --git a/src/components/BridgeScreen.tsx b/src/components/BridgeScreen.tsx index 987f3f5..5dc7fc4 100644 --- a/src/components/BridgeScreen.tsx +++ b/src/components/BridgeScreen.tsx @@ -1,10 +1,13 @@ import { useEffect, useRef, useState } from 'react' +import { navFor } from '../game/aim' +import { SECTORS } from '../game/constants' import { condition, daysLeft } from '../game/engine' -import type { Command, Line, Patrol } from '../game/types' +import { adjacent, occupant, same } from '../game/galaxy' +import type { Command, Coord, Line, Patrol } from '../game/types' import { useSkin } from '../skin' import { LogPanel } from './LogPanel' import { ShipPanel } from './ShipPanel' -import { TacticalView } from './TacticalView' +import { TacticalView, type SectorClick } from './TacticalView' import { Teletype } from './Teletype' /** @@ -27,6 +30,15 @@ import { Teletype } from './Teletype' * 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. + * + * The remaster also points. Click a sector and you warp to it; click a raider + * and you put a torpedo through it; click a starbase and you moor alongside; + * click a quadrant on the chart and you cross the galaxy to it. None of that + * is a new move. Every click is turned into the same course and warp factor + * the prompt would have taken, and the echo below writes the questions and + * the answers into the log as though they had been typed -- which is also + * how anybody works out that course 3 is north. The keyboard still works and + * is still the way it was meant to be played. */ type Ask = 'command' | 'nav-course' | 'nav-warp' | 'tor-course' | 'beams' | 'shields' @@ -87,6 +99,79 @@ export function BridgeScreen({ text: `${QUESTION[ask]} ? ${answer}`, }) + /** The same line a typed answer leaves, for an answer that was clicked. */ + const asked = (question: string, answer: string): Line => ({ + kind: 'system', + text: `${question} ? ${answer}`, + }) + + /** A move, expressed the only way the engine accepts one. */ + const navBy = (d: Coord) => { + const { course, warp } = navFor(d) + setAsk('command') + onCommand({ type: 'nav', course, warp }, [ + asked(QUESTION.command, 'NAV'), + asked(QUESTION['nav-course'], String(course)), + asked(QUESTION['nav-warp'], String(warp)), + ]) + } + + const clickSector = ({ at, what }: SectorClick) => { + if (what === 'ship' || what === 'star') return onBlip() + + const to = (target: Coord): Coord => ({ + row: target.row - patrol.sector.row, + col: target.col - patrol.sector.col, + }) + + if (what === 'raider') { + const course = navFor(to(at)).course + setPanel('sector') + setAsk('command') + onCommand({ type: 'torpedo', course }, [ + asked(QUESTION.command, 'TOR'), + asked(QUESTION['tor-course'], String(course)), + ]) + return + } + + if (what === 'base') { + // Mooring is standing next to it, so clicking it means "park here": + // the nearest free sector alongside, which is what a player typing + // this out would have had to work out for themselves. + if (adjacent(patrol.sector, at)) return onBlip() + const berth = mooringFor(patrol, at) + if (!berth) return onBlip() + navBy(to(berth)) + return + } + + navBy(to(at)) + } + + const clickQuadrant = (at: Coord) => { + setPanel('sector') + navBy({ + row: (at.row - patrol.quadrant.row) * SECTORS, + col: (at.col - patrol.quadrant.col) * SECTORS, + }) + } + + /** Fire or set, from a button rather than two prompts. */ + const spend = (kind: 'beams' | 'shields', amount: number) => { + setAsk('command') + onCommand({ type: kind, energy: amount }, [ + asked(QUESTION.command, kind === 'beams' ? 'BEA' : 'SHE'), + asked(QUESTION[kind], String(amount)), + ]) + } + + const simpleClick = (id: string, cmd: Command) => { + if (id === 'LRS' || id === 'COM') setPanel('galaxy') + setAsk('command') + onCommand(cmd, [asked(QUESTION.command, id)]) + } + /** `raw` is how the command buttons answer: a click is a whole answer. */ const submit = (raw?: string) => { const answer = (raw ?? text).trim().toUpperCase() @@ -176,7 +261,13 @@ export function BridgeScreen({ {skin === 'modern' ? (
- +
) : ( @@ -238,24 +329,169 @@ export function BridgeScreen({ not, and making somebody thumb-type NAV forty times is not faithfulness, it is an obstacle the original never had. Typing still works and is still the way it is meant to be played. + + On the remaster they become an action bar instead, because half + of them have been replaced by pointing at the thing: NAV and TOR + are a click on the grid, and SRS is the grid. */} - {ask === 'command' && ( -
- {COMMANDS.map(([id, what]) => ( - - ))} -
+ {ask === 'command' && + (skin === 'modern' ? ( + + ) : ( +
+ {COMMANDS.map(([id, what]) => ( + + ))} +
+ ))} + + )} + + ) +} + +/** + * Where to tie up. + * + * A starbase is moored *alongside*, never on, so clicking one has to pick a + * sector next to it -- and not any sector next to it, because most of them + * are as far from you as the base is. This takes the free berth nearest to + * where the ship already is, which is the one a player working it out on + * paper would have chosen. + */ +function mooringFor(patrol: Patrol, base: Coord): Coord | null { + const berths: Coord[] = [] + for (let row = base.row - 1; row <= base.row + 1; row++) { + for (let col = base.col - 1; col <= base.col + 1; col++) { + const at = { row, col } + if (row < 0 || row >= SECTORS || col < 0 || col >= SECTORS) continue + if (same(at, base)) continue + if (occupant(patrol, at) !== null && !same(at, patrol.sector)) continue + berths.push(at) + } + } + if (berths.length === 0) return null + + return berths.reduce((best, at) => + Math.hypot(at.row - patrol.sector.row, at.col - patrol.sector.col) < + Math.hypot(best.row - patrol.sector.row, best.col - patrol.sector.col) + ? at + : best, + ) +} + +/** + * The remaster's action bar: the commands that are not a place. + * + * NAV, TOR and SRS are gone from it because they became pointing -- warping + * somewhere, shooting at something, and looking at where you are, all of + * which now have a thing on screen to click. What is left is the four that + * are not about a position, plus the two that spend energy. + * + * Those two ask "how much", which on paper is a second prompt and here is a + * row of amounts. They are amounts rather than a slider on purpose: energy + * is spent in round numbers and read back as a bar, and dragging for a + * precise 437 would be a worse version of typing it -- which still works. + */ +function ActionBar({ + patrol, + onSimple, + onSpend, + onBlip, +}: { + patrol: Patrol + onSimple: (id: string, cmd: Command) => void + onSpend: (kind: 'beams' | 'shields', amount: number) => void + onBlip: () => void +}) { + const [armed, setArmed] = useState<'beams' | 'shields' | null>(null) + + const arm = (which: 'beams' | 'shields') => { + onBlip() + setArmed((a) => (a === which ? null : which)) + } + + const fire = (amount: number) => { + if (!armed) return + onSpend(armed, Math.max(0, Math.floor(amount))) + setArmed(null) + } + + const pool = armed === 'shields' ? patrol.energy + patrol.shields : patrol.energy + const presets = [250, 500, 1000].filter((n) => n <= pool) + + return ( +
+
+ + + + + + +
+ + {armed && ( +
+ + {armed === 'beams' ? 'UNITS TO FIRE' : 'UNITS TO SHIELDS'} + + {presets.map((n) => ( + + ))} + {armed === 'beams' ? ( + + ) : ( + )} +
)}
diff --git a/src/components/TacticalView.tsx b/src/components/TacticalView.tsx index d594527..15de417 100644 --- a/src/components/TacticalView.tsx +++ b/src/components/TacticalView.tsx @@ -1,10 +1,12 @@ +import { useState } from 'react' +import { navFor } from '../game/aim' import { GALAXY, SECTORS } from '../game/constants' -import { censusCode } from '../game/galaxy' +import { adjacent, censusCode, occupant, same, type Occupant } from '../game/galaxy' import { condition } from '../game/engine' -import type { Patrol } from '../game/types' +import type { Coord, Patrol } from '../game/types' /** - * The remaster: the sensors, drawn. + * The remaster: the sensors, drawn, and clickable. * * 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 @@ -22,22 +24,34 @@ import type { Patrol } from '../game/types' * 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. + * Clicking is not a second way to play. A click is turned into a course and + * a warp factor a player could have typed, at the same precision the prompt + * accepts, and the log prints the questions and the answers as though they + * had -- see `game/aim.ts`. The readout under the grid says what the click + * is about to become before it becomes it, which is how anybody learns that + * course 3 is north. */ const CELL = 40 const HALF = CELL / 2 +export type SectorClick = { at: Coord; what: Occupant } + export function TacticalView({ patrol, panel, onPanel, + onSector, + onQuadrant, }: { patrol: Patrol panel: 'sector' | 'galaxy' onPanel: (p: 'sector' | 'galaxy') => void + onSector: (click: SectorClick) => void + onQuadrant: (at: Coord) => void }) { + const [hover, setHover] = useState(null) + return (
@@ -54,23 +68,86 @@ export function TacticalView({ GALAXY
- {panel === 'sector' ? : } + + {panel === 'sector' ? ( + + ) : ( + + )} + +

{caption(patrol, panel, hover)}

) } +/** What the click under the pointer would do, in the machine's own terms. */ +function caption(patrol: Patrol, panel: 'sector' | 'galaxy', hover: Coord | null): string { + if (!hover) return panel === 'sector' ? 'CLICK A SECTOR' : 'CLICK A QUADRANT' + + if (panel === 'galaxy') { + const d = { + row: (hover.row - patrol.quadrant.row) * SECTORS, + col: (hover.col - patrol.quadrant.col) * SECTORS, + } + if (d.row === 0 && d.col === 0) return 'YOU ARE HERE' + const { course, warp } = navFor(d) + return `NAV COURSE ${course} WARP ${warp}` + } + + const what = occupant(patrol, hover) + if (what === 'ship') return 'YOU ARE HERE' + if (what === 'star') return 'A STAR. NOTHING GOES THROUGH IT.' + + const d = { row: hover.row - patrol.sector.row, col: hover.col - patrol.sector.col } + if (what === 'raider') return `TORPEDO COURSE ${navFor(d).course}` + if (what === 'base') return adjacent(patrol.sector, hover) ? 'ALREADY MOORED' : 'MOOR ALONGSIDE' + + const { course, warp } = navFor(d) + return `NAV COURSE ${course} WARP ${warp}` +} + /** The quadrant you are standing in, at sector resolution. */ -function SectorGrid({ patrol }: { patrol: Patrol }) { +function SectorGrid({ + patrol, + hover, + onHover, + onSector, +}: { + patrol: Patrol + hover: Coord | null + onHover: (c: Coord | null) => void + onSector: (click: SectorClick) => void +}) { const size = CELL * SECTORS if (patrol.damage.srs > 0) { return } - const at = (c: { row: number; col: number }) => ({ - x: c.col * CELL + HALF, - y: c.row * CELL + HALF, - }) + const at = (c: Coord) => ({ x: c.col * CELL + HALF, y: c.row * CELL + HALF }) + const me = at(patrol.sector) + + const cells = [] + for (let row = 0; row < SECTORS; row++) { + for (let col = 0; col < SECTORS; col++) { + const cell = { row, col } + const what = occupant(patrol, cell) + const dead = what === 'ship' || what === 'star' + cells.push( + onHover(cell)} + onMouseLeave={() => onHover(null)} + onClick={() => onSector({ at: cell, what })} + />, + ) + } + } return ( + {/* The course, drawn before it is flown. */} + {hover && !same(hover, patrol.sector) && occupant(patrol, hover) !== 'star' && ( + + + + + )} + {patrol.stars.map((s, i) => { const { x, y } = at(s) return ( @@ -94,7 +179,10 @@ function SectorGrid({ patrol }: { patrol: Patrol }) { })} {patrol.base && ( - + @@ -125,18 +213,31 @@ function SectorGrid({ patrol }: { patrol: Patrol }) { */} + + {/* Last, so nothing is drawn over the thing taking the clicks. */} + {cells} ) } /** The chart: what has been scanned, three digits at a time. */ -function GalaxyGrid({ patrol }: { patrol: Patrol }) { +function GalaxyGrid({ + patrol, + hover, + onHover, + onQuadrant, +}: { + patrol: Patrol + hover: Coord | null + onHover: (c: Coord | null) => void + onQuadrant: (at: Coord) => void +}) { const size = CELL * GALAXY if (patrol.damage.computer > 0) { @@ -146,20 +247,28 @@ function GalaxyGrid({ patrol }: { patrol: Patrol }) { const cells = [] for (let row = 0; row < GALAXY; row++) { for (let col = 0; col < GALAXY; col++) { + const cell = { row, col } const census = patrol.chart[row]![col]! - const here = row === patrol.quadrant.row && col === patrol.quadrant.col + const here = same(cell, patrol.quadrant) const classes = [ 'quad', census === null ? 'unknown' : '', census && census.raiders > 0 ? 'hostile' : '', census?.base ? 'has-base' : '', here ? 'here' : '', + hover && same(hover, cell) ? 'on' : '', ] .filter(Boolean) .join(' ') cells.push( - + onHover(cell)} + onMouseLeave={() => onHover(null)} + onClick={() => !here && onQuadrant(cell)} + > {census === null ? 'ยทยทยท' : censusCode(census)} @@ -209,6 +318,6 @@ function Rules({ n, size }: { n: number; size: number }) { } /** 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 { +function angle(from: Coord, to: Coord): number { return (Math.atan2(to.row - from.row, to.col - from.col) * 180) / Math.PI } diff --git a/src/components/screens.tsx b/src/components/screens.tsx index a804ee4..5a5c4a0 100644 --- a/src/components/screens.tsx +++ b/src/components/screens.tsx @@ -153,6 +153,10 @@ export function InstructionsScreen({ onDone }: { onDone: () => void }) { ))} + ON THE REMASTER YOU CAN POINT INSTEAD: CLICK A SECTOR TO WARP TO + IT, A RAIDER TO PUT A TORPEDO THROUGH IT, A STARBASE TO MOOR ALONGSIDE, OR A QUADRANT + ON THE CHART TO CROSS TO IT. IT TYPES THE SAME COMMANDS FOR YOU, AND SAYS SO. + UNDERSTOOD diff --git a/src/game/aim.test.ts b/src/game/aim.test.ts new file mode 100644 index 0000000..48fef89 --- /dev/null +++ b/src/game/aim.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { SECTORS } from './constants' +import { courseTo, landing, navFor, stepsFor } from './aim' +import { heading } from './galaxy' + +/** + * Clicking a sector has to arrive at that sector, or the remaster is a + * different game from the one on paper rather than the same one with the + * lamp on. This is the test that says so. + */ + +describe('reading a course off a direction', () => { + it('recovers the eight spokes exactly', () => { + const spokes: [number, number, number][] = [ + [0, 1, 1], // east, along the row + [-1, 1, 2], + [-1, 0, 3], // north + [-1, -1, 4], + [0, -1, 5], + [1, -1, 6], + [1, 0, 7], // south + [1, 1, 8], + ] + for (const [row, col, course] of spokes) { + expect(courseTo({ row, col }), `${row},${col}`).toBe(course) + } + }) + + it('counts steps along the heading, not as the crow flies', () => { + // Three diagonal steps is three, even though it covers 4.24 sectors. + expect(stepsFor({ row: 3, col: 3 }, 8)).toBe(3) + expect(stepsFor({ row: 0, col: 5 }, 1)).toBe(5) + }) +}) + +describe('clicking a sector', () => { + /** + * The whole grid against itself: 4032 ordered pairs of distinct sectors, + * every one of them turned into a course and a warp and then flown by the + * same arithmetic the engine uses. + */ + it('lands on the sector that was clicked, from anywhere to anywhere', () => { + const misses: string[] = [] + + for (let fr = 0; fr < SECTORS; fr++) { + for (let fc = 0; fc < SECTORS; fc++) { + for (let tr = 0; tr < SECTORS; tr++) { + for (let tc = 0; tc < SECTORS; tc++) { + if (fr === tr && fc === tc) continue + const from = { row: fr, col: fc } + const d = { row: tr - fr, col: tc - fc } + const { course, warp } = navFor(d) + const at = landing(from, course, warp) + if (at.row !== tr || at.col !== tc) { + misses.push(`${fr},${fc} -> ${tr},${tc} landed ${at.row},${at.col}`) + } + } + } + } + } + + expect(misses).toEqual([]) + }) + + it('asks for a course and a warp a player could have typed', () => { + const { course, warp } = navFor({ row: -2, col: 5 }) + expect(course).toBeGreaterThanOrEqual(1) + expect(course).toBeLessThan(9) + // One decimal place, which is what both prompts accept. + expect(course * 10).toBeCloseTo(Math.round(course * 10)) + expect(warp * 10).toBeCloseTo(Math.round(warp * 10)) + }) +}) + +describe('clicking a quadrant', () => { + it('crosses whole quadrants and keeps the sector you were in', () => { + // Two quadrants east is sixteen sectors east, and lands in the same + // sector of the new one. + const d = { row: 0, col: 2 * SECTORS } + const { course, warp } = navFor(d) + expect(course).toBe(1) + expect(warp).toBe(2) + expect(heading(course)).toEqual({ row: 0, col: 1 }) + }) +}) diff --git a/src/game/aim.ts b/src/game/aim.ts new file mode 100644 index 0000000..572b246 --- /dev/null +++ b/src/game/aim.ts @@ -0,0 +1,73 @@ +import { SECTORS } from './constants' +import { heading } from './galaxy' +import type { Coord } from './types' + +/** + * Pointing, translated into steering. + * + * The remaster lets you click where you want to go, and the engine takes a + * course from 1 to 9 and a warp factor. Nothing here is a second way to play + * the game -- it is a way to type. Everything below produces a course and a + * warp a player could have entered themselves, at the same one-decimal + * precision the prompt accepts, and the log prints them as though they had. + * + * The course is found by search rather than by algebra, and deliberately. + * `heading()` is piecewise linear between the eight spokes, so its magnitude + * is not constant -- it runs from 1 on the axes to root two on the diagonals + * -- and inverting that in closed form is a page of case analysis that would + * be wrong in one of the octants. Eighty-one candidates at a tenth apiece is + * the whole search space of things a player could type, so trying all of them + * is both simpler and exact where it matters. + */ + +const DEG = 180 / Math.PI + +/** The course whose heading points most nearly along `d`. */ +export function courseTo(d: Coord): number { + const want = Math.atan2(d.row, d.col) + let best = 1 + let bestErr = Infinity + + for (let c = 10; c < 90; c++) { + const course = c / 10 + const h = heading(course) + // Angular distance, wrapped, so 359 degrees away counts as one. + const err = Math.abs(((Math.atan2(h.row, h.col) - want) * DEG + 540) % 360) - 180 + if (Math.abs(err) < bestErr) { + bestErr = Math.abs(err) + best = course + } + } + return best +} + +/** + * How many sectors to travel on that course to arrive at `d`. + * + * Measured along the heading rather than as a straight-line distance, + * because a diagonal step covers root two sectors and the engine counts + * steps, not distance. + */ +export function stepsFor(d: Coord, course: number): number { + const h = heading(course) + const len = Math.hypot(h.row, h.col) + return Math.max(1, Math.round(Math.hypot(d.row, d.col) / len)) +} + +/** A whole answer to both prompts, for a move of `d` sectors. */ +export function navFor(d: Coord): { course: number; warp: number } { + const course = courseTo(d) + const steps = stepsFor(d, course) + // Warp is sectors over eight, at the tenth the prompt accepts. + return { course, warp: Math.round((steps / SECTORS) * 10) / 10 } +} + +/** Where a warp on this course actually ends up, by the engine's own rules. */ +export function landing(from: Coord, course: number, warp: number): Coord { + const h = heading(course) + const steps = Math.round(warp * SECTORS) + return { + row: Math.round(from.row + h.row * steps), + col: Math.round(from.col + h.col * steps), + } +}