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:
+85
@@ -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;
|
||||
|
||||
+255
-19
@@ -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' ? (
|
||||
<div className="bridge">
|
||||
<ShipPanel patrol={patrol} captainName={captainName} />
|
||||
<TacticalView patrol={patrol} panel={panel} onPanel={setPanel} />
|
||||
<TacticalView
|
||||
patrol={patrol}
|
||||
panel={panel}
|
||||
onPanel={setPanel}
|
||||
onSector={clickSector}
|
||||
onQuadrant={clickQuadrant}
|
||||
/>
|
||||
<LogPanel lines={transcript} />
|
||||
</div>
|
||||
) : (
|
||||
@@ -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' && (
|
||||
<div className="keys">
|
||||
{COMMANDS.map(([id, what]) => (
|
||||
<button
|
||||
key={id}
|
||||
className="btn btn-ghost key"
|
||||
title={what}
|
||||
onClick={() => {
|
||||
onBlip()
|
||||
submit(id)
|
||||
}}
|
||||
>
|
||||
{id}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{ask === 'command' &&
|
||||
(skin === 'modern' ? (
|
||||
<ActionBar
|
||||
patrol={patrol}
|
||||
onSimple={simpleClick}
|
||||
onSpend={spend}
|
||||
onBlip={onBlip}
|
||||
/>
|
||||
) : (
|
||||
<div className="keys">
|
||||
{COMMANDS.map(([id, what]) => (
|
||||
<button
|
||||
key={id}
|
||||
className="btn btn-ghost key"
|
||||
title={what}
|
||||
onClick={() => {
|
||||
onBlip()
|
||||
submit(id)
|
||||
}}
|
||||
>
|
||||
{id}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="actions">
|
||||
<div className="action-row">
|
||||
<button className="btn btn-ghost key" onClick={() => arm('beams')}>
|
||||
BEAMS
|
||||
</button>
|
||||
<button className="btn btn-ghost key" onClick={() => arm('shields')}>
|
||||
SHIELDS
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost key"
|
||||
onClick={() => onSimple('LRS', { type: 'lrs' })}
|
||||
title="Scan the nine quadrants around this one"
|
||||
>
|
||||
LONG RANGE
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost key"
|
||||
onClick={() => onSimple('COM', { type: 'chart' })}
|
||||
title="The chart, and where you are on it"
|
||||
>
|
||||
CHART
|
||||
</button>
|
||||
<button className="btn btn-ghost key" onClick={() => onSimple('DAM', { type: 'damage' })}>
|
||||
DAMAGE
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost key"
|
||||
onClick={() => onSimple('XXX', { type: 'resign' })}
|
||||
title="Break off and go home"
|
||||
>
|
||||
BREAK OFF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{armed && (
|
||||
<div className="action-row amounts">
|
||||
<span className="panel-label">
|
||||
{armed === 'beams' ? 'UNITS TO FIRE' : 'UNITS TO SHIELDS'}
|
||||
</span>
|
||||
{presets.map((n) => (
|
||||
<button className="btn btn-ghost key" key={n} onClick={() => fire(n)}>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
{armed === 'beams' ? (
|
||||
<button className="btn btn-ghost key" onClick={() => fire(patrol.energy / 2)}>
|
||||
HALF
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-ghost key" onClick={() => fire(0)}>
|
||||
DOWN
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-ghost key cancel" onClick={() => setArmed(null)}>
|
||||
CANCEL
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+126
-17
@@ -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<Coord | null>(null)
|
||||
|
||||
return (
|
||||
<div className="tactical">
|
||||
<div className="panel-tabs">
|
||||
@@ -54,23 +68,86 @@ export function TacticalView({
|
||||
GALAXY
|
||||
</button>
|
||||
</div>
|
||||
{panel === 'sector' ? <SectorGrid patrol={patrol} /> : <GalaxyGrid patrol={patrol} />}
|
||||
|
||||
{panel === 'sector' ? (
|
||||
<SectorGrid patrol={patrol} hover={hover} onHover={setHover} onSector={onSector} />
|
||||
) : (
|
||||
<GalaxyGrid patrol={patrol} hover={hover} onHover={setHover} onQuadrant={onQuadrant} />
|
||||
)}
|
||||
|
||||
<p className="aim-readout">{caption(patrol, panel, hover)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 <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,
|
||||
})
|
||||
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(
|
||||
<rect
|
||||
className={`cell ${dead ? 'cell-dead' : ''} ${hover && same(hover, cell) ? 'on' : ''}`}
|
||||
key={`${row}-${col}`}
|
||||
x={col * CELL}
|
||||
y={row * CELL}
|
||||
width={CELL}
|
||||
height={CELL}
|
||||
onMouseEnter={() => onHover(cell)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
onClick={() => onSector({ at: cell, what })}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
@@ -82,6 +159,14 @@ function SectorGrid({ patrol }: { patrol: Patrol }) {
|
||||
<rect className="grid-bg" x="-6" y="-6" width={size + 12} height={size + 12} rx="12" />
|
||||
<Rules n={SECTORS} size={size} />
|
||||
|
||||
{/* The course, drawn before it is flown. */}
|
||||
{hover && !same(hover, patrol.sector) && occupant(patrol, hover) !== 'star' && (
|
||||
<g className="aim">
|
||||
<line x1={me.x} y1={me.y} x2={at(hover).x} y2={at(hover).y} />
|
||||
<circle cx={at(hover).x} cy={at(hover).y} r="15" />
|
||||
</g>
|
||||
)}
|
||||
|
||||
{patrol.stars.map((s, i) => {
|
||||
const { x, y } = at(s)
|
||||
return (
|
||||
@@ -94,7 +179,10 @@ function SectorGrid({ patrol }: { patrol: Patrol }) {
|
||||
})}
|
||||
|
||||
{patrol.base && (
|
||||
<g className="mark mark-base" transform={`translate(${at(patrol.base).x} ${at(patrol.base).y})`}>
|
||||
<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>
|
||||
@@ -125,18 +213,31 @@ function SectorGrid({ patrol }: { patrol: Patrol }) {
|
||||
*/}
|
||||
<g
|
||||
className={`mark mark-ship ${condition(patrol) === 'RED' ? 'red' : ''}`}
|
||||
transform={`translate(${at(patrol.sector).x} ${at(patrol.sector).y})`}
|
||||
transform={`translate(${me.x} ${me.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>
|
||||
|
||||
{/* Last, so nothing is drawn over the thing taking the clicks. */}
|
||||
{cells}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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(
|
||||
<g className={classes} key={`${row}-${col}`}>
|
||||
<g
|
||||
className={classes}
|
||||
key={`${row}-${col}`}
|
||||
onMouseEnter={() => onHover(cell)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
onClick={() => !here && onQuadrant(cell)}
|
||||
>
|
||||
<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)}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -153,6 +153,10 @@ export function InstructionsScreen({ onDone }: { onDone: () => void }) {
|
||||
</Line>
|
||||
))}
|
||||
<Line> </Line>
|
||||
<Line className="dim">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.</Line>
|
||||
<Line> </Line>
|
||||
<Btn kind="primary" onClick={onDone}>
|
||||
UNDERSTOOD
|
||||
</Btn>
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user