From c00772058e2ac71f0d8f8778ae56a051dcaaac96 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 8 Sep 2026 23:53:23 -0700 Subject: [PATCH] Point at it: the remaster plays with the mouse 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. Hovering draws the course and says what it will cost first, which is also how anybody works out that course 3 is north. None of it is a new move. A click becomes the same course and warp factor the prompt takes, at the same one decimal place, and the log writes the questions and the answers as though they had been typed. The keyboard still works on both skins. The conversion is the inverse of a piecewise-linear compass, so it is found by searching the eighty-one courses a player could actually type rather than by inverting eight octants of algebra. It is tested by flying it: all 4,032 ordered pairs of sectors in a quadrant, turned into a course and a warp and flown by the engine's own arithmetic, have to land on the sector that was clicked. They do. NAV, TOR and SRS leave the remaster's command row, because each of them is now a thing on screen to click. What is left becomes an action bar, and the two that spend energy ask how much as a row of amounts rather than a second prompt. --- README.md | 5 +- src/App.css | 85 ++++++++++ src/components/BridgeScreen.tsx | 274 +++++++++++++++++++++++++++++--- src/components/TacticalView.tsx | 143 +++++++++++++++-- src/components/screens.tsx | 4 + src/game/aim.test.ts | 85 ++++++++++ src/game/aim.ts | 73 +++++++++ 7 files changed, 631 insertions(+), 38 deletions(-) create mode 100644 src/game/aim.test.ts create mode 100644 src/game/aim.ts 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), + } +}