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.
This commit is contained in:
2026-09-08 23:53:23 -07:00
parent d623315517
commit c00772058e
7 changed files with 631 additions and 38 deletions
+85
View File
@@ -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 })
})
})
+73
View File
@@ -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),
}
}