app/console/camera.rb
the library's own source, console 0.2.0 · back to Console::Camera
# Console::Camera -- a 2D scroll/zoom camera. # # The camera owns the world -> screen offset used by every draw call inside # `camera.apply`. UI drawn outside the block stays screen-locked, which is the # usual split (world in `apply`, HUD after). # # camera.look_at(entity) # follow an entity # camera.within(100) # 100px of the world stays visible # camera.shake(6, 12) # magnitude 6, 12 ticks # camera.apply do # draw.sprite(x: entity.x, y: entity.y, path: :hero) # end # # Offsets are computed so the focused point lands at the screen's centre, and # the view is clamped so you never see past the world bounds unless you opt out # with `camera.unbounded`. module Console class Camera attr_reader :shake_magnitude attr_accessor :x, :y, :zoom, :bounds, :target_x, :target_y def initialize(args) @args = args @x = 0.0 @y = 0.0 @zoom = 1.0 @shake_magnitude = 0.0 @shake_until = 0 @bounds = nil @target_x = nil @target_y = nil @margin = 100 @offset_stack = [] @follow = nil end def args @args end def grid @args.grid end # Centre of the screen. Named `screen_center_*` so it cannot be confused # with Geom.center_x, which takes a rect. def screen_center_x grid.w / 2.0 end def screen_center_y grid.h / 2.0 end # --- configuration ---------------------------------------------------- def bounds=(value) @bounds = value end # Set the world rectangle the camera is allowed to show. def within(margin) @margin = margin self end def unbounded @bounds = nil @margin = 0 self end def zoom=(value) @zoom = value <= 0 ? 1.0 : value end # --- focus ------------------------------------------------------------ def follow(entity) @follow = entity self end def stop_following @follow = nil self end # Point the camera at a world position (or an entity) for one frame. def look_at(thing) if thing.is_a?(Hash) && thing[:x] @target_x = thing[:x] @target_y = thing[:y] end self end def shake(magnitude, ticks = 10) @shake_magnitude = magnitude @shake_until = Kernel.tick_count + ticks self end # --- per-frame ------------------------------------------------------- def update if @follow cx = @target_x || Geom.center_x(@follow) cy = @target_y || Geom.center_y(@follow) else cx = @target_x || (@x + screen_center_x) cy = @target_y || (@y + screen_center_y) end @target_x = nil @target_y = nil desired_x = cx - screen_center_x desired_y = cy - screen_center_y @x = desired_x @y = desired_y clamp_to_bounds self end def shake_offset_x return 0.0 unless shaking? (rand - 0.5) * 2 * @shake_magnitude end def shake_offset_y return 0.0 unless shaking? (rand - 0.5) * 2 * @shake_magnitude end def shaking? Kernel.tick_count < @shake_until end # --- transform -------------------------------------------------------- # Current total offset, including shake. def offset_x @x + shake_offset_x end def offset_y @y + shake_offset_y end # Convert a world point to screen space. def to_screen(wx, wy) { x: (wx - offset_x) * @zoom + (screen_center_x * (1 - @zoom)), y: (wy - offset_y) * @zoom + (screen_center_y * (1 - @zoom)) } end # Convert a screen point to world space (for mouse input). def to_world(sx, sy) { x: (sx - (screen_center_x * (1 - @zoom))) / @zoom + offset_x, y: (sy - (screen_center_y * (1 - @zoom))) / @zoom + offset_y } end # Pointer position in world space, accounting for camera and shake. def pointer_world p = @args.inputs.mouse to_world p.x, p.y end # --- drawing ---------------------------------------------------------- # Run the block with the camera applied to all coordinates. # # Coordinates are transformed by pushing a translate offset that Draw reads. def apply @offset_stack.push({ x: offset_x, y: offset_y, zoom: @zoom }) begin yield self ensure @offset_stack.pop end end def active? @offset_stack.size > 0 end def current_offset active? ? @offset_stack.last : { x: 0, y: 0, zoom: 1.0 } end # Screen bounds of the visible world area (for culling / minimaps). def visible_rect tl = to_world 0, grid.h { x: tl[:x], y: tl[:y], w: grid.w / @zoom, h: grid.h / @zoom } end # True when a world rect overlaps the visible area (with padding). def visible?(r) v = visible_rect r.x < v.x + v.w && (r.x + r.w) > v.x && r.y < v.y + v.h && (r.y + r.h) > v.y end private def clamp_to_bounds return if @bounds.nil? return if @bounds[:x].nil? || @bounds[:y].nil? half_w = screen_center_x half_h = screen_center_y min_x = @bounds[:x] max_x = @bounds[:x] + @bounds[:w] - half_w min_y = @bounds[:y] max_y = @bounds[:y] + @bounds[:h] - half_h # When the world is smaller than the view, centre it instead of clamping. # When the world is smaller than the view on an axis, centre it there # instead of clamping (which would otherwise pin it to one edge). @x = if max_x < min_x @bounds[:x] + ((@bounds[:w] - grid.w) / 2.0) else @x.clamp(min_x, max_x) end @y = if max_y < min_y @bounds[:y] + ((@bounds[:h] - grid.h) / 2.0) else @y.clamp(min_y, max_y) end end end end