Added a tilted orthographic camera looking north

This commit is contained in:
2026-09-19 19:37:17 +02:00
parent 08f1b2d154
commit b514a19ecc
9 changed files with 62 additions and 21 deletions
+4
View File
@@ -169,6 +169,10 @@ input:focus, select:focus { border-color: var(--accent); }
avoids holding a huge composited layer in memory. */
#map-viewport.dragging #map-world { will-change: transform; }
.layer { position: absolute; left: 0; top: 0; }
/* Ground layers follow the camera's orthographic tilt (north-south
foreshortening); the sprite and label layers stay upright on top. The factor
is set from CAMERA_TILT by MapView. */
.layer.tilted { transform: scaleY(var(--camera-tilt, 1)); transform-origin: 0 0; }
/* A materialised block of the detailed map. Positioned at its world origin and
moved with `transform`; its tiles are positioned relative to it. */
.chunk { position: absolute; left: 0; top: 0; }
+11
View File
@@ -13,6 +13,7 @@ import { entityMethods } from "./map_view/entities.js";
import { motionMethods } from "./map_view/motion.js";
import { inputMethods } from "./map_view/input.js";
import { createGLMapRenderer } from "./map_view/webgl.js";
import { CAMERA_TILT } from "./map_view/constants.js";
// Re-exported for tests and callers that use the border geometry directly.
export { inwardOffset } from "./map_view/utils.js";
@@ -54,6 +55,16 @@ export class MapView {
this.$world.append(this.$labels);
}
// The orthographic camera tilts the ground plane, so the terrain and every
// ground overlay are scaled on Y while the sprite layers stay upright.
this.$viewport[0].style.setProperty("--camera-tilt", String(CAMERA_TILT));
for (const $layer of [
this.$terrain, this.$roads, this.$borders, this.$fog,
this.$highlight, this.$paths, this.$targets,
]) {
if ($layer && $layer.length) $layer.addClass("tilted");
}
this.seed = -1;
// Signature of the terrain currently built, so a snapshot with the same
// seed and config does not rebuild the world.
+8
View File
@@ -6,6 +6,14 @@ export const DRAG_THRESHOLD = 5;
export const ZOOM_MIN = 0.2;
export const ZOOM_MAX = 2.0;
// The camera looks north from an orthographic elevation of 70° above the
// ground. That foreshortens the north-south axis by sin(70°) ~= 0.94 while the
// east-west axis is untouched: a gentle 3D tilt that keeps the current
// orientation. It is applied to the terrain, roads, borders, fog and ground
// overlays; units, cities and labels stay upright on top.
export const CAMERA_ELEVATION_DEG = 45;
export const CAMERA_TILT = Math.sin((CAMERA_ELEVATION_DEG * Math.PI) / 180);
// A battle spreads the two stacks to either side of the tile centre, with the
// battle icon between them. Side offset is in pixels; step is the spacing
// between the (half-size) units of one stack.
+6 -3
View File
@@ -3,7 +3,7 @@
import { key, mapToLocal, parseKey } from "../../../shared/hex.js";
import { statusById } from "../../../shared/data.js";
import { BATTLE_SIDE_OFFSET } from "./constants.js";
import { BATTLE_SIDE_OFFSET, CAMERA_TILT } from "./constants.js";
import { hashEntries, pathProgress, nearestCorners, inwardOffset } from "./utils.js";
export const entityMethods = {
@@ -184,15 +184,18 @@ export const entityMethods = {
const local = mapToLocal(coords.x, coords.y);
view._localX = local.x;
view._localY = local.y;
// The sprite layers are not tilted, so a city stands on the foreshortened
// ground position rather than the raw world Y.
const wrappedX = local.x + this._wrapShift(local.x);
const groundY = local.y * CAMERA_TILT;
view.style.left = `${wrappedX}px`;
view.style.top = `${local.y}px`;
view.style.top = `${groundY}px`;
const owned = data.civ === this.localCiv;
const label = view._label;
label.textContent = data.name;
label.classList.toggle("capital", !!data.isCapital);
label.style.left = `${wrappedX}px`;
label.style.top = `${local.y}px`;
label.style.top = `${groundY}px`;
const marker = view.querySelector(".marker");
marker.style.background = this.civilisations[data.civ]
? this.civilisations[data.civ].primaryColour
+9 -5
View File
@@ -2,7 +2,7 @@
// hit-testing.
import { mapToLocal, key, parity, COL_STEP, HEX_W, HEX_H } from "../../../shared/hex.js";
import { DRAG_THRESHOLD, ZOOM_MIN, ZOOM_MAX } from "./constants.js";
import { CAMERA_TILT, DRAG_THRESHOLD, ZOOM_MIN, ZOOM_MAX } from "./constants.js";
import { CORNERS, distortedBoundary, periodUnits } from "./distortion.js";
export const inputMethods = {
@@ -33,8 +33,10 @@ export const inputMethods = {
const width = this.$viewport.width();
const height = this.$viewport.height();
const { x, y, zoom } = this.camera;
// The camera focus sits at the screen centre; the north-south axis is
// foreshortened by the camera tilt, so the vertical pan is scaled too.
const tx = width / 2 - x * zoom;
const ty = height / 2 - y * zoom;
const ty = height / 2 - y * zoom * CAMERA_TILT;
this.$world.css("transform", `translate(${tx}px, ${ty}px) scale(${zoom})`);
if (applyWrap) this._syncChunks();
if (this._selectedTile) this._positionHighlight();
@@ -70,7 +72,7 @@ export const inputMethods = {
}
if (dragged) {
this.camera.x -= (event.clientX - lastX) / this.camera.zoom;
this.camera.y -= (event.clientY - lastY) / this.camera.zoom;
this.camera.y -= (event.clientY - lastY) / (this.camera.zoom * CAMERA_TILT);
this._applyCamera(false);
}
lastX = event.clientX;
@@ -281,8 +283,9 @@ export const inputMethods = {
if (view.style.display === "none") continue;
const local = this._clickLocal(coords);
const cx = parseFloat(view.style.left);
// City views are placed on the foreshortened ground.
const cy = parseFloat(view.style.top);
if (Math.hypot(cx - local.x, cy - local.y) < HEX_W * 0.5) return { id };
if (Math.hypot(cx - local.x, cy - local.y * CAMERA_TILT) < HEX_W * 0.5) return { id };
}
return null;
},
@@ -300,7 +303,8 @@ export const inputMethods = {
const width = this.$viewport.width();
const height = this.$viewport.height();
const worldX = (screenX - (width / 2 - this.camera.x * this.camera.zoom)) / this.camera.zoom;
const worldY = (screenY - (height / 2 - this.camera.y * this.camera.zoom)) / this.camera.zoom;
const worldY = (screenY - (height / 2 - this.camera.y * this.camera.zoom * CAMERA_TILT)) /
(this.camera.zoom * CAMERA_TILT);
return this._worldToHex(worldX, worldY);
},
+7 -5
View File
@@ -5,7 +5,7 @@ import { mapToLocal, key } from "../../../shared/hex.js";
import { HexPathfinder } from "../../../shared/hex_pathfinder.js";
import { ROADS } from "../../../shared/data/roads.js";
import { CONTROLLED_SPEED_MULTIPLIER } from "../../../shared/game_state/constants.js";
import { RESYNC_HOURS, PREDICTION_GRACE_HOURS } from "./constants.js";
import { CAMERA_TILT, RESYNC_HOURS, PREDICTION_GRACE_HOURS } from "./constants.js";
import { hoursAlong, samePath, pathProgress, makeSegment } from "./utils.js";
// A pathfinding slice runs for this long before handing the thread back, so
@@ -101,23 +101,25 @@ export const motionMethods = {
// offset, so members of one stack never split across the wrap seam.
const wrapped = this._wrappedPosition(position);
const offset = motion.offset || { x: 0, y: 0 };
// Sprites stand on the tilted ground, so only the world Y is
// foreshortened; the stack offset stays a plain pixel spread.
view.style.left = `${wrapped.x + offset.x}px`;
view.style.top = `${wrapped.y + offset.y}px`;
view.style.top = `${wrapped.y * CAMERA_TILT + offset.y}px`;
}
for (const view of this._cityViews.values()) {
if (view._localX === undefined) continue;
const left = view._localX + this._wrapShift(view._localX);
view.style.left = `${left}px`;
view.style.top = `${view._localY}px`;
view.style.top = `${view._localY * CAMERA_TILT}px`;
if (view._label) {
view._label.style.left = `${left}px`;
view._label.style.top = `${view._localY}px`;
view._label.style.top = `${view._localY * CAMERA_TILT}px`;
}
}
for (const marker of this._battleViews.values()) {
if (marker._localX === undefined) continue;
marker.style.left = `${marker._localX + this._wrapShift(marker._localX)}px`;
marker.style.top = `${marker._localY}px`;
marker.style.top = `${marker._localY * CAMERA_TILT}px`;
}
},
+3 -2
View File
@@ -13,7 +13,7 @@ import { MapTopology, mapToLocal, key, parseKey, HEX_W, HEX_H, COL_STEP } from "
import { MapGenerator } from "../../../shared/map_generator.js";
import { TerrainStats } from "../../../shared/terrain_stats.js";
import { MAP_CONFIG } from "../../../shared/data.js";
import { CHUNK_SIZE, CHUNK_MARGIN, CHUNK_CACHE_LIMIT } from "./constants.js";
import { CHUNK_SIZE, CHUNK_MARGIN, CHUNK_CACHE_LIMIT, CAMERA_TILT } from "./constants.js";
import { terrainLayers, textureRepeat } from "./textures.js";
export const terrainMethods = {
@@ -289,7 +289,8 @@ export const terrainMethods = {
this._chunkView = { x: cameraX, y: cameraY, zoom };
const halfW = width / (2 * zoom);
const halfH = height / (2 * zoom);
// The tilt foreshortens the ground, so more world Y fits on screen.
const halfH = height / (2 * zoom * CAMERA_TILT);
const slackX = CHUNK_MARGIN * this._chunkSize * COL_STEP;
const slackY = CHUNK_MARGIN * this._chunkSize * HEX_H;
const left = cameraX - halfW - slackX;
+11 -4
View File
@@ -39,6 +39,7 @@ import {
vertexTextureWeights,
} from "./textures.js";
import { blurFog, fogLevel, isFogged } from "./fog.js";
import { CAMERA_TILT } from "./constants.js";
// Full terrain vertex layout, in floats: x, y, u, v, wx, wy, plus the base
// colour (grass, sand, atlas) and one weight per overlay. Fog uses a compact
@@ -71,8 +72,8 @@ const UNIT_MOUNTAIN_NORMAL = 7;
// drawn as dotted dashes instead of a continuous line.
const NATIONAL_BORDER = 3.5;
const REGION_BORDER = NATIONAL_BORDER / 2;
const REGION_FILL = [1, 1, 1, 0.06];
const REGION_BORDER_COLOUR = [1, 1, 1, 0.55];
const REGION_FILL = [1, 1, 1, 0.2];
const REGION_BORDER_COLOUR = [1, 1, 1, 0.9];
const REGION_DASHES = 6;
const VERTEX_SHADER = `
@@ -84,14 +85,17 @@ attribute vec4 aOverlay;
uniform vec2 uCamera;
uniform vec2 uViewport;
uniform float uZoom;
uniform float uTilt;
uniform float uShiftX;
varying vec2 vUV;
varying vec4 vColour;
varying vec2 vWorld;
varying vec4 vOverlay;
void main() {
// Orthographic camera looking north at uTilt over the ground: the north-south
// axis is foreshortened, the east-west axis is untouched.
vec2 world = vec2(aPos.x + uShiftX, aPos.y);
vec2 screen = (world - uCamera) * uZoom + uViewport * 0.5;
vec2 screen = vec2(world.x - uCamera.x, (world.y - uCamera.y) * uTilt) * uZoom + uViewport * 0.5;
vec2 clip = (screen / uViewport) * 2.0 - 1.0;
gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
vUV = aUV;
@@ -373,6 +377,7 @@ export class GLMapRenderer {
this.uCamera = gl.getUniformLocation(program, "uCamera");
this.uViewport = gl.getUniformLocation(program, "uViewport");
this.uZoom = gl.getUniformLocation(program, "uZoom");
this.uTilt = gl.getUniformLocation(program, "uTilt");
this.uShiftX = gl.getUniformLocation(program, "uShiftX");
this.uAtlas = gl.getUniformLocation(program, "uAtlas");
this.uGrass = gl.getUniformLocation(program, "uGrass");
@@ -1250,7 +1255,8 @@ export class GLMapRenderer {
const topo = view.topology;
if (!topo) return null;
const zoom = view.camera.zoom || 1;
const halfHeight = height / (2 * zoom);
// The tilt compresses the ground, so a taller slice of world Y is on screen.
const halfHeight = height / (2 * zoom * CAMERA_TILT);
const top = view.camera.y - halfHeight;
const bottom = view.camera.y + halfHeight;
let start = Math.floor(top / HEX_H) - 1 - topo.originY;
@@ -1358,6 +1364,7 @@ export class GLMapRenderer {
gl.uniform2f(this.uCamera, camera.x, camera.y);
gl.uniform2f(this.uViewport, width, height);
gl.uniform1f(this.uZoom, camera.zoom);
gl.uniform1f(this.uTilt, CAMERA_TILT);
gl.uniform1f(this.uTextureRepeat, textureRepeat(view._period || 0));
gl.uniform1f(this.uTime, now);
gl.enable(gl.BLEND);
+3 -2
View File
@@ -3,6 +3,7 @@ import { setupDom, teardownDom } from "./framework/dom.js";
import { MapView, inwardOffset } from "../client/js/map_view.js";
import { mapToLocal, COL_STEP, HEX_W, parseKey } from "../shared/hex.js";
import { CIVILISATIONS, PROTO_UNITS } from "../shared/data.js";
import { CAMERA_TILT } from "../client/js/map_view/constants.js";
import { defaultState, smallState, mapLayers, adjacentLand, seaTile } from "./framework/helpers.js";
export class MapViewTest extends TestCase {
@@ -138,7 +139,7 @@ export class MapViewTest extends TestCase {
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
const local = mapToLocal(capital.coords.x, capital.coords.y);
const screenX = 400 + (local.x - map.camera.x) * map.camera.zoom;
const screenY = 300 + (local.y - map.camera.y) * map.camera.zoom;
const screenY = 300 + (local.y - map.camera.y) * map.camera.zoom * CAMERA_TILT;
this.assertEqual(map.tileAtScreen(screenX, screenY), capital.coords);
} finally {
teardownDom(env);
@@ -491,7 +492,7 @@ export class MapViewTest extends TestCase {
map.setSelectedUnit(unit.id);
const local = mapToLocal(goal.x, goal.y);
const clientX = 400 + (local.x - map.camera.x) * map.camera.zoom;
const clientY = 300 + (local.y - map.camera.y) * map.camera.zoom;
const clientY = 300 + (local.y - map.camera.y) * map.camera.zoom * CAMERA_TILT;
const view = map._unitViews.get(target.id);
this.assertNotNull(view, "the target unit has a view");
// Real bubbling events, so the icon's own native handler is exercised: