Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/plans/air-war-v5.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,9 @@ aircraft units; air-ops only sets waypoints/decisions):
the existing visibility + fire-control network does the rest.
- **RTB**: at bingo or out of weapons → waypoints home, on arrival the Flight
unit is removed, squadron readyAt += turnaround, `available` restored minus
losses. Flight destroyed → airframes lost, pilot roll per airframe:
KIA (−2 war support) / rescued (+1) / POW (−4, OSINT event).
losses. Flight destroyed → airframes lost, one pilot-fate roll per flight:
KIA (−2 war support) / rescued (−1, a rescue softens the loss) / POW (−4,
OSINT event).
- **Iran AI**: scramble-only — when a detected USA package/flight approaches a
defended box, spawn an interceptor CAP from the nearest airbase with
available fighters (ai.ts hook, max 2 concurrent). Su-35 squadron only
Expand Down
7 changes: 5 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import EconomyPanel from '@/components/panels/EconomyPanel'
import OrbatPanel from '@/components/panels/OrbatPanel'
import StatsPanel from '@/components/panels/StatsPanel'
import IntelPanel from '@/components/panels/IntelPanel'
import AirOpsPanel from '@/components/panels/AirOpsPanel'
import ImintViewer from '@/components/intel/ImintViewer'
import LiveFeeds from '@/components/intel/LiveFeeds'
import OsintTicker from '@/components/intel/OsintTicker'
Expand Down Expand Up @@ -72,6 +73,7 @@ export default function App() {
const showStats = useUIStore((s) => s.showStats)
const showEconomy = useUIStore((s) => s.showEconomy)
const showIntel = useUIStore((s) => s.showIntel)
const showAirOps = useUIStore((s) => s.showAirOps)
// StrikePanel manages its own visibility via useStrikeStore

// Debrief overlay: shown when the war is decided, until dismissed (keyed by
Expand Down Expand Up @@ -141,9 +143,9 @@ export default function App() {
const ui = useUIStore.getState()
// Close the topmost registered panel first (ImintViewer capture-stops its own Escape before this runs)
if (ui.closeTopPanel()) break
if (ui.leftPanel !== null || ui.showIntel || strike.open) {
if (ui.leftPanel !== null || ui.showIntel || ui.showAirOps || strike.open) {
ui.setLeftPanel(null)
useUIStore.setState({ showIntel: false })
useUIStore.setState({ showIntel: false, showAirOps: false })
strike.closeStrike()
break
}
Expand Down Expand Up @@ -195,6 +197,7 @@ export default function App() {
{showStats && <StatsPanel />}
{showEconomy && <EconomyPanel />}
{showIntel && <IntelPanel />}
{showAirOps && <AirOpsPanel />}
<LiveFeeds />
<ImintViewer />
{showDebrief && <DebriefScreen onDismiss={dismissDebrief} />}
Expand Down
30 changes: 29 additions & 1 deletion src/components/hud/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ export default function TopBar() {
const toggleIntel = useUIStore((s) => s.toggleIntel)
const liveFeedsOpen = useUIStore((s) => s.liveFeedsOpen)
const toggleLiveFeeds = useUIStore((s) => s.toggleLiveFeeds)
const showAirOps = useUIStore((s) => s.showAirOps)
const toggleAirOps = useUIStore((s) => s.toggleAirOps)
const placingCatalogId = useIntelStore((s) => s.placingCatalogId)

const units = useGameStore((s) => s.viewState.units)
Expand All @@ -122,6 +124,7 @@ export default function TopBar() {
const shippingLanes = useGameStore((s) => s.viewState.shippingLanes)
const warSupport = useGameStore((s) => s.viewState.warSupport)
const objectives = useGameStore((s) => s.viewState.objectives)
const surgeOps = useGameStore((s) => s.viewState.surgeOps)
const eventLog = useGameStore((s) => s.eventLog)
const hormuzLane = shippingLanes.find((l) => l.id === 'hormuz')

Expand Down Expand Up @@ -454,6 +457,10 @@ export default function TopBar() {

<Sep />

{/* Air ops panel toggle + surge indicator */}
<ToggleBtn active={showAirOps} onClick={toggleAirOps} label="AIR" />
{surgeOps && <SurgeChip />}

{/* Intel panel toggle */}
<IntelBtn active={showIntel || !!placingCatalogId} onClick={toggleIntel} compact={false} />

Expand Down Expand Up @@ -970,7 +977,7 @@ function exitToMainMenu() {
const ui = useUIStore.getState()
ui.clearSelection()
ui.setLeftPanel(null)
useUIStore.setState({ showIntel: false })
useUIStore.setState({ showIntel: false, showAirOps: false })
useMenuStore.getState().setScreen('start')
}

Expand Down Expand Up @@ -1208,6 +1215,27 @@ function LiveBtn({ active, onClick }: { active: boolean; onClick: () => void })
)
}

function SurgeChip() {
return (
<>
<style>{'@keyframes surge-pulse{0%,100%{opacity:1}50%{opacity:0.35}}'}</style>
<span style={{
color: 'var(--status-engaged)',
border: '1px solid var(--status-engaged)',
borderRadius: 3,
padding: '2px 4px',
fontFamily: 'var(--font-mono)',
fontSize: 'var(--font-size-xs)',
fontWeight: 700,
whiteSpace: 'nowrap',
animation: 'surge-pulse 1.4s ease-in-out infinite',
}}>
SURGE
</span>
</>
)
}

function MuteBtn() {
const muted = useUIStore((s) => s.audioMuted)
const toggleAudioMuted = useUIStore((s) => s.toggleAudioMuted)
Expand Down
22 changes: 22 additions & 0 deletions src/components/hud/__tests__/TopBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,3 +321,25 @@ describe('TopBar time controls', () => {
expect(useUIStore.getState().liveFeedsOpen).toBe(true)
})
})

describe('TopBar air ops', () => {
it('toggles the air ops panel with the AIR button', () => {
useUIStore.setState({ showAirOps: false })
render(<TopBar />)
fireEvent.click(screen.getByText('AIR'))
expect(useUIStore.getState().showAirOps).toBe(true)
fireEvent.click(screen.getByText('AIR'))
expect(useUIStore.getState().showAirOps).toBe(false)
})

it('shows the SURGE chip only while surge ops is active', () => {
setStore(makeViewState({ surgeOps: true }))
const { unmount } = render(<TopBar />)
expect(screen.getByText('SURGE')).toBeTruthy()
unmount()

setStore(makeViewState({}))
render(<TopBar />)
expect(screen.queryByText('SURGE')).toBeNull()
})
})
13 changes: 10 additions & 3 deletions src/components/map/DeckOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { MapboxOverlay } from '@deck.gl/mapbox'
import type { MapboxOverlayProps } from '@deck.gl/mapbox'
import { createIntelMapLayers } from './layers/IntelLayers'
import { createEffectsLayers, KILL_ANIM_MS } from './layers/EffectsLayers'
import { createAirMapLayers } from './layers/AirLayers'
import { useGameStore } from '@/store/game-store'
import { useUIStore } from '@/store/ui-store'
import { useMapIntelStore } from '@/store/map-intel-store'
Expand All @@ -15,6 +16,7 @@ export default function DeckOverlay(props: MapboxOverlayProps) {
const tick = useGameStore((s) => s.viewState.time.tick)
const playerNation = useGameStore((s) => s.viewState.playerNation)
const intel = useGameStore((s) => s.viewState.intel)
const airMissions = useGameStore((s) => s.viewState.airMissions)
const eventLog = useGameStore((s) => s.eventLog)
const selectedUnitId = useUIStore((s) => s.selectedUnitId)
const intelOverlays = useMapIntelStore((s) => s.intelOverlays)
Expand Down Expand Up @@ -101,11 +103,16 @@ export default function DeckOverlay(props: MapboxOverlayProps) {
[killMarkers, trackHistory, units, playerNation, effectsNowMs],
)

const airLayers = useMemo(
() => createAirMapLayers({ missions: airMissions ?? [], units, selectedUnitId }),
[airMissions, units, selectedUnitId],
)

// Intel layers prepend (draw beneath) the game layers passed by GameMap;
// effects sit between — above rings/overlays, below unit icons
// effects and air-war glyphs sit between — above rings/overlays, below unit icons
const mergedProps = useMemo<MapboxOverlayProps>(
() => ({ ...props, layers: [...intelLayers, ...effectsLayers, ...(props.layers ?? [])] }),
[props, intelLayers, effectsLayers],
() => ({ ...props, layers: [...intelLayers, ...effectsLayers, ...airLayers, ...(props.layers ?? [])] }),
[props, intelLayers, effectsLayers, airLayers],
)

const overlay = useControl<MapboxOverlay>(
Expand Down
132 changes: 132 additions & 0 deletions src/components/map/layers/AirLayers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { LineLayer, PathLayer } from '@deck.gl/layers'
import type { Layer } from '@deck.gl/core'
import type { AirMission } from '@/types/game'
import type { ViewUnit } from '@/types/view'

type LngLat = [number, number]

const KM_PER_DEG_LAT = 110.574
const KM_PER_DEG_LON_EQ = 111.32

const CAP_CYAN: [number, number, number, number] = [64, 224, 240, 170]
const AEW_WHITE: [number, number, number, number] = [235, 235, 235, 150]
const STRIKE_AMBER: [number, number, number, number] = [212, 168, 84, 230]
const STRIKE_AMBER_DIM: [number, number, number, number] = [212, 168, 84, 102]

const RACETRACK_LEG_KM = 18
const RACETRACK_RADIUS_KM = 7

function offsetKm(lng: number, lat: number, eastKm: number, northKm: number): LngLat {
const dLat = northKm / KM_PER_DEG_LAT
const dLng = eastKm / (KM_PER_DEG_LON_EQ * Math.cos((lat * Math.PI) / 180))
return [lng + dLng, lat + dLat]
}

/** Point on a stadium (racetrack) perimeter at arc-distance d, in local km offsets */
function stadiumPointKm(d: number, legKm: number, rKm: number): [number, number] {
const arc = Math.PI * rKm
if (d < legKm) return [-legKm / 2 + d, rKm]
d -= legKm
if (d < arc) {
const a = d / rKm
return [legKm / 2 + Math.sin(a) * rKm, Math.cos(a) * rKm]
}
d -= arc
if (d < legKm) return [legKm / 2 - d, -rKm]
d -= legKm
const a = d / rKm
return [-legKm / 2 - Math.sin(a) * rKm, -Math.cos(a) * rKm]
}

// Dashed oval as separate short arcs — PathStyleExtension is not a dependency
function dashedRacetrackPaths(lng: number, lat: number, dashes = 28): LngLat[][] {
const total = 2 * RACETRACK_LEG_KM + 2 * Math.PI * RACETRACK_RADIUS_KM
const slot = total / dashes
const paths: LngLat[][] = []
for (let i = 0; i < dashes; i++) {
const arc: LngLat[] = []
for (let j = 0; j <= 3; j++) {
const [e, n] = stadiumPointKm((i * slot + (j / 3) * slot * 0.55) % total, RACETRACK_LEG_KM, RACETRACK_RADIUS_KM)
arc.push(offsetKm(lng, lat, e, n))
}
paths.push(arc)
}
return paths
}

interface StationDashDatum {
path: LngLat[]
kind: AirMission['kind']
}

interface StrikeLineDatum {
id: string
source: LngLat
target: LngLat
selected: boolean
}

export interface AirMapLayerInputs {
missions: AirMission[]
units: ViewUnit[]
selectedUnitId: string | null
}

/** CAP/AEW station racetracks + strike flight→target lines. Missions arrive pre-filtered to the player nation. */
export function createAirMapLayers(opts: AirMapLayerInputs): Layer[] {
const { missions, units, selectedUnitId } = opts
const unitById = new Map(units.map((u) => [u.id, u]))

const dashData: StationDashDatum[] = []
const lineData: StrikeLineDatum[] = []
for (const m of missions) {
if ((m.kind === 'cap' || m.kind === 'aew') && m.station && (m.status === 'active' || m.status === 'planning')) {
for (const path of dashedRacetrackPaths(m.station.lng, m.station.lat)) {
dashData.push({ path, kind: m.kind })
}
}
if (m.kind === 'strike' && m.status === 'active' && m.flightUnitId !== undefined && m.targetId !== undefined) {
const flight = unitById.get(m.flightUnitId)
const target = unitById.get(m.targetId)
if (!flight || !target || flight.status === 'destroyed') continue
lineData.push({
id: m.id,
source: [flight.position.lng, flight.position.lat],
target: [target.position.lng, target.position.lat],
selected: selectedUnitId === flight.id,
})
}
}

const layers: Layer[] = []
if (dashData.length > 0) {
layers.push(
new PathLayer<StationDashDatum>({
id: 'air-station-racetracks',
data: dashData,
getPath: (d) => d.path,
getColor: (d) => (d.kind === 'aew' ? AEW_WHITE : CAP_CYAN),
widthUnits: 'pixels',
getWidth: 1,
widthMinPixels: 1,
pickable: false,
}),
)
}
if (lineData.length > 0) {
layers.push(
new LineLayer<StrikeLineDatum>({
id: 'air-strike-lines',
data: lineData,
getSourcePosition: (d) => d.source,
getTargetPosition: (d) => d.target,
// Unselected flights keep a faint intent line (40%); selection brings it up
getColor: (d) => (d.selected ? STRIKE_AMBER : STRIKE_AMBER_DIM),
getWidth: 1,
widthUnits: 'pixels',
pickable: false,
}),
)
}
return layers
}
Loading