diff --git a/client/css/style.css b/client/css/style.css index 7b807c0..26a4a45 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -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; } diff --git a/client/js/map_view.js b/client/js/map_view.js index 7a4667e..db97dea 100644 --- a/client/js/map_view.js +++ b/client/js/map_view.js @@ -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. diff --git a/client/js/map_view/constants.js b/client/js/map_view/constants.js index aa207f2..2bb0573 100644 --- a/client/js/map_view/constants.js +++ b/client/js/map_view/constants.js @@ -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. diff --git a/client/js/map_view/entities.js b/client/js/map_view/entities.js index 780c590..d889e5b 100644 --- a/client/js/map_view/entities.js +++ b/client/js/map_view/entities.js @@ -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 diff --git a/client/js/map_view/input.js b/client/js/map_view/input.js index de1372b..3b6a2cc 100644 --- a/client/js/map_view/input.js +++ b/client/js/map_view/input.js @@ -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); }, diff --git a/client/js/map_view/motion.js b/client/js/map_view/motion.js index b68fac7..1f47f3e 100644 --- a/client/js/map_view/motion.js +++ b/client/js/map_view/motion.js @@ -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`; } }, diff --git a/client/js/map_view/terrain.js b/client/js/map_view/terrain.js index 586f83c..05a9823 100644 --- a/client/js/map_view/terrain.js +++ b/client/js/map_view/terrain.js @@ -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; diff --git a/client/js/map_view/webgl.js b/client/js/map_view/webgl.js index e5c7019..9cb224b 100644 --- a/client/js/map_view/webgl.js +++ b/client/js/map_view/webgl.js @@ -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); diff --git a/tests/map_view_test.js b/tests/map_view_test.js index 11ed0dc..e9ea025 100644 --- a/tests/map_view_test.js +++ b/tests/map_view_test.js @@ -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: