2014-11-28 20:49:41 -05:00
CocoClass = require ' core/CocoClass '
2016-06-22 14:20:21 -04:00
GameUIState = require ' models/GameUIState '
2014-01-03 13:32:13 -05:00
# If I were the kind of math major who remembered his math, this would all be done with matrix transforms.
r2d = (radians) -> radians * 180 / Math . PI
d2r = (degrees) -> degrees / 180 * Math . PI
2014-03-16 16:23:01 -04:00
MAX_ZOOM = 8
MIN_ZOOM = 0.1
2014-01-03 13:32:13 -05:00
DEFAULT_ZOOM = 2.0
2014-06-30 22:16:26 -04:00
DEFAULT_TARGET = { x: 0 , y: 0 }
2014-01-03 13:32:13 -05:00
DEFAULT_TIME = 1000
2014-05-12 16:28:46 -04:00
STANDARD_ZOOM_WIDTH = 924
STANDARD_ZOOM_HEIGHT = 589
2014-01-03 13:32:13 -05:00
# You can't mutate any of the constructor parameters after construction.
# You can only call zoomTo to change the zoom target and zoom level.
module.exports = class Camera extends CocoClass
@PPM: 10 # pixels per meter
@MPP: 0.1 # meters per pixel; should match @PPM
2014-03-16 16:23:01 -04:00
bounds: null # list of two surface points defining the viewable rectangle in the world
2014-01-03 13:32:13 -05:00
# or null if there are no bounds
# what the camera is pointed at right now
target: DEFAULT_TARGET
zoom: DEFAULT_ZOOM
2014-05-16 18:33:49 -04:00
canvasScaleFactorX: 1
canvasScaleFactorY: 1
2014-01-03 13:32:13 -05:00
# properties for tracking going between targets
oldZoom: null
newZoom: null
oldTarget: null
newTarget: null
tweenProgress: 0.0
instant: false
# INIT
subscriptions:
2014-08-27 15:24:03 -04:00
' camera:zoom-in ' : ' onZoomIn '
' camera:zoom-out ' : ' onZoomOut '
' camera:zoom-to ' : ' onZoomTo '
2014-01-03 13:32:13 -05:00
' level:restarted ' : ' onLevelRestarted '
2016-06-22 14:20:21 -04:00
constructor: (@canvas, @options={}) ->
angle = Math . asin ( 0.75 )
hFOV = d2r ( 30 )
2014-01-03 13:32:13 -05:00
super ( )
2016-06-22 14:20:21 -04:00
@gameUIState = @ options . gameUIState or new GameUIState ( )
@ listenTo @ gameUIState , ' surface:stage-mouse-move ' , @ onMouseMove
@ listenTo @ gameUIState , ' surface:stage-mouse-down ' , @ onMouseDown
@ listenTo @ gameUIState , ' surface:stage-mouse-up ' , @ onMouseUp
@ listenTo @ gameUIState , ' surface:mouse-scrolled ' , @ onMouseScrolled
@handleEvents = @ options . handleEvents ? true
2014-05-16 18:33:49 -04:00
@canvasWidth = parseInt ( @ canvas . attr ( ' width ' ) , 10 )
@canvasHeight = parseInt ( @ canvas . attr ( ' height ' ) , 10 )
2014-05-12 16:28:46 -04:00
@offset = { x: 0 , y: 0 }
2014-01-03 13:32:13 -05:00
@ calculateViewingAngle angle
@ calculateFieldOfView hFOV
@ calculateAxisConversionFactors ( )
2014-05-12 16:28:46 -04:00
@ calculateMinMaxZoom ( )
2014-01-03 13:32:13 -05:00
@ updateViewports ( )
2014-05-12 16:28:46 -04:00
onResize: (newCanvasWidth, newCanvasHeight) ->
@canvasScaleFactorX = newCanvasWidth / @ canvasWidth
@canvasScaleFactorY = newCanvasHeight / @ canvasHeight
2014-09-26 14:07:01 -04:00
Backbone . Mediator . publish ' camera:zoom-updated ' , camera: @ , zoom: @ zoom , surfaceViewport: @ surfaceViewport
2014-01-03 13:32:13 -05:00
calculateViewingAngle: (angle) ->
# Operate on open interval between 0 - 90 degrees to make the math easier
epsilon = 0.000001 # Too small and numerical instability will get us.
@angle = Math . max ( Math . min ( Math . PI / 2 - epsilon , angle ) , epsilon )
if @ angle isnt angle and angle isnt 0 and angle isnt Math . PI / 2
console . log " Restricted given camera angle of #{ r2d ( angle ) } to #{ r2d ( @ angle ) } . "
calculateFieldOfView: (hFOV) ->
# http://en.wikipedia.org/wiki/Field_of_view_in_video_games
epsilon = 0.000001 # Too small and numerical instability will get us.
@hFOV = Math . max ( Math . min ( Math . PI - epsilon , hFOV ) , epsilon )
if @ hFOV isnt hFOV and hFOV isnt 0 and hFOV isnt Math . PI
console . log " Restricted given horizontal field of view to #{ r2d ( hFOV ) } to #{ r2d ( @ hFOV ) } . "
@vFOV = 2 * Math . atan ( Math . tan ( @ hFOV / 2 ) * @ canvasHeight / @ canvasWidth )
if @ vFOV > Math . PI
2014-06-30 22:16:26 -04:00
console . log ' Vertical field of view problem: expected canvas not to be taller than it is wide with high field of view. '
2014-01-03 13:32:13 -05:00
@vFOV = Math . PI - epsilon
calculateAxisConversionFactors: ->
@y2x = Math . sin @ angle # 1 unit along y is equivalent to y2x units along x
@z2x = Math . cos @ angle # 1 unit along z is equivalent to z2x units along x
@z2y = @ z2x / @ y2x # 1 unit along z is equivalent to z2y units along y
@x2y = 1 / @ y2x # 1 unit along x is equivalent to x2y units along y
@x2z = 1 / @ z2x # 1 unit along x is equivalent to x2z units along z
@y2z = 1 / @ z2y # 1 unit along y is equivalent to y2z units along z
# CONVERSIONS AND CALCULATIONS
worldToSurface: (pos) ->
x = pos . x * Camera . PPM
y = - pos . y * @ y2x * Camera . PPM
if pos . z
y -= @ z2y * @ y2x * pos . z * Camera . PPM
{ x: x , y: y }
surfaceToCanvas: (pos) ->
{ x: ( pos . x - @ surfaceViewport . x ) * @ zoom , y: ( pos . y - @ surfaceViewport . y ) * @ zoom }
canvasToScreen: (pos) ->
2014-05-12 16:28:46 -04:00
{ x: pos . x * @ canvasScaleFactorX , y: pos . y * @ canvasScaleFactorY }
2014-01-03 13:32:13 -05:00
screenToCanvas: (pos) ->
2014-05-12 16:28:46 -04:00
{ x: pos . x / @ canvasScaleFactorX , y: pos . y / @ canvasScaleFactorY }
2014-01-03 13:32:13 -05:00
canvasToSurface: (pos) ->
{ x: pos . x / @ zoom + @ surfaceViewport . x , y: pos . y / @ zoom + @ surfaceViewport . y }
surfaceToWorld: (pos) ->
{ x: pos . x * Camera . MPP , y: - pos . y * Camera . MPP * @ x2y , z: 0 }
canvasToWorld: (pos) -> @ surfaceToWorld @ canvasToSurface pos
worldToCanvas: (pos) -> @ surfaceToCanvas @ worldToSurface pos
worldToScreen: (pos) -> @ canvasToScreen @ worldToCanvas pos
surfaceToScreen: (pos) -> @ canvasToScreen @ surfaceToCanvas pos
screenToSurface: (pos) -> @ canvasToSurface @ screenToCanvas pos
screenToWorld: (pos) -> @ surfaceToWorld @ screenToSurface pos
cameraWorldPos: ->
# I tried to figure out the math for how much of @vFOV is below the midpoint (botFOV) and how much is above (topFOV), but I failed.
# So I'm just making something up. This would give botFOV 20deg, topFOV 10deg at @vFOV 30deg and @angle 45deg, or an even 15/15 at @angle 90deg.
botFOV = @ x2y * @ vFOV / ( @ y2x + @ x2y )
topFOV = @ y2x * @ vFOV / ( @ y2x + @ x2y )
botDist = @ worldViewport . height / 2 * Math . sin ( @ angle ) / Math . sin ( botFOV )
z = botDist * Math . sin ( @ angle + botFOV )
x: @ worldViewport . cx , y: @ worldViewport . cy - z * @ z2y , z: z
distanceTo: (pos) ->
# Get the physical distance in meters from the camera to the given world pos.
cpos = @ cameraWorldPos ( )
dx = pos . x - cpos . x
dy = pos . y - cpos . y
dz = ( pos . z or 0 ) - cpos . z
Math . sqrt dx * dx + dy * dy + dz * dz
distanceRatioTo: (pos) ->
# Get the ratio of the distance to the given world pos over the distance to the center of the camera view.
cpos = @ cameraWorldPos ( )
dy = @ worldViewport . cy - cpos . y
camDist = Math . sqrt ( dy * dy + cpos . z * cpos . z )
return @ distanceTo ( pos ) / camDist
# Old method for flying things below; could re-integrate this
## Because none of our maps are designed to get smaller with distance along the y-axis, we'll only use z, as if we were looking straight down, until we get high enough. Based on worldPos.z, we gradually shift over to the more-realistic scale. This is pretty hacky.
#ratioWithoutY = dz * dz / (cPos.z * cPos.z)
#zv = Math.min(Math.max(0, worldPos.z - 5), cPos.z - 5) / (cPos.z - 5)
#zv * ratioWithY + (1 - zv) * ratioWithoutY
# SUBSCRIPTIONS
onZoomIn: (e) -> @ zoomTo @ target , @ zoom * 1.15 , 300
onZoomOut: (e) -> @ zoomTo @ target , @ zoom / 1.15 , 300
2016-06-22 14:20:21 -04:00
onMouseDown: (e) ->
return if @ dragDisabled
@lastPos = { x: e . originalEvent . rawX , y: e . originalEvent . rawY }
@mousePressed = true
onMouseMove: (e) ->
return unless @ mousePressed and @ gameUIState . get ( ' canDragCamera ' )
return if @ dragDisabled
target = @ boundTarget ( @ target , @ zoom )
newPos =
x: target . x + ( @ lastPos . x - e . originalEvent . rawX ) / @ zoom
y: target . y + ( @ lastPos . y - e . originalEvent . rawY ) / @ zoom
@ zoomTo newPos , @ zoom , 0
@lastPos = { x: e . originalEvent . rawX , y: e . originalEvent . rawY }
Backbone . Mediator . publish ' camera:dragged ' , { }
onMouseUp: (e) ->
@mousePressed = false
2014-01-03 13:32:13 -05:00
onMouseScrolled: (e) ->
ratio = 1 + 0.05 * Math . sqrt ( Math . abs ( e . deltaY ) )
ratio = 1 / ratio if e . deltaY > 0
2014-02-15 16:45:16 -05:00
newZoom = @ zoom * ratio
2014-05-14 18:29:55 -04:00
if e . screenPos and not @ focusedOnSprite ( )
2014-02-15 16:45:16 -05:00
# zoom based on mouse position, adjusting the target so the point under the mouse stays the same
2014-05-14 18:29:55 -04:00
mousePoint = @ screenToSurface ( e . screenPos )
2014-02-15 16:45:16 -05:00
ratioPosX = ( mousePoint . x - @ surfaceViewport . x ) / @ surfaceViewport . width
ratioPosY = ( mousePoint . y - @ surfaceViewport . y ) / @ surfaceViewport . height
newWidth = @ canvasWidth / newZoom
newHeight = @ canvasHeight / newZoom
newTargetX = mousePoint . x - ( newWidth * ratioPosX ) + ( newWidth / 2 )
newTargetY = mousePoint . y - ( newHeight * ratioPosY ) + ( newHeight / 2 )
2014-05-12 16:28:46 -04:00
target = { x: newTargetX , y: newTargetY }
2014-02-15 16:45:16 -05:00
else
target = @ target
2014-04-24 17:23:15 -04:00
@ zoomTo target , newZoom , 0
2014-02-15 16:45:16 -05:00
2014-01-03 13:32:13 -05:00
onLevelRestarted: ->
2014-02-20 17:25:39 -05:00
@ setBounds ( @ firstBounds , false )
2014-01-03 13:32:13 -05:00
# COMMANDS
2014-02-20 17:25:39 -05:00
setBounds: (worldBounds, updateZoom=true) ->
2014-01-03 13:32:13 -05:00
# receives an array of two world points. Normalize and apply them
@firstBounds = worldBounds unless @ firstBounds
@bounds = @ normalizeBounds ( worldBounds )
2014-05-12 16:28:46 -04:00
@ calculateMinMaxZoom ( )
2014-02-20 17:25:39 -05:00
@ updateZoom true if updateZoom
2014-02-22 14:31:31 -05:00
@target = @ currentTarget unless @ focusedOnSprite ( )
2014-01-03 13:32:13 -05:00
normalizeBounds: (worldBounds) ->
return null unless worldBounds
top = Math . max ( worldBounds [ 0 ] . y , worldBounds [ 1 ] . y )
left = Math . min ( worldBounds [ 0 ] . x , worldBounds [ 1 ] . x )
bottom = Math . min ( worldBounds [ 0 ] . y , worldBounds [ 1 ] . y )
right = Math . max ( worldBounds [ 0 ] . x , worldBounds [ 1 ] . x )
bottom -= 1 if top is bottom
right += 1 if left is right
2014-06-30 22:16:26 -04:00
p1 = @ worldToSurface ( { x: left , y: top } )
p2 = @ worldToSurface ( { x: right , y: bottom } )
{ x: p1 . x , y: p1 . y , width: p2 . x - p1 . x , height: p2 . y - p1 . y }
2014-01-03 13:32:13 -05:00
2014-05-12 16:28:46 -04:00
calculateMinMaxZoom: ->
2014-01-03 13:32:13 -05:00
# Zoom targets are always done in Surface coordinates.
2014-05-12 16:28:46 -04:00
@maxZoom = MAX_ZOOM
return @minZoom = MIN_ZOOM unless @ bounds
2014-01-03 13:32:13 -05:00
@minZoom = Math . max @ canvasWidth / @ bounds . width , @ canvasHeight / @ bounds . height
2014-05-12 16:28:46 -04:00
if @ zoom
@zoom = Math . max @ minZoom , @ zoom
@zoom = Math . min @ maxZoom , @ zoom
2014-01-03 13:32:13 -05:00
zoomTo: (newTarget=null, newZoom=1.0, time=1500) ->
# Target is either just a {x, y} pos or a display object with {x, y} that might change; surface coordinates.
time = 0 if @ instant
2014-05-12 16:28:46 -04:00
newTarget ? = { x: 0 , y: 0 }
2014-02-12 15:41:41 -05:00
newTarget = ( @ newTarget or @ target ) if @ locked
2014-05-12 16:28:46 -04:00
newZoom = Math . max newZoom , @ minZoom
newZoom = Math . min newZoom , @ maxZoom
2014-03-01 09:28:15 -05:00
2014-02-22 14:31:31 -05:00
thangType = @ target ? . sprite ? . thangType
if thangType
2014-05-12 16:28:46 -04:00
@offset = _ . clone ( thangType . get ( ' positions ' ) ? . torso or { x: 0 , y: 0 } )
2014-02-22 14:31:31 -05:00
scale = thangType . get ( ' scale ' ) or 1
@ offset . x *= scale
@ offset . y *= scale
else
2014-05-12 16:28:46 -04:00
@offset = { x: 0 , y: 0 }
2014-03-01 09:28:15 -05:00
2014-01-03 13:32:13 -05:00
return if @ zoom is newZoom and newTarget is newTarget . x and newTarget . y is newTarget . y
@ finishTween ( true )
if time
@newTarget = newTarget
@oldTarget = @ boundTarget ( @ target , @ zoom )
@oldZoom = @ zoom
@newZoom = newZoom
@tweenProgress = 0.01
createjs . Tween . get ( @ )
2014-03-13 09:02:23 -04:00
. to ( { tweenProgress: 1.0 } , time , createjs . Ease . getPowOut ( 4 ) )
2014-02-12 15:41:41 -05:00
. call @ finishTween
2014-01-03 13:32:13 -05:00
else
@target = newTarget
@zoom = newZoom
@ updateZoom true
2014-03-01 09:28:15 -05:00
2014-02-22 14:31:31 -05:00
focusedOnSprite: ->
return @ target ? . name
2014-01-03 13:32:13 -05:00
finishTween: (abort=false) =>
createjs . Tween . removeTweens ( @ )
return unless @ newTarget
2014-02-22 14:31:31 -05:00
unless abort is true
2014-01-03 13:32:13 -05:00
@target = @ newTarget
@zoom = @ newZoom
@newZoom = @oldZoom = @newTarget = @newTarget = @tweenProgress = null
@ updateZoom true
updateZoom: (force=false) ->
# Update when we're focusing on a Thang, tweening, or forcing it, unless we're locked
2014-02-22 14:31:31 -05:00
return if ( not force ) and ( @ locked or ( not @ newTarget and not @ focusedOnSprite ( ) ) )
2014-01-03 13:32:13 -05:00
if @ newTarget
t = @ tweenProgress
@zoom = @ oldZoom + t * ( @ newZoom - @ oldZoom )
[ p1 , p2 ] = [ @ oldTarget , @ boundTarget ( @ newTarget , @ newZoom ) ]
target = @target = x: p1 . x + t * ( p2 . x - p1 . x ) , y: p1 . y + t * ( p2 . y - p1 . y )
else
target = @ boundTarget @ target , @ zoom
return if not force and _ . isEqual target , @ currentTarget
@currentTarget = target
2014-11-20 14:37:10 -05:00
viewportDifference = @ updateViewports target
if viewportDifference > 0.1 # Roughly 0.1 pixel difference in what we can see
2014-11-23 00:22:46 -05:00
Backbone . Mediator . publish ' camera:zoom-updated ' , camera: @ , zoom: @ zoom , surfaceViewport: @ surfaceViewport , minZoom: @ minZoom
2014-01-03 13:32:13 -05:00
boundTarget: (pos, zoom) ->
# Given an {x, y} in Surface coordinates, return one that will keep our viewport on the Surface.
return pos unless @ bounds
2014-06-23 13:36:36 -04:00
y = pos . y
if thang = pos . sprite ? . thang
y = @ worldToSurface ( x: thang . pos . x , y: thang . pos . y ) . y # ignore z
2014-01-03 13:32:13 -05:00
marginX = ( @ canvasWidth / zoom / 2 )
marginY = ( @ canvasHeight / zoom / 2 )
2014-02-22 14:31:31 -05:00
x = Math . min ( Math . max ( marginX + @ bounds . x , pos . x + @ offset . x ) , @ bounds . x + @ bounds . width - marginX )
2014-06-23 13:36:36 -04:00
y = Math . min ( Math . max ( marginY + @ bounds . y , y + @ offset . y ) , @ bounds . y + @ bounds . height - marginY )
2014-01-03 13:32:13 -05:00
{ x: x , y: y }
updateViewports: (target) ->
target ? = @ target
sv = width: @ canvasWidth / @ zoom , height: @ canvasHeight / @ zoom , cx: target . x , cy: target . y
sv.x = sv . cx - sv . width / 2
sv.y = sv . cy - sv . height / 2
2014-11-20 14:37:10 -05:00
if @ surfaceViewport
# Calculate how different this viewport is. (If it's basically not different, we can avoid visualizing the update.)
viewportDifference = Math . abs ( @ surfaceViewport . x - sv . x ) + 1.01 * Math . abs ( @ surfaceViewport . y - sv . y ) + 1.02 * Math . abs ( @ surfaceViewport . width - sv . width )
else
viewportDifference = 9001
2014-01-03 13:32:13 -05:00
@surfaceViewport = sv
wv = @ surfaceToWorld sv # get x and y
wv.width = sv . width * Camera . MPP
wv.height = sv . height * Camera . MPP * @ x2y
wv.cx = wv . x + wv . width / 2
wv.cy = wv . y + wv . height / 2
@worldViewport = wv
2014-11-20 14:37:10 -05:00
viewportDifference
2014-01-03 13:32:13 -05:00
lock: ->
@target = @ currentTarget
@locked = true
2014-06-30 22:16:26 -04:00
2014-01-03 13:32:13 -05:00
unlock: ->
@locked = false
2014-02-11 17:58:45 -05:00
destroy: ->
2014-02-12 15:41:41 -05:00
createjs . Tween . removeTweens @
2014-02-14 13:57:47 -05:00
super ( )
2014-03-01 15:43:37 -05:00
2014-08-27 15:24:03 -04:00
onZoomTo: (e) ->
@ zoomTo @ worldToSurface ( e . pos ) , @ zoom , e . duration