315 lines
12 KiB
Python
315 lines
12 KiB
Python
import math
|
|
|
|
import numpy as np
|
|
|
|
import vector
|
|
|
|
|
|
def clamp(val, lowest, highest):
|
|
return min(highest, max(lowest, val))
|
|
|
|
|
|
class Camera:
|
|
# good starting values
|
|
start_x, start_y = 0, 0
|
|
start_zoom = 2.5
|
|
x_tilt, y_tilt = 0, 0
|
|
# pan/zoom speed tuning
|
|
mouse_pan_rate = 10
|
|
pan_accel = 0.005
|
|
base_max_pan_speed = 0.8
|
|
pan_friction = 0.1
|
|
# min/max zoom % between which pan speed variation scales
|
|
pan_min_pct = 25.0
|
|
pan_max_pct = 200.0
|
|
# factor by which zoom level modifies pan speed
|
|
pan_zoom_increase_factor = 16
|
|
zoom_accel = 0.1
|
|
max_zoom_speed = 2.5
|
|
zoom_friction = 0.1
|
|
# kill velocity if below this
|
|
min_velocity = 0.05
|
|
# map extents
|
|
# starting values only, bounds are generated according to art size
|
|
min_x, max_x = -10, 50
|
|
min_y, max_y = -50, 10
|
|
use_bounds = True
|
|
min_zoom, max_zoom = 1, 1000
|
|
# matrices -> worldspace renderable vertex shader uniforms
|
|
fov = 90
|
|
near_z = 0.0001
|
|
far_z = 100000
|
|
|
|
def __init__(self, app):
|
|
self.app = app
|
|
self.reset()
|
|
self.max_pan_speed = self.base_max_pan_speed
|
|
|
|
def reset(self):
|
|
self.x, self.y = self.start_x, self.start_y
|
|
self.z = self.start_zoom
|
|
# store look vectors so world/screen space conversions can refer to it
|
|
self.look_x, self.look_y, self.look_z = None, None, None
|
|
self.vel_x, self.vel_y, self.vel_z = 0, 0, 0
|
|
self.mouse_panned, self.moved_this_frame = False, False
|
|
# GameObject to focus on
|
|
self.focus_object = None
|
|
self.calc_projection_matrix()
|
|
self.calc_view_matrix()
|
|
|
|
def calc_projection_matrix(self):
|
|
self.projection_matrix = self.get_perspective_matrix()
|
|
|
|
def calc_view_matrix(self):
|
|
eye = vector.Vec3(self.x, self.y, self.z)
|
|
up = vector.Vec3(0, 1, 0)
|
|
target = vector.Vec3(eye.x + self.x_tilt, eye.y + self.y_tilt, 0)
|
|
# view axes
|
|
forward = (target - eye).normalize()
|
|
side = forward.cross(up).normalize()
|
|
upward = side.cross(forward)
|
|
m = [
|
|
[side.x, upward.x, -forward.x, 0],
|
|
[side.y, upward.y, -forward.y, 0],
|
|
[side.z, upward.z, -forward.z, 0],
|
|
[-eye.dot(side), -eye.dot(upward), eye.dot(forward), 1],
|
|
]
|
|
self.view_matrix = np.array(m, dtype=np.float32)
|
|
self.look_x, self.look_y, self.look_z = side, upward, forward
|
|
|
|
def get_perspective_matrix(self):
|
|
zmul = (-2 * self.near_z * self.far_z) / (self.far_z - self.near_z)
|
|
ymul = 1 / math.tan(self.fov * math.pi / 360)
|
|
aspect = self.app.window_width / self.app.window_height
|
|
xmul = ymul / aspect
|
|
m = [[xmul, 0, 0, 0], [0, ymul, 0, 0], [0, 0, -1, -1], [0, 0, zmul, 0]]
|
|
return np.array(m, dtype=np.float32)
|
|
|
|
def get_ortho_matrix(self, width=None, height=None):
|
|
width, height = width or self.app.window_width, height or self.app.window_height
|
|
m = np.eye(4, 4, dtype=np.float32)
|
|
left, bottom = 0, 0
|
|
right, top = width, height
|
|
x = 2 / (right - left)
|
|
y = 2 / (top - bottom)
|
|
z = -2 / (self.far_z - self.near_z)
|
|
wx = -(right + left) / (right - left)
|
|
wy = -(top + bottom) / (top - bottom)
|
|
wz = -(self.far_z + self.near_z) / (self.far_z - self.near_z)
|
|
m = [[x, 0, 0, 0], [0, y, 0, 0], [0, 0, z, 0], [wx, wy, wz, 0]]
|
|
return np.array(m, dtype=np.float32)
|
|
|
|
def pan(self, dx, dy, keyboard=False):
|
|
# modify pan speed based on zoom according to a factor
|
|
m = (self.pan_zoom_increase_factor * self.z) / self.min_zoom
|
|
self.vel_x += dx * self.pan_accel * m
|
|
self.vel_y += dy * self.pan_accel * m
|
|
# for brevity, app passes in whether user appears to be keyboard editing
|
|
if keyboard:
|
|
self.app.keyboard_editing = True
|
|
|
|
def zoom(self, dz, keyboard=False, towards_cursor=False):
|
|
self.vel_z += dz * self.zoom_accel
|
|
# pan towards cursor while zooming?
|
|
if towards_cursor:
|
|
dx = self.app.cursor.x - self.x
|
|
dy = self.app.cursor.y - self.y
|
|
self.pan(dx, dy, keyboard)
|
|
if keyboard:
|
|
self.app.keyboard_editing = True
|
|
|
|
def get_current_zoom_pct(self):
|
|
"returns % of base (1:1) for current camera"
|
|
return (self.get_base_zoom() / self.z) * 100
|
|
|
|
def get_base_zoom(self):
|
|
"returns camera Z needed for 1:1 pixel zoom"
|
|
wh = self.app.window_height
|
|
ch = self.app.ui.active_art.charset.char_height
|
|
# TODO: understand why this produces correct result for 8x8 charsets
|
|
if ch == 8:
|
|
ch = 16
|
|
return wh / ch
|
|
|
|
def set_to_base_zoom(self):
|
|
self.z = self.get_base_zoom()
|
|
|
|
def zoom_proportional(self, direction):
|
|
"zooms in or out via increments of 1:1 pixel scales for active art"
|
|
if not self.app.ui.active_art:
|
|
return
|
|
self.app.ui.active_art.camera_zoomed_extents = False
|
|
base_zoom = self.get_base_zoom()
|
|
# build span of all 1:1 zoom increments
|
|
zooms = []
|
|
m = 1
|
|
while base_zoom / m > self.min_zoom:
|
|
zooms.append(base_zoom / m)
|
|
m *= 2
|
|
zooms.reverse()
|
|
m = 1
|
|
while base_zoom * m < self.max_zoom:
|
|
zooms.append(base_zoom * m)
|
|
m *= 2
|
|
# set zoom to nearest increment in direction we're heading
|
|
if direction > 0:
|
|
zooms.reverse()
|
|
for zoom in zooms:
|
|
if self.z > zoom:
|
|
self.z = zoom
|
|
break
|
|
elif direction < 0:
|
|
for zoom in zooms:
|
|
if self.z < zoom:
|
|
self.z = zoom
|
|
break
|
|
# kill all Z velocity for camera so we don't drift out of 1:1
|
|
self.vel_z = 0
|
|
|
|
def find_closest_zoom_extents(self):
|
|
def corners_on_screen():
|
|
art = self.app.ui.active_art
|
|
z = art.layers_z[-1]
|
|
x1, y1 = art.renderables[0].x, art.renderables[0].y
|
|
left, top = vector.world_to_screen_normalized(self.app, x1, y1, z)
|
|
x2 = x1 + art.width * art.quad_width
|
|
y2 = y1 - art.height * art.quad_height
|
|
right, bot = vector.world_to_screen_normalized(self.app, x2, y2, z)
|
|
# print('(%.3f, %.3f) -> (%.3f, %.3f)' % (left, top, right, bot))
|
|
# add 1 tile of UI chars to top and bottom margins
|
|
top_margin = 1 - self.app.ui.menu_bar.art.quad_height
|
|
bot_margin = -1 + self.app.ui.status_bar.art.quad_height
|
|
return left >= -1 and top <= top_margin and right <= 1 and bot >= bot_margin
|
|
|
|
# zoom out from minimum until all corners are visible
|
|
self.z = self.min_zoom
|
|
# recalc view matrix each move so projection stays correct
|
|
self.calc_view_matrix()
|
|
tries = 0
|
|
while not corners_on_screen() and tries < 30:
|
|
self.zoom_proportional(-1)
|
|
self.calc_view_matrix()
|
|
tries += 1
|
|
|
|
def toggle_zoom_extents(self, override=None):
|
|
art = self.app.ui.active_art
|
|
if override is not None:
|
|
art.camera_zoomed_extents = not override
|
|
if art.camera_zoomed_extents:
|
|
# restore cached position
|
|
self.x, self.y, self.z = (
|
|
art.non_extents_camera_x,
|
|
art.non_extents_camera_y,
|
|
art.non_extents_camera_z,
|
|
)
|
|
else:
|
|
(
|
|
art.non_extents_camera_x,
|
|
art.non_extents_camera_y,
|
|
art.non_extents_camera_z,
|
|
) = self.x, self.y, self.z
|
|
# center camera on art
|
|
self.x = (art.width * art.quad_width) / 2
|
|
self.y = -(art.height * art.quad_height) / 2
|
|
self.find_closest_zoom_extents()
|
|
# kill all camera velocity when snapping
|
|
self.vel_x, self.vel_y, self.vel_z = 0, 0, 0
|
|
art.camera_zoomed_extents = not art.camera_zoomed_extents
|
|
|
|
def window_resized(self):
|
|
self.calc_projection_matrix()
|
|
|
|
def set_zoom(self, z):
|
|
# TODO: set lerp target, clear if keyboard etc call zoom()
|
|
self.z = z
|
|
|
|
def set_loc(self, x, y, z):
|
|
self.x, self.y, self.z = x, y, (z or self.z) # z optional
|
|
|
|
def set_loc_from_obj(self, game_object):
|
|
self.set_loc(game_object.x, game_object.y, game_object.z)
|
|
|
|
def set_for_art(self, art):
|
|
# set limits
|
|
self.max_x = art.width * art.quad_width
|
|
self.min_y = -art.height * art.quad_height
|
|
# use saved pan/zoom
|
|
self.set_loc(art.camera_x, art.camera_y, art.camera_z)
|
|
|
|
def mouse_pan(self, dx, dy):
|
|
"pan view based on mouse delta"
|
|
if dx == 0 and dy == 0:
|
|
return
|
|
m = ((1 * self.pan_zoom_increase_factor) * self.z) / self.min_zoom
|
|
m /= self.max_zoom
|
|
self.x -= dx / self.mouse_pan_rate * m
|
|
self.y += dy / self.mouse_pan_rate * m
|
|
self.vel_x = self.vel_y = 0
|
|
self.mouse_panned = True
|
|
|
|
def update(self):
|
|
# zoom-proportional pan scale is based on art
|
|
if self.app.ui.active_art:
|
|
speed_scale = clamp(
|
|
self.get_current_zoom_pct(), self.pan_min_pct, self.pan_max_pct
|
|
)
|
|
self.max_pan_speed = self.base_max_pan_speed / (speed_scale / 100)
|
|
else:
|
|
self.max_pan_speed = self.base_max_pan_speed
|
|
# remember last position to see if it changed
|
|
self.last_x, self.last_y, self.last_z = self.x, self.y, self.z
|
|
# if focus object is set, use it for X and Y transforms
|
|
if self.focus_object:
|
|
# track towards target
|
|
# TODO: revisit this for better feel later
|
|
dx, dy = self.focus_object.x - self.x, self.focus_object.y - self.y
|
|
l = math.sqrt(dx**2 + dy**2)
|
|
if l != 0 and l > 0.1:
|
|
il = 1 / l
|
|
dx *= il
|
|
dy *= il
|
|
self.x += dx * self.pan_friction
|
|
self.y += dy * self.pan_friction
|
|
else:
|
|
# clamp velocity
|
|
self.vel_x = clamp(self.vel_x, -self.max_pan_speed, self.max_pan_speed)
|
|
self.vel_y = clamp(self.vel_y, -self.max_pan_speed, self.max_pan_speed)
|
|
# apply friction
|
|
self.vel_x *= 1 - self.pan_friction
|
|
self.vel_y *= 1 - self.pan_friction
|
|
if abs(self.vel_x) < self.min_velocity:
|
|
self.vel_x = 0
|
|
if abs(self.vel_y) < self.min_velocity:
|
|
self.vel_y = 0
|
|
# if camera moves, we're not in zoom-extents state anymore
|
|
if self.app.ui.active_art and (self.vel_x or self.vel_y):
|
|
self.app.ui.active_art.camera_zoomed_extents = False
|
|
# move
|
|
self.x += self.vel_x
|
|
self.y += self.vel_y
|
|
# process Z separately
|
|
self.vel_z = clamp(self.vel_z, -self.max_zoom_speed, self.max_zoom_speed)
|
|
self.vel_z *= 1 - self.zoom_friction
|
|
if abs(self.vel_z) < self.min_velocity:
|
|
self.vel_z = 0
|
|
# as bove, if zooming turn off zoom-extents state
|
|
if self.vel_z and self.app.ui.active_art:
|
|
self.app.ui.active_art.camera_zoomed_extents = False
|
|
self.z += self.vel_z
|
|
# keep within bounds
|
|
if self.use_bounds:
|
|
self.x = clamp(self.x, self.min_x, self.max_x)
|
|
self.y = clamp(self.y, self.min_y, self.max_y)
|
|
self.z = clamp(self.z, self.min_zoom, self.max_zoom)
|
|
# set view matrix from xyz
|
|
self.calc_view_matrix()
|
|
self.moved_this_frame = (
|
|
self.mouse_panned
|
|
or self.x != self.last_x
|
|
or self.y != self.last_y
|
|
or self.z != self.last_z
|
|
)
|
|
self.mouse_panned = False
|
|
|
|
def log_loc(self):
|
|
self.app.log("camera x={}, y={}, z={}".format(self.x, self.y, self.z))
|