playscii/cursor.py

409 lines
15 KiB
Python

import ctypes
import math
import numpy as np
from OpenGL import GL
import vector
from edit_command import EditCommand
from renderable_sprite import UISpriteRenderable
"""
reference diagram:
0 0.2 0.8 1.0
A--------B *--------*
| | | |
0.1 | D-----C *-----* |
| | | |
| | | |
0.2 F--E *--*
etc
"""
OUTSIDE_EDGE_SIZE = 0.2
THICKNESS = 0.1
corner_verts = [
0,
0, # A/0
OUTSIDE_EDGE_SIZE,
0, # B/1
OUTSIDE_EDGE_SIZE,
-THICKNESS, # C/2
THICKNESS,
-THICKNESS, # D/3
THICKNESS,
-OUTSIDE_EDGE_SIZE, # E/4
0,
-OUTSIDE_EDGE_SIZE, # F/5
]
# vert indices for the above
corner_elems = [0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 5, 4]
# X/Y flip transforms to make all 4 corners
# (top left, top right, bottom left, bottom right)
corner_transforms = [(1, 1), (-1, 1), (1, -1), (-1, -1)]
# offsets to translate the 4 corners by
corner_offsets = [(0, 0), (1, 0), (0, -1), (1, -1)]
BASE_COLOR = (0.8, 0.8, 0.8, 1)
# why do we use the weird transforms and offsets?
# because a static vertex list wouldn't be able to adjust to different
# character set aspect ratios.
class Cursor:
vert_shader_source = "cursor_v.glsl"
frag_shader_source = "cursor_f.glsl"
alpha = 1
icon_scale_factor = 4
logg = False
def __init__(self, app):
self.app = app
self.x, self.y, self.z = 0, 0, 0
self.last_x, self.last_y = 0, 0
self.scale_x, self.scale_y, self.scale_z = 1, 1, 1
# list of EditCommandTiles for preview
self.preview_edits = []
self.current_command = None
# offsets to render the 4 corners at
self.mouse_x, self.mouse_y = 0, 0
self.moved = False
self.color = np.array(BASE_COLOR, dtype=np.float32)
# GL objects
if self.app.use_vao:
self.vao = GL.glGenVertexArrays(1)
GL.glBindVertexArray(self.vao)
self.vert_buffer, self.elem_buffer = GL.glGenBuffers(2)
self.vert_array = np.array(corner_verts, dtype=np.float32)
self.elem_array = np.array(corner_elems, dtype=np.uint32)
self.vert_count = int(len(self.elem_array))
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer)
GL.glBufferData(
GL.GL_ARRAY_BUFFER,
self.vert_array.nbytes,
self.vert_array,
GL.GL_STATIC_DRAW,
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_buffer)
GL.glBufferData(
GL.GL_ELEMENT_ARRAY_BUFFER,
self.elem_array.nbytes,
self.elem_array,
GL.GL_STATIC_DRAW,
)
# shader, attributes
self.shader = self.app.sl.new_shader(
self.vert_shader_source, self.frag_shader_source
)
# vert positions
self.pos_attrib = self.shader.get_attrib_location("vertPosition")
GL.glEnableVertexAttribArray(self.pos_attrib)
offset = ctypes.c_void_p(0)
GL.glVertexAttribPointer(
self.pos_attrib, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, offset
)
# uniforms
self.proj_matrix_uniform = self.shader.get_uniform_location("projection")
self.view_matrix_uniform = self.shader.get_uniform_location("view")
self.position_uniform = self.shader.get_uniform_location("objectPosition")
self.scale_uniform = self.shader.get_uniform_location("objectScale")
self.color_uniform = self.shader.get_uniform_location("baseColor")
self.quad_size_uniform = self.shader.get_uniform_location("quadSize")
self.xform_uniform = self.shader.get_uniform_location("vertTransform")
self.offset_uniform = self.shader.get_uniform_location("vertOffset")
self.alpha_uniform = self.shader.get_uniform_location("baseAlpha")
# finish
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
if self.app.use_vao:
GL.glBindVertexArray(0)
# init tool sprite, tool will provide texture when rendered
self.tool_sprite = UISpriteRenderable(self.app)
def clamp_to_active_art(self):
self.x = max(0, min(self.x, self.app.ui.active_art.width - 1))
self.y = min(0, max(self.y, -self.app.ui.active_art.height + 1))
def keyboard_move(self, delta_x, delta_y):
if not self.app.ui.active_art:
return
self.x += delta_x
self.y += delta_y
self.clamp_to_active_art()
self.moved = True
self.app.keyboard_editing = True
if self.logg:
self.app.log(
"Cursor: {},{},{} scale {:.2f},{:.2f}".format(
self.x, self.y, self.z, self.scale_x, self.scale_y
)
)
def set_scale(self, new_scale):
self.scale_x = self.scale_y = new_scale
def get_tile(self):
# adjust for brush size
size = self.app.ui.selected_tool.brush_size
if size:
size_offset = math.ceil(size / 2) - 1
return int(self.x + size_offset), int(-self.y + size_offset)
else:
return int(self.x), int(-self.y)
def center_in_art(self):
art = self.app.ui.active_art
if not art:
return
self.x = round(art.width / 2) * art.quad_width
self.y = round(-art.height / 2) * art.quad_height
self.moved = True
# !!TODO!! finish this, work in progress
def get_tiles_under_drag(self):
"""
returns list of tuple coordinates of all tiles under cursor's current
position AND tiles it's moved over since last update
"""
# TODO: get vector of last to current position, for each tile under
# current brush, do line trace along grid towards last point
# TODO: this works in two out of four diagonals,
# swap current and last positions to determine delta?
if self.last_x <= self.x:
x0, y0 = self.last_x, -self.last_y
x1, y1 = self.x, -self.y
else:
x0, y0 = self.x, -self.y
x1, y1 = self.last_x, -self.last_y
tiles = vector.get_tiles_along_line(x0, y0, x1, y1)
print("drag from {},{} to {},{}:".format(x0, y0, x1, y1))
print(tiles)
return tiles
def get_tiles_under_brush(self):
"""
returns list of tuple coordinates of all tiles under the cursor @ its
current brush size
"""
size = self.app.ui.selected_tool.brush_size
tiles = []
x_start, y_start = int(self.x), int(-self.y)
for y in range(y_start, y_start + size):
for x in range(x_start, x_start + size):
tiles.append((x, y))
return tiles
def undo_preview_edits(self):
for edit in self.preview_edits:
edit.undo()
def update_cursor_preview(self):
# rebuild list of cursor preview commands
if self.app.ui.selected_tool.show_preview:
self.preview_edits = self.app.ui.selected_tool.get_paint_commands()
for edit in self.preview_edits:
edit.apply()
else:
self.preview_edits = []
def start_paint(self):
if (
self.app.ui.console.visible
or self.app.ui.popup in self.app.ui.hovered_elements
):
return
if self.app.ui.selected_tool is self.app.ui.grab_tool:
self.app.ui.grab_tool.grab()
return
# start a new command group, commit and clear any preview edits
self.current_command = EditCommand(self.app.ui.active_art)
self.current_command.add_command_tiles(self.preview_edits)
self.preview_edits = []
self.app.ui.active_art.set_unsaved_changes(True)
# print(self.app.ui.active_art.command_stack)
def finish_paint(self):
"invoked by mouse button up and undo"
if (
self.app.ui.console.visible
or self.app.ui.popup in self.app.ui.hovered_elements
):
return
# push current command group onto undo stack
if not self.current_command:
return
self.current_command.finish_time = self.app.get_elapsed_time()
self.app.ui.active_art.command_stack.commit_commands([self.current_command])
self.current_command = None
# tools like rotate produce a different change each time, so update again
if self.app.ui.selected_tool.update_preview_after_paint:
self.update_cursor_preview()
# print(self.app.ui.active_art.command_stack)
def moved_this_frame(self):
return (
self.moved
or int(self.last_x) != int(self.x)
or int(self.last_y) != int(self.y)
)
def reposition_from_mouse(self):
self.x, self.y, _ = vector.screen_to_world(
self.app, self.app.mouse_x, self.app.mouse_y
)
def snap_to_tile(self):
w, h = self.app.ui.active_art.quad_width, self.app.ui.active_art.quad_height
char_aspect = w / h
# round result for oddly proportioned charsets
self.x = round(math.floor(self.x / w) * w)
self.y = round(math.ceil(self.y / h) * h * char_aspect)
def pre_first_update(self):
# vector.screen_to_world result will be off because camera hasn't
# moved yet, recalc view matrix
self.app.camera.calc_view_matrix()
self.reposition_from_mouse()
self.snap_to_tile()
self.update_cursor_preview()
self.entered_new_tile()
def update(self):
# save old positions before update
self.last_x, self.last_y = self.x, self.y
# pulse alpha and scale
self.alpha = 0.75 + (math.sin(self.app.get_elapsed_time() / 100) / 2)
# self.scale_x = 1.5 + (math.sin(self.get_elapsed_time() / 100) / 50 - 0.5)
mouse_moved = self.app.mouse_dx != 0 or self.app.mouse_dy != 0
# update cursor from mouse if: mouse moved, camera moved w/o keyboard
if mouse_moved or (
not self.app.keyboard_editing and self.app.camera.moved_this_frame
):
# don't let mouse move cursor if text tool input is happening
if not self.app.ui.text_tool.input_active:
self.reposition_from_mouse()
# cursor always at depth of active layer
art = self.app.ui.active_art
self.z = art.layers_z[art.active_layer] if art else 0
self.moved = True
if not self.moved and not self.app.ui.tool_settings_changed:
return
if not self.app.keyboard_editing and not self.app.ui.tool_settings_changed:
self.snap_to_tile()
# adjust for brush size
if self.app.ui.selected_tool.brush_size:
size = self.app.ui.selected_tool.brush_size
self.scale_x = self.scale_y = size
# don't reposition on resize if keyboard navigating
if mouse_moved:
size_offset = math.ceil(size / 2) - 1
self.x -= size_offset
self.y += size_offset
else:
self.scale_x = self.scale_y = 1
self.undo_preview_edits()
self.update_cursor_preview()
if self.moved_this_frame():
self.entered_new_tile()
def end_update(self):
"called at the end of App.update"
self.moved = False
def entered_new_tile(self):
if self.current_command and self.app.ui.selected_tool.paint_while_dragging:
# add new tile(s) to current command group
self.current_command.add_command_tiles(self.preview_edits)
self.app.ui.active_art.set_unsaved_changes(True)
self.preview_edits = []
def render(self):
GL.glUseProgram(self.shader.program)
GL.glUniformMatrix4fv(
self.proj_matrix_uniform, 1, GL.GL_FALSE, self.app.camera.projection_matrix
)
GL.glUniformMatrix4fv(
self.view_matrix_uniform, 1, GL.GL_FALSE, self.app.camera.view_matrix
)
GL.glUniform3f(self.position_uniform, self.x, self.y, self.z)
GL.glUniform3f(self.scale_uniform, self.scale_x, self.scale_y, self.scale_z)
GL.glUniform4fv(self.color_uniform, 1, self.color)
GL.glUniform2f(
self.quad_size_uniform,
self.app.ui.active_art.quad_width,
self.app.ui.active_art.quad_height,
)
GL.glUniform1f(self.alpha_uniform, self.alpha)
# VAO vs non-VAO paths
if self.app.use_vao:
GL.glBindVertexArray(self.vao)
else:
attrib = self.shader.get_attrib_location # for brevity
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer)
GL.glVertexAttribPointer(
attrib("vertPosition"),
2,
GL.GL_FLOAT,
GL.GL_FALSE,
0,
ctypes.c_void_p(0),
)
GL.glEnableVertexAttribArray(attrib("vertPosition"))
# bind elem array instead of passing it to glDrawElements - latter
# sends pyopengl a new array, which is deprecated and breaks on Mac.
# thanks Erin Congden!
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_buffer)
GL.glEnable(GL.GL_BLEND)
GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA)
# draw 4 corners
for i in range(4):
tx, ty = corner_transforms[i][0], corner_transforms[i][1]
ox, oy = corner_offsets[i][0], corner_offsets[i][1]
GL.glUniform2f(self.xform_uniform, tx, ty)
GL.glUniform2f(self.offset_uniform, ox, oy)
GL.glDrawElements(
GL.GL_TRIANGLES, self.vert_count, GL.GL_UNSIGNED_INT, None
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
GL.glDisable(GL.GL_BLEND)
if self.app.use_vao:
GL.glBindVertexArray(0)
GL.glUseProgram(0)
# position and render tool icon
ui = self.app.ui
# special handling for quick grab
if self.app.right_mouse:
self.tool_sprite.texture = ui.grab_tool.get_icon_texture()
else:
self.tool_sprite.texture = ui.selected_tool.get_icon_texture()
# scale same regardless of screen resolution
aspect = self.app.window_height / self.app.window_width
scale_x = self.tool_sprite.texture.width / self.app.window_width
scale_x *= self.icon_scale_factor * self.app.ui.scale
self.tool_sprite.scale_x = scale_x
scale_y = self.tool_sprite.texture.height / self.app.window_height
scale_y *= self.icon_scale_factor * self.app.ui.scale
self.tool_sprite.scale_y = scale_y
# top left of icon at bottom right of cursor
size = ui.selected_tool.brush_size or 1
x, y = self.x, self.y
x += size * ui.active_art.quad_width
# non-square charsets a bit tricky to properly account for
char_aspect = ui.active_art.quad_height / ui.active_art.quad_width
y -= (size / char_aspect) * ui.active_art.quad_height
y *= char_aspect
sx, sy = vector.world_to_screen_normalized(self.app, x, y, self.z)
# screen-space offset by icon's height
sy -= scale_y
self.tool_sprite.x, self.tool_sprite.y = sx, sy
self.tool_sprite.render()