File size: 11,671 Bytes
bde0962
 
0596153
3752d30
bde0962
 
 
 
 
 
 
 
 
 
 
 
 
 
0596153
 
bde0962
2e13c62
 
bde0962
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0596153
 
 
 
 
 
 
 
bde0962
 
 
0596153
 
bde0962
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3752d30
bde0962
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0596153
 
bde0962
 
 
 
 
 
 
 
 
 
 
 
 
0596153
bde0962
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3752d30
bde0962
 
 
 
 
 
 
 
 
 
 
 
3752d30
bde0962
 
 
 
 
 
 
3752d30
bde0962
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0596153
bde0962
0596153
 
bde0962
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4408421
 
bde0962
4408421
 
bde0962
 
 
 
 
 
 
 
3752d30
bde0962
 
 
 
 
 
 
 
 
3752d30
bde0962
 
 
 
 
0596153
bde0962
 
3752d30
bde0962
3752d30
 
bde0962
 
 
 
 
 
 
 
 
 
3752d30
bde0962
 
 
 
 
0596153
bde0962
 
0596153
 
bde0962
 
 
 
 
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
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
import { Container, Ticker } from 'pixi.js'
import type { CameraState } from 'shared/board.ts'
import { CELL_SIZE } from 'shared/board.ts'
import { debug } from '../debug.ts'

const ZOOM_MIN = 0.1
const ZOOM_MAX = 5
const ZOOM_SPEED = 0.002
const CLICK_THRESHOLD = 5
const MOMENTUM_DECAY = 4
const MOMENTUM_MIN = 0.5

export class CameraController {
  container: Container
  state: CameraState
  onChange: (() => void) | null = null
  onCellClick: ((gx: number, gy: number) => void) | null = null
  onCellHover: ((gx: number | null, gy: number | null) => void) | null = null
  offsetX = 0
  offsetY = 0

  vw = 0
  vh = 0
  private dragging = false
  private dragStartX = 0
  private dragStartY = 0
  private camStartX = 0
  private camStartY = 0
  private pointers = new Map<number, { x: number; y: number }>()
  private pinchDist = 0
  private pinchZoomStart = 1
  private canvas: HTMLCanvasElement | null = null
  private moved = false
  private lastMoveX = 0
  private lastMoveY = 0
  private velocityX = 0
  private velocityY = 0
  private momentumActive = false
  private currentHoverGx: number | null = null
  private currentHoverGy: number | null = null

  constructor(container: Container, vw: number, vh: number) {
    this.container = container
    this.vw = vw
    this.vh = vh
    this.state = { x: 0, y: 0, zoom: 1 }
  }

  resize(vw: number, vh: number): void {
    this.vw = vw
    this.vh = vh
    this.apply()
  }

  screenToWorld(sx: number, sy: number): { x: number; y: number } {
    return {
      x: (sx - (this.vw / 2 + this.offsetX)) / this.state.zoom + this.state.x,
      y: (sy - (this.vh / 2 + this.offsetY)) / this.state.zoom + this.state.y,
    }
  }

  getVisibleWorldBounds(): { minX: number; minY: number; maxX: number; maxY: number } {
    const topLeft = this.screenToWorld(0, 0)
    const bottomRight = this.screenToWorld(this.vw, this.vh)
    return {
      minX: Math.min(topLeft.x, bottomRight.x),
      minY: Math.min(topLeft.y, bottomRight.y),
      maxX: Math.max(topLeft.x, bottomRight.x),
      maxY: Math.max(topLeft.y, bottomRight.y),
    }
  }

  setInitialZoom(boardSize: number = 15, fitRatio = 0.85): void {
    const gridW = boardSize * CELL_SIZE
    const gridH = boardSize * CELL_SIZE
    const zoom = Math.min(this.vw / gridW, this.vh / gridH) * fitRatio
    this.state.zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, zoom))
    this.state.x = boardSize / 2 * CELL_SIZE
    this.state.y = boardSize / 2 * CELL_SIZE
    debug('CameraController', 'setInitialZoom', { boardSize, fitRatio, zoom: this.state.zoom, vw: this.vw, vh: this.vh })
    this.apply()
    this.triggerChange()
  }

  ensureCellsInView(cells: [number, number][], marginFraction = 0.15): void {
    if (cells.length === 0) return

    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
    for (const [gx, gy] of cells) {
      const cx = gx * CELL_SIZE
      const cy = gy * CELL_SIZE
      if (cx < minX) minX = cx
      if (cy < minY) minY = cy
      if (cx > maxX) maxX = cx
      if (cy > maxY) maxY = cy
    }
    maxX += CELL_SIZE
    maxY += CELL_SIZE

    const boardCx = (minX + maxX) / 2
    const boardCy = (minY + maxY) / 2

    const visible = this.getVisibleWorldBounds()
    const visCx = (visible.minX + visible.maxX) / 2
    const visCy = (visible.minY + visible.maxY) / 2
    const visW = visible.maxX - visible.minX
    const visH = visible.maxY - visible.minY

    let changed = false

    if (Math.abs(boardCx - visCx) > visW * 0.5 || Math.abs(boardCy - visCy) > visH * 0.5) {
      this.state.x = boardCx
      this.state.y = boardCy
      changed = true
    }

    const marginX = visW * marginFraction
    const marginY = visH * marginFraction

    if (minX < visible.minX + marginX || minY < visible.minY + marginY ||
        maxX > visible.maxX - marginX || maxY > visible.maxY - marginY) {
      const boardW = maxX - minX
      const boardH = maxY - minY
      const occupyFrac = 0.4
      const zoomX = occupyFrac * this.vw / boardW
      const zoomY = occupyFrac * this.vh / boardH
      const newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, Math.min(zoomX, zoomY)))

      if (newZoom < this.state.zoom) {
        this.state.zoom = newZoom
        this.state.x = boardCx
        this.state.y = boardCy
        changed = true
      }
    }

    if (changed) {
      this.apply()
      this.triggerChange()
    }
  }

  enable(canvas: HTMLCanvasElement): void {
    this.canvas = canvas
    canvas.addEventListener('pointerdown', this.onPointerDown)
    canvas.addEventListener('pointermove', this.onPointerMove)
    canvas.addEventListener('pointerup', this.onPointerUp)
    canvas.addEventListener('pointercancel', this.onPointerUp)
    canvas.addEventListener('pointerleave', this.onPointerLeave)
    canvas.addEventListener('wheel', this.onWheel, { passive: false })
  }

  disable(): void {
    this.stopMomentum()
    if (!this.canvas) return
    this.canvas.removeEventListener('pointerdown', this.onPointerDown)
    this.canvas.removeEventListener('pointermove', this.onPointerMove)
    this.canvas.removeEventListener('pointerup', this.onPointerUp)
    this.canvas.removeEventListener('pointercancel', this.onPointerUp)
    this.canvas.removeEventListener('pointerleave', this.onPointerLeave)
    this.canvas.removeEventListener('wheel', this.onWheel)
    this.canvas = null
  }

  apply(): void {
    this.container.position.set(
      (this.vw / 2 + this.offsetX) - this.state.x * this.state.zoom,
      (this.vh / 2 + this.offsetY) - this.state.y * this.state.zoom,
    )
    this.container.scale.set(this.state.zoom)
  }

  triggerChange(): void {
    this.onChange?.()
  }

  private updateHover(e: PointerEvent): void {
    if (!this.canvas) return
    const rect = this.canvas.getBoundingClientRect()
    const sx = e.clientX - rect.left
    const sy = e.clientY - rect.top
    const world = this.screenToWorld(sx, sy)
    const gx = Math.floor(world.x / CELL_SIZE)
    const gy = Math.floor(world.y / CELL_SIZE)
    if (gx !== this.currentHoverGx || gy !== this.currentHoverGy) {
      this.currentHoverGx = gx
      this.currentHoverGy = gy
      this.onCellHover?.(gx, gy)
    }
  }

  private clearHover(): void {
    if (this.currentHoverGx !== null) {
      this.currentHoverGx = null
      this.currentHoverGy = null
      this.onCellHover?.(null, null)
    }
  }

  private startMomentum(vx: number, vy: number): void {
    if (Math.abs(vx) < MOMENTUM_MIN && Math.abs(vy) < MOMENTUM_MIN) return
    this.momentumActive = true
    this.velocityX = vx
    this.velocityY = vy

    const ticker = Ticker.shared
    const cb = () => {
      if (!this.momentumActive) return
      const dt = ticker.deltaTime / 60
      this.state.x += this.velocityX * dt
      this.state.y += this.velocityY * dt
      this.velocityX *= 1 - MOMENTUM_DECAY * dt
      this.velocityY *= 1 - MOMENTUM_DECAY * dt
      if (Math.abs(this.velocityX) < MOMENTUM_MIN && Math.abs(this.velocityY) < MOMENTUM_MIN) {
        this.momentumActive = false
        ticker.remove(cb)
      }
      this.apply()
      this.triggerChange()
    }
    ticker.add(cb)
  }

  private stopMomentum(): void {
    this.momentumActive = false
    this.velocityX = 0
    this.velocityY = 0
  }

  private onPointerDown = (e: PointerEvent): void => {
    debug('CameraController', 'pointerDown', { id: e.pointerId, button: e.button, clientX: e.clientX.toFixed(0), clientY: e.clientY.toFixed(0), pointerCount: this.pointers.size + 1 })
    this.stopMomentum()
    this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
    this.moved = false

    if (this.pointers.size === 1 && e.button === 1) {
      this.dragging = true
      this.dragStartX = e.clientX
      this.dragStartY = e.clientY
      this.camStartX = this.state.x
      this.camStartY = this.state.y
      this.lastMoveX = e.clientX
      this.lastMoveY = e.clientY
      debug('CameraController', 'drag start')
    }

    if (this.pointers.size === 2) {
      this.dragging = false
      const p = Array.from(this.pointers.values())
      this.pinchDist = Math.hypot(p[1].x - p[0].x, p[1].y - p[0].y)
      this.pinchZoomStart = this.state.zoom
      debug('CameraController', 'pinch start', { pinchDist: this.pinchDist.toFixed(1), zoomStart: this.pinchZoomStart.toFixed(3) })
    }
  }

  private onPointerMove = (e: PointerEvent): void => {
    this.updateHover(e)
    this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })

    if (this.pointers.size === 2) {
      const p = Array.from(this.pointers.values())
      const dist = Math.hypot(p[1].x - p[0].x, p[1].y - p[0].y)
      const factor = dist / this.pinchDist

      const mx = (p[0].x + p[1].x) / 2
      const my = (p[0].y + p[1].y) / 2
      const rect = this.canvas!.getBoundingClientRect()
      const sx = mx - rect.left
      const sy = my - rect.top

      const world = this.screenToWorld(sx, sy)
      this.state.zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, this.pinchZoomStart * factor))
      this.state.x = world.x - (sx - (this.vw / 2 + this.offsetX)) / this.state.zoom
      this.state.y = world.y - (sy - (this.vh / 2 + this.offsetY)) / this.state.zoom

      this.apply()
      this.triggerChange()
      return
    }

    if (!this.dragging) return

    if (Math.abs(e.clientX - this.dragStartX) > CLICK_THRESHOLD ||
        Math.abs(e.clientY - this.dragStartY) > CLICK_THRESHOLD) {
      this.moved = true
    }

    const dx = e.clientX - this.dragStartX
    const dy = e.clientY - this.dragStartY
    this.state.x = this.camStartX - dx / this.state.zoom
    this.state.y = this.camStartY - dy / this.state.zoom

    this.velocityX = (e.clientX - this.lastMoveX) / this.state.zoom
    this.velocityY = (e.clientY - this.lastMoveY) / this.state.zoom
    this.lastMoveX = e.clientX
    this.lastMoveY = e.clientY

    this.apply()
    this.triggerChange()
  }

  private onPointerUp = (e: PointerEvent): void => {
    debug('CameraController', 'pointerUp', { id: e.pointerId, button: e.button, moved: this.moved, pointerCount: this.pointers.size })
    this.pointers.delete(e.pointerId)

    if (e.button === 1) {
      this.dragging = false
    }

    if (this.pointers.size === 0) {
      this.clearHover()
      if (this.moved) {
        debug('CameraController', 'start momentum')
        this.startMomentum(this.velocityX, this.velocityY)
      } else if (this.onCellClick && this.canvas && e.button !== 1) {
        const rect = this.canvas.getBoundingClientRect()
        const sx = e.clientX - rect.left
        const sy = e.clientY - rect.top
        const world = this.screenToWorld(sx, sy)
        const gx = Math.floor(world.x / CELL_SIZE)
        const gy = Math.floor(world.y / CELL_SIZE)
        debug('CameraController', 'cell click detected', { gx, gy })
        this.onCellClick(gx, gy)
      } else {
        debug('CameraController', 'no action on pointerUp')
      }
    }
  }

  private onPointerLeave = (): void => {
    this.clearHover()
  }

  private onWheel = (e: WheelEvent): void => {
    e.preventDefault()
    debug('CameraController', 'wheel', { deltaY: e.deltaY.toFixed(0), zoomBefore: this.state.zoom.toFixed(3) })

    const rect = this.canvas!.getBoundingClientRect()
    const sx = e.clientX - rect.left
    const sy = e.clientY - rect.top

    const world = this.screenToWorld(sx, sy)
    this.state.zoom *= 1 - e.deltaY * ZOOM_SPEED
    this.state.zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, this.state.zoom))
    this.state.x = world.x - (sx - (this.vw / 2 + this.offsetX)) / this.state.zoom
    this.state.y = world.y - (sy - (this.vh / 2 + this.offsetY)) / this.state.zoom

    this.apply()
    this.triggerChange()
  }
}