playscii/camera.py

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(f"camera x={self.x}, y={self.y}, z={self.z}")