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' && (
-
+ ))}
+
+ )}
+
+ )
+}
+
+/**
+ * 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 (
+
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
)
}
+/** 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),
+ }
+}