File size: 4,811 Bytes
34ea8bc
 
 
 
 
 
 
 
 
 
 
 
 
19ca109
 
 
 
 
 
 
 
34ea8bc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19ca109
34ea8bc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e7bc56d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
/* ============================================================
   Camera: pans and zooms the view over the map instead of squeezing
   the whole map into one screen. Pure view-space math — never reads or
   mutates game state, just converts between screen (CSS pixel, origin
   at the canvas's top-left) and world (map) coordinates.
   ============================================================ */

"use strict";

export const MIN_ZOOM = 0.6;
export const MAX_ZOOM = 2.5;
const PAN_SPEED = 900;   // world units/sec of keyboard pan at zoom 1

// How far out the wheel can zoom: the usual MIN_ZOOM, but never so tight that
// a big map can't be pulled fully into view. On a Small map this is just
// MIN_ZOOM; on a Gigantic one it drops low enough to fit the whole thing.
function minZoomFor(map, vw, vh) {
  if (!vw || !vh) return MIN_ZOOM;
  return Math.min(MIN_ZOOM, vw / map.width, vh / map.height);
}

export function createCamera(map) {
  return { x: map.width / 2, y: map.height / 2, zoom: 1 };
}

export function screenToWorld(camera, vw, vh, sx, sy) {
  return {
    x: camera.x + (sx - vw / 2) / camera.zoom,
    y: camera.y + (sy - vh / 2) / camera.zoom,
  };
}

export function worldToScreen(camera, vw, vh, wx, wy) {
  return {
    x: vw / 2 + (wx - camera.x) * camera.zoom,
    y: vh / 2 + (wy - camera.y) * camera.zoom,
  };
}

// Keeps the camera from panning past the map edge into empty space. Once
// the viewport is wider/taller than the map (zoomed out far enough, or a
// tiny window), that axis just centers instead of "clamping" to a
// backwards range.
export function clampCamera(camera, map, vw, vh) {
  const halfW = vw / (2 * camera.zoom);
  const halfH = vh / (2 * camera.zoom);
  camera.x = map.width <= 2 * halfW ? map.width / 2 : Math.min(Math.max(camera.x, halfW), map.width - halfW);
  camera.y = map.height <= 2 * halfH ? map.height / 2 : Math.min(Math.max(camera.y, halfH), map.height - halfH);
}

// Zooms by `factor` while keeping the world point under (sx, sy) fixed on
// screen, so scrolling the wheel feels like it's zooming toward the
// cursor instead of the map drifting out from under it.
export function zoomAt(camera, map, vw, vh, sx, sy, factor) {
  const before = screenToWorld(camera, vw, vh, sx, sy);
  camera.zoom = Math.min(MAX_ZOOM, Math.max(minZoomFor(map, vw, vh), camera.zoom * factor));
  const after = screenToWorld(camera, vw, vh, sx, sy);
  camera.x += before.x - after.x;
  camera.y += before.y - after.y;
  clampCamera(camera, map, vw, vh);
}

// dx/dy is a direction (not necessarily normalized) — e.g. from held
// keys — scaled by dt and the current zoom so panning covers the same
// screen distance per second regardless of zoom level.
export function panCamera(camera, map, vw, vh, dx, dy, dt) {
  if (dx === 0 && dy === 0) return;
  const len = Math.hypot(dx, dy);
  camera.x += (dx / len) * PAN_SPEED * dt / camera.zoom;
  camera.y += (dy / len) * PAN_SPEED * dt / camera.zoom;
  clampCamera(camera, map, vw, vh);
}

// Drag the camera by a raw screen-space delta (touch two-finger pan, or a
// drag-pan): move the view so the world stays glued to the fingers. dsx/dsy is
// the finger movement in CSS pixels; dividing by zoom converts it to world
// units, and the sign is inverted because dragging content right (positive dsx)
// must decrease camera.x to bring the left-of-view world into frame.
export function dragCamera(camera, map, vw, vh, dsx, dsy) {
  camera.x -= dsx / camera.zoom;
  camera.y -= dsy / camera.zoom;
  clampCamera(camera, map, vw, vh);
}

// One combined two-finger gesture step. `prev`/`cur` each hold the two touch
// points in screen space ({ ax, ay, bx, by }); this pinch-zooms by how their
// spread changed and pans by how their midpoint moved — with the zoom anchored
// on the current midpoint so the world under the pinch stays put. Pure view
// math (same clamps as the wheel), so a test can assert it without a browser.
export function pinchZoomPan(camera, map, vw, vh, prev, cur) {
  const prevMidX = (prev.ax + prev.bx) / 2, prevMidY = (prev.ay + prev.by) / 2;
  const curMidX = (cur.ax + cur.bx) / 2, curMidY = (cur.ay + cur.by) / 2;
  const prevDist = Math.hypot(prev.ax - prev.bx, prev.ay - prev.by) || 1;
  const curDist = Math.hypot(cur.ax - cur.bx, cur.ay - cur.by) || 1;

  // Zoom about the current midpoint (keep that world point fixed on screen).
  const before = screenToWorld(camera, vw, vh, curMidX, curMidY);
  camera.zoom = Math.min(MAX_ZOOM, Math.max(minZoomFor(map, vw, vh), camera.zoom * (curDist / prevDist)));
  const after = screenToWorld(camera, vw, vh, curMidX, curMidY);
  camera.x += before.x - after.x;
  camera.y += before.y - after.y;

  // ...then pan by the midpoint's own movement.
  dragCamera(camera, map, vw, vh, curMidX - prevMidX, curMidY - prevMidY);
}