feat: v0.3.0 — flat 2D camera: remove zoom-driven tilt + terrain; max zoom ~100 km screen width
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# 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
|
## 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
|
- `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)
|
- Verified: unit suite 8/8; `tsc --noEmit` clean; e2e suite 3/3 green against the rebuilt compose instance (`/api/airports` serves 242 features)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Flight Ops — Game Design
|
# Flight Ops — Game Design
|
||||||
|
|
||||||
Browser game, pure economic gameplay: run a small airplane transport company in Europe.
|
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)
|
## Layout (single screen)
|
||||||
- Left pane — Assets: fleet list (plane type, status, fuel, location), wallet, reputation
|
- 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
|
## 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
|
- 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)
|
- 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/`
|
- Game-logic data (airports, runways, countries/regions): downloaded CSV dataset cached in `data/`
|
||||||
|
|
||||||
## Simulation engine
|
## Simulation engine
|
||||||
|
|||||||
+3
-27
@@ -3,16 +3,14 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import * as maplibregl from 'maplibre-gl';
|
import * as maplibregl from 'maplibre-gl';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
import { pitchForZoom } from '@/lib/camera';
|
|
||||||
|
|
||||||
const EUROPE_CENTER: [number, number] = [10, 52];
|
const EUROPE_CENTER: [number, number] = [10, 52];
|
||||||
const INITIAL_ZOOM = 4;
|
const INITIAL_ZOOM = 4;
|
||||||
// Useful gameplay range: continent overview → city detail (10–100 m/px). The
|
// Flat 2D view only. Max zoom ≈ ~100 km across a typical desktop viewport width at
|
||||||
// zoom→pitch camera transition completes at z12; street-level zoom has no value.
|
// European latitudes (~47 m/px at z11, 52°N); street-level zoom has no gameplay value.
|
||||||
export const MIN_ZOOM = 3;
|
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 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 {
|
function escapeHtml(s: string): string {
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
@@ -25,10 +23,6 @@ export default function Map() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
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
|
// 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.
|
// route that serves the worker script (and its relative imports) from node_modules.
|
||||||
maplibregl.setWorkerUrl('/api/mlworker/maplibre-gl-worker.mjs');
|
maplibregl.setWorkerUrl('/api/mlworker/maplibre-gl-worker.mjs');
|
||||||
@@ -38,20 +32,12 @@ export default function Map() {
|
|||||||
style: OPENFREEMAP_STYLE,
|
style: OPENFREEMAP_STYLE,
|
||||||
center: EUROPE_CENTER,
|
center: EUROPE_CENTER,
|
||||||
zoom: INITIAL_ZOOM,
|
zoom: INITIAL_ZOOM,
|
||||||
pitch: 0,
|
|
||||||
minZoom: MIN_ZOOM,
|
minZoom: MIN_ZOOM,
|
||||||
maxZoom: MAX_ZOOM,
|
maxZoom: MAX_ZOOM,
|
||||||
});
|
});
|
||||||
// Dev/e2e hook so tests can project coordinates to screen space.
|
// Dev/e2e hook so tests can project coordinates to screen space.
|
||||||
(window as unknown as Record<string, unknown>).__flightMap = map;
|
(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
|
// 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
|
// as the style is applied ('styledata') rather than waiting for 'load', which also
|
||||||
// requires all tile sources to finish.
|
// requires all tile sources to finish.
|
||||||
@@ -60,16 +46,6 @@ export default function Map() {
|
|||||||
if (initialized) return;
|
if (initialized) return;
|
||||||
initialized = true;
|
initialized = true;
|
||||||
try {
|
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');
|
const res = await fetch('/api/airports');
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = (await res.json()) as { features: unknown[] };
|
const data = (await res.json()) as { features: unknown[] };
|
||||||
|
|||||||
+2
-4
@@ -1,12 +1,10 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
// Terrain is disabled in e2e (?terrain=0) for deterministic headless rendering;
|
const APP_URL = '/';
|
||||||
// the terrain code path stays toggleable on by default in the app.
|
|
||||||
const APP_URL = '/?terrain=0';
|
|
||||||
|
|
||||||
// Must match MIN_ZOOM / MAX_ZOOM exported from components/Map.tsx.
|
// Must match MIN_ZOOM / MAX_ZOOM exported from components/Map.tsx.
|
||||||
const MIN_ZOOM = 3;
|
const MIN_ZOOM = 3;
|
||||||
const MAX_ZOOM = 14;
|
const MAX_ZOOM = 11;
|
||||||
|
|
||||||
async function waitForAirportCount(page: Page): Promise<number> {
|
async function waitForAirportCount(page: Page): Promise<number> {
|
||||||
const countEl = page.getByTestId('airport-count');
|
const countEl = page.getByTestId('airport-count');
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.2.9",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user