feat: v0.3.0 — flat 2D camera: remove zoom-driven tilt + terrain; max zoom ~100 km screen width

This commit is contained in:
2026-09-17 00:33:10 +00:00
parent 11619d11b2
commit b7796e5d78
7 changed files with 15 additions and 80 deletions
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## v0.3.0 — 2026-09-17 00:32
- Removed the zoom-driven camera tilt (`lib/camera.ts` pitch curve + the map `move` handler): the map is now always flat/top-down with standard pan/zoom only; deleted `lib/camera.ts` and its unit tests
- Removed terrain: no more DEM raster source / `map.setTerrain`, and the `?terrain=0` / `NEXT_PUBLIC_TERRAIN` toggles are gone — pure 2D view, no 3D elements
- MAX_ZOOM 14 → 11: at European latitudes (~52°N) z11 ≈ 47 m/px, so the viewport spans ~90–135 km across a typical desktop width (≈100 km on screen); street-level zoom had no gameplay value
- `e2e/map.spec.ts`: APP_URL is now `/` (terrain flag removed), MAX_ZOOM mirror updated to 11; PLAN.md map section updated to flat-2D-only
- Verified: unit suite 8/8; `tsc --noEmit` clean; e2e suite 3/3 green against the rebuilt compose instance (flat view, wheel zoom clamped to [3, 11])
## v0.2.9 — 2026-09-17 00:21
- `data/airports_eu.csv`: multi-airport metro areas reduced to one dot each on the map — Paris→CDG, London→LHR, Moscow→SVO, Milan→MXP, Istanbul→IST, Warsaw→WAW, Brussels→BRU (Rhine-Ruhr keeps both DUS and CGN as distinct cities); dropped 18 satellite airports incl. Orly, Gatwick, Luton, Stansted, Domodedovo, Vnukovo, Zhukovsky, Linate, Caravaggio, Atatürk, Modlin, Charleroi, Weeze, Prestwick, Treviso, Ciampino, Băneasa — 260 → 242 rows; `/api/airports` and the circle layer pick it up with no code changes
- Verified: unit suite 8/8; `tsc --noEmit` clean; e2e suite 3/3 green against the rebuilt compose instance (`/api/airports` serves 242 features)
+2 -2
View File
@@ -1,7 +1,7 @@
# Flight Ops — Game Design
Browser game, pure economic gameplay: run a small airplane transport company in Europe.
No manual flying — planes auto-fly assigned routes; you manage the business from an ops dashboard over a 2D map (lite 3D terrain if it stays lightweight).
No manual flying — planes auto-fly assigned routes; you manage the business from an ops dashboard over a 2D map.
## Layout (single screen)
- Left pane — Assets: fleet list (plane type, status, fuel, location), wallet, reputation
@@ -14,7 +14,7 @@ Accept job → dispatch a plane → watch it fly the route on the map → earn m
## Map & rendering
- MapLibre GL JS with vector tiles (OpenFreeMap/OSM-based) — crisp detail down to ~10–100 m/px at city zoom, smooth continent overview; no Cesium needed
- Camera: top-down when zoomed out, tilts to ~45° as you zoom in (zoom-driven pitch transition)
- Lite 3D once tilted: terrain/hillshade from free DEM tiles + building extrusion if it stays lightweight, otherwise flat 2D
- View is flat 2D only (zoom-driven tilt and terrain were removed in v0.3.0 as over-engineered)
- Game-logic data (airports, runways, countries/regions): downloaded CSV dataset cached in `data/`
## Simulation engine
+3 -27
View File
@@ -3,16 +3,14 @@
import { useEffect, useRef, useState } from 'react';
import * as maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { pitchForZoom } from '@/lib/camera';
const EUROPE_CENTER: [number, number] = [10, 52];
const INITIAL_ZOOM = 4;
// Useful gameplay range: continent overview → city detail (10–100 m/px). The
// zoom→pitch camera transition completes at z12; street-level zoom has no value.
// Flat 2D view only. Max zoom ≈ ~100 km across a typical desktop viewport width at
// European latitudes (~47 m/px at z11, 52°N); street-level zoom has no gameplay value.
export const MIN_ZOOM = 3;
export const MAX_ZOOM = 14;
export const MAX_ZOOM = 11;
const OPENFREEMAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
const TERRAIN_TILES = ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'];
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -25,10 +23,6 @@ export default function Map() {
useEffect(() => {
if (!containerRef.current) return;
const terrainEnabled =
new URLSearchParams(window.location.search).get('terrain') !== '0' &&
process.env.NEXT_PUBLIC_TERRAIN !== '0';
// Bundlers (Turbopack) break MapLibre's derived worker URL, so point it at a
// route that serves the worker script (and its relative imports) from node_modules.
maplibregl.setWorkerUrl('/api/mlworker/maplibre-gl-worker.mjs');
@@ -38,20 +32,12 @@ export default function Map() {
style: OPENFREEMAP_STYLE,
center: EUROPE_CENTER,
zoom: INITIAL_ZOOM,
pitch: 0,
minZoom: MIN_ZOOM,
maxZoom: MAX_ZOOM,
});
// Dev/e2e hook so tests can project coordinates to screen space.
(window as unknown as Record<string, unknown>).__flightMap = map;
map.on('move', () => {
const target = pitchForZoom(map.getZoom());
if (Math.abs(map.getPitch() - target) > 0.05) {
map.setPitch(target);
}
});
// Airports are GeoJSON, so they don't depend on basemap tiles: initialize as soon
// as the style is applied ('styledata') rather than waiting for 'load', which also
// requires all tile sources to finish.
@@ -60,16 +46,6 @@ export default function Map() {
if (initialized) return;
initialized = true;
try {
if (terrainEnabled) {
map.addSource('dem', {
type: 'raster-dem',
tiles: TERRAIN_TILES,
encoding: 'terrarium',
tileSize: 256,
});
map.setTerrain({ source: 'dem', exaggeration: 1.4 });
}
const res = await fetch('/api/airports');
if (!res.ok) return;
const data = (await res.json()) as { features: unknown[] };
+2 -4
View File
@@ -1,12 +1,10 @@
import { test, expect, type Page } from '@playwright/test';
// Terrain is disabled in e2e (?terrain=0) for deterministic headless rendering;
// the terrain code path stays toggleable on by default in the app.
const APP_URL = '/?terrain=0';
const APP_URL = '/';
// Must match MIN_ZOOM / MAX_ZOOM exported from components/Map.tsx.
const MIN_ZOOM = 3;
const MAX_ZOOM = 14;
const MAX_ZOOM = 11;
async function waitForAirportCount(page: Page): Promise<number> {
const countEl = page.getByTestId('airport-count');
-33
View File
@@ -1,33 +0,0 @@
import { describe, expect, it } from 'vitest';
import { pitchForZoom, smoothstep, PITCH_ZOOM_MIN, PITCH_ZOOM_MAX, MAX_PITCH_DEG } from './camera';
describe('smoothstep', () => {
it('clamps outside the edges and is 0.5 at the midpoint', () => {
expect(smoothstep(0, 1, -5)).toBe(0);
expect(smoothstep(0, 1, 2)).toBe(1);
expect(smoothstep(0, 1, 0.5)).toBeCloseTo(0.5);
});
});
describe('pitchForZoom', () => {
it('is 0 at and below the lower zoom threshold', () => {
expect(pitchForZoom(PITCH_ZOOM_MIN)).toBe(0);
expect(pitchForZoom(4)).toBe(0);
expect(pitchForZoom(0)).toBe(0);
});
it('is max pitch at and above the upper zoom threshold', () => {
expect(pitchForZoom(PITCH_ZOOM_MAX)).toBe(MAX_PITCH_DEG);
expect(pitchForZoom(15)).toBe(MAX_PITCH_DEG);
});
it('rises smoothly between thresholds (monotonic, midpoint = half max)', () => {
expect(pitchForZoom((PITCH_ZOOM_MIN + PITCH_ZOOM_MAX) / 2)).toBeCloseTo(MAX_PITCH_DEG / 2);
let prev = -1;
for (let z = PITCH_ZOOM_MIN; z <= PITCH_ZOOM_MAX; z += 0.25) {
const p = pitchForZoom(z);
expect(p).toBeGreaterThan(prev);
prev = p;
}
});
});
-13
View File
@@ -1,13 +0,0 @@
export const PITCH_ZOOM_MIN = 6;
export const PITCH_ZOOM_MAX = 12;
export const MAX_PITCH_DEG = 45;
export function smoothstep(edge0: number, edge1: number, x: number): number {
const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
// Pitch is a pure function of zoom so it never fights user pan/zoom gestures.
export function pitchForZoom(zoom: number): number {
return smoothstep(PITCH_ZOOM_MIN, PITCH_ZOOM_MAX, zoom) * MAX_PITCH_DEG;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
"version": "0.2.9",
"version": "0.3.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",