2014-01-03 13:32:13 -05:00
CocoClass = require ' lib/CocoClass '
# 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
MAX_ZOOM = 8
MIN_ZOOM = 0.1
DEFAULT_ZOOM = 2.0
DEFAULT_TARGET = { x : 0 , y : 0 }
DEFAULT_TIME = 1000
# 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
bounds: null # list of two surface points defining the viewable rectangle in the world
# or null if there are no bounds
# what the camera is pointed at right now
target: DEFAULT_TARGET
zoom: DEFAULT_ZOOM
# properties for tracking going between targets
oldZoom: null
newZoom: null
oldTarget: null
newTarget: null
tweenProgress: 0.0
instant: false
# INIT
subscriptions:
' camera-zoom-in ' : ' onZoomIn '
' camera-zoom-out ' : ' onZoomOut '
' surface:mouse-scrolled ' : ' onMouseScrolled '
' level:restarted ' : ' onLevelRestarted '
# TODO: Fix tests to not use mainLayer
constructor: ( @ canvasWidth , @ canvasHeight , angle = Math . asin ( 0.75 ) , hFOV = d2r ( 30 ) ) ->
super ( )
@ calculateViewingAngle angle
@ calculateFieldOfView hFOV
@ calculateAxisConversionFactors ( )
@ updateViewports ( )
@ calculateMinZoom ( )
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
console . log " Vertical field of view problem: expected canvas not to be taller than it is wide with high field of view. "
@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 }
# TODO: do we even need separate screen coordinates?
# We would need some other properties for the actual ratio of screen size to canvas size.
canvasToScreen: (pos) ->
#{x: pos.x * @someCanvasToScreenXScaleFactor, y: pos.y * @someCanvasToScreenYScaleFactor}
{ x: pos . x , y: pos . y }
screenToCanvas: (pos) ->
#{x: pos.x / @someCanvasToScreenXScaleFactor, y: pos.y / @someCanvasToScreenYScaleFactor}
{ x: pos . x , y: pos . y }
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
onMouseScrolled: (e) ->
ratio = 1 + 0.05 * Math . sqrt ( Math . abs ( e . deltaY ) )
ratio = 1 / ratio if e . deltaY > 0
@ zoomTo @ target , @ zoom * ratio , 0
onLevelRestarted: ->
@ setBounds ( @ firstBounds )
# COMMANDS
setBounds: (worldBounds) ->
# receives an array of two world points. Normalize and apply them
@firstBounds = worldBounds unless @ firstBounds
@bounds = @ normalizeBounds ( worldBounds )
@ calculateMinZoom ( )
@ updateZoom true
@target = @ currentTarget unless @ target . name
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
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 }
calculateMinZoom: ->
# Zoom targets are always done in Surface coordinates.
if not @ bounds
@minZoom = 0.5
return
@minZoom = Math . max @ canvasWidth / @ bounds . width , @ canvasHeight / @ bounds . height
@zoom = Math . max ( @ minZoom , @ zoom ) if @ zoom
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
newTarget ? = { x : 0 , y : 0 }
newZoom = Math . min ( ( Math . max @ minZoom , newZoom ) , MAX_ZOOM )
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 ( @ )
. to ( { tweenProgress: 1.0 } , time , createjs . Ease . getPowInOut ( 3 ) )
. call @ onTweenEnd
else
@target = newTarget
@zoom = newZoom
@ updateZoom true
onTweenEnd: => @ finishTween ( )
finishTween: (abort=false) =>
createjs . Tween . removeTweens ( @ )
return unless @ newTarget
unless abort
@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-01-21 02:23:49 -05:00
return if ( not force ) and ( @ locked or ( not @ newTarget and not @ target ? . name ) )
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
@ updateViewports target
Backbone . Mediator . publish ' camera:zoom-updated ' , camera: @ , zoom: @ zoom , surfaceViewport: @ surfaceViewport
boundTarget: (pos, zoom) ->
# Given an {x, y} in Surface coordinates, return one that will keep our viewport on the Surface.
return pos unless @ bounds
marginX = ( @ canvasWidth / zoom / 2 )
marginY = ( @ canvasHeight / zoom / 2 )
x = Math . min ( Math . max ( marginX + @ bounds . x , pos . x ) , @ bounds . x + @ bounds . width - marginX )
y = Math . min ( Math . max ( marginY + @ bounds . y , pos . y ) , @ bounds . y + @ bounds . height - marginY )
{ 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
@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
lock: ->
@target = @ currentTarget
@locked = true
unlock: ->
@locked = false