Created
September 25, 2026 06:53
-
-
Save amirrajan/b434842a2bacf62eb6e52128eb3738af to your computer and use it in GitHub Desktop.
DragonRuby Game Toolkit - Live in-game map editor with AABB collision and camera. (https://youtu.be/N71tnzHuNq4)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| class Camera | |
| class << self | |
| def viewport_w | |
| Grid.allscreen_w | |
| end | |
| def viewport_h | |
| Grid.allscreen_h | |
| end | |
| def viewport_w_half | |
| if Grid.origin_center? | |
| 0 | |
| else | |
| Grid.allscreen_w.fdiv(2).ceil | |
| end | |
| end | |
| def viewport_h_half | |
| if Grid.origin_center? | |
| 0 | |
| else | |
| Grid.allscreen_h.fdiv(2).ceil | |
| end | |
| end | |
| def viewport_offset_x | |
| if Grid.origin_center? | |
| 0 | |
| else | |
| Grid.allscreen_x | |
| end | |
| end | |
| def viewport_offset_y | |
| if Grid.origin_center? | |
| 0 | |
| else | |
| Grid.allscreen_y | |
| end | |
| end | |
| def __to_world_space__ camera, rect | |
| return nil if !rect | |
| x = (rect.x - viewport_w_half + camera.x * camera.scale - viewport_offset_x) / camera.scale | |
| y = (rect.y - viewport_h_half + camera.y * camera.scale - viewport_offset_y) / camera.scale | |
| if rect.w | |
| w = rect.w / camera.scale | |
| h = rect.h / camera.scale | |
| { **rect, x: x, y: y, w: w, h: h } | |
| else | |
| { **rect, x: x, y: y } | |
| end | |
| end | |
| def to_world_space camera, rect | |
| if rect.is_a? Array | |
| rect.map { |r| to_world_space camera, r } | |
| else | |
| __to_world_space__ camera, rect | |
| end | |
| end | |
| def __to_screen_space__ camera, rect | |
| return nil if !rect | |
| x = rect.x * camera.scale - camera.x * camera.scale + viewport_w_half | |
| y = rect.y * camera.scale - camera.y * camera.scale + viewport_h_half | |
| if rect.w | |
| w = rect.w * camera.scale | |
| h = rect.h * camera.scale | |
| { **rect, x: x, y: y, w: w, h: h } | |
| else | |
| { **rect, x: x, y: y } | |
| end | |
| end | |
| def to_screen_space camera, rect | |
| if rect.is_a? Array | |
| rect.map { |r| to_screen_space camera, r } | |
| else | |
| __to_screen_space__ camera, rect | |
| end | |
| end | |
| def viewport | |
| if Grid.origin_center? | |
| { | |
| x: viewport_offset_x, | |
| y: viewport_offset_y, | |
| w: viewport_w, | |
| h: viewport_h, | |
| anchor_x: 0.5, | |
| anchor_y: 0.5 | |
| } | |
| else | |
| { | |
| x: viewport_offset_x, | |
| y: viewport_offset_y, | |
| w: viewport_w, | |
| h: viewport_h, | |
| } | |
| end | |
| end | |
| def viewport_world camera | |
| to_world_space camera, viewport | |
| end | |
| def find_all_intersect_viewport camera, os | |
| Geometry.find_all_intersect_rect viewport_world(camera), os | |
| end | |
| def primitives(camera, *os) | |
| rects = if os.is_a? Hash | |
| [os] | |
| else | |
| os.flatten | |
| end | |
| find_all_intersect_viewport(camera, rects).map do |o| | |
| to_screen_space camera, o | |
| end | |
| end | |
| end | |
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| class LevelEditor | |
| attr_dr | |
| def initialize args | |
| @args = args | |
| @tilesheet_rect = Layout.rect(row: 5, col: 0, w: 7, h: 7) | |
| @toggle_collision_button = Layout.rect(row: 4, col: 0, w: 7, h: 1) | |
| @tilesheet_button_size = @tilesheet_rect.w.fdiv(20) | |
| @tilesheet_metadata = {} | |
| @mode = :add | |
| init_tilesheet_buttons | |
| end | |
| def tick camera, terrain | |
| if mouse.click && hovered_tile | |
| @selected_tile = hovered_tile.copy | |
| end | |
| if mouse.click && @selected_tile && inside_toggle_collision_button_rect? | |
| tile_id = @selected_tile.id | |
| selected_tilesheet_button.has_collision = !selected_tilesheet_button.has_collision | |
| @selected_tile.has_collision = selected_tilesheet_button.has_collision | |
| end | |
| world_mouse = Camera.to_world_space camera, inputs.mouse.rect | |
| ifloor_x = world_mouse.x.ifloor(@tilesheet_button_size) | |
| ifloor_y = world_mouse.y.ifloor(@tilesheet_button_size) | |
| @mouse_world_rect = { x: ifloor_x, | |
| y: ifloor_y, | |
| w: @tilesheet_button_size, | |
| h: @tilesheet_button_size } | |
| if @selected_tile | |
| @selected_tile.x = @mouse_world_rect.x | |
| @selected_tile.y = @mouse_world_rect.y | |
| end | |
| if !inside_tilesheet_rect? && !inside_toggle_collision_button_rect? | |
| if delete_mode? && (mouse.click || (mouse.held && mouse.moved)) | |
| terrain.reject! { |t| Geometry.intersect_rect? t, @mouse_world_rect } | |
| save_terrain terrain | |
| elsif @selected_tile && (mouse.click || (mouse.held && mouse.moved)) | |
| if @mode == :add | |
| terrain.reject! { |t| Geometry.intersect_rect? t, @selected_tile } | |
| terrain << @selected_tile.copy | |
| else | |
| terrain.reject! { |t| Geometry.intersect_rect? t, @selected_tile } | |
| end | |
| save_terrain terrain | |
| end | |
| end | |
| end | |
| def selected_tilesheet_button | |
| return nil if !@selected_tile | |
| @tilesheet_buttons.find { |t| t.id == @selected_tile.id } | |
| end | |
| def hovered_tile | |
| return nil if !inside_tilesheet_rect? | |
| Geometry.find_intersect_rect({ click_box: inputs.mouse.rect }, @tilesheet_buttons, using: :click_box) | |
| end | |
| def inside_tilesheet_rect? | |
| Geometry.intersect_rect? mouse.rect, @tilesheet_rect | |
| end | |
| def inside_toggle_collision_button_rect? | |
| Geometry.intersect_rect? mouse.rect, @toggle_collision_button | |
| end | |
| def delete_mode? | |
| @selected_tile&.x_ordinal == 0 && @selected_tile&.y_ordinal == 0 | |
| end | |
| def hovered_tile_primitives | |
| return nil if !hovered_tile | |
| { x: hovered_tile.click_box.x, | |
| y: hovered_tile.click_box.y, | |
| w: hovered_tile.click_box.w, | |
| h: hovered_tile.click_box.h, | |
| path: :pixel, | |
| r: 255, g: 0, b: 0, a: 128 } | |
| end | |
| def delete_instruction_primitives | |
| return nil if !delete_mode? | |
| { **@toggle_collision_button.center, | |
| text: "Select tile to delete.", | |
| r: 255, | |
| g: 255, | |
| b: 255, | |
| anchor_x: 0.5, | |
| anchor_y: 0.5 } | |
| end | |
| def current_tile_marker_primitives | |
| return nil if !@selected_tile | |
| { **selected_tilesheet_button.click_box, | |
| path: :solid, | |
| r: 0, | |
| g: 255, | |
| b: 0, | |
| a: 128 } | |
| end | |
| def overlay_primitives | |
| [ | |
| { **@tilesheet_rect, path: :tilesheet }, | |
| hovered_tile_primitives, | |
| delete_instruction_primitives, | |
| toggle_collision_button_primitives, | |
| current_tile_marker_primitives, | |
| current_tile_marker_primitives | |
| ] | |
| end | |
| def scene_primitives | |
| return nil if !@selected_tile | |
| if delete_mode? | |
| { **@selected_tile, path: :solid, r: 255, g: 0, b: 0, a: 128 } | |
| else | |
| [ | |
| { **@selected_tile, path: :solid, r: 0, g: 0, b: 0 }, | |
| { **@selected_tile, r: 128, g: 255, b: 255 } | |
| ] | |
| end | |
| end | |
| def toggle_collision_button_primitives | |
| return nil if !@selected_tile | |
| return nil if delete_mode? | |
| text = if selected_tilesheet_button.has_collision | |
| "Collidable? Yes (click to toggle)" | |
| else | |
| "Collidable? No (click to toggle)" | |
| end | |
| [ | |
| { **@toggle_collision_button, | |
| path: :solid, | |
| r: 255, | |
| g: 255, | |
| b: 255 }, | |
| { **@toggle_collision_button.center, | |
| text: text, | |
| anchor_x: 0.5, | |
| anchor_y: 0.5, | |
| size_px: 16, | |
| r: 0, | |
| g: 0, | |
| b: 0 } | |
| ] | |
| end | |
| def init_tilesheet_buttons | |
| @tilesheet_buttons = [] | |
| rows = 20 | |
| cols = 20 | |
| height = rows * @tilesheet_button_size | |
| width = cols * @tilesheet_button_size | |
| rows.map_with_index do |row| | |
| cols.map_with_index do |col| | |
| x = col * @tilesheet_button_size | |
| y = height - row * @tilesheet_button_size - @tilesheet_button_size | |
| @tilesheet_buttons << { | |
| id: "#{col},#{row}", | |
| x_ordinal: col, | |
| y_ordinal: row, | |
| click_box: { | |
| x: @tilesheet_rect.x + x, | |
| y: @tilesheet_rect.y + y, | |
| w: @tilesheet_button_size, | |
| h: @tilesheet_button_size, | |
| }, | |
| x: x, | |
| y: y, | |
| w: @tilesheet_button_size, | |
| h: @tilesheet_button_size, | |
| path: tile_path(row, col, cols), | |
| has_collision: true | |
| } | |
| end | |
| end | |
| outputs[:tilesheet].w = width | |
| outputs[:tilesheet].h = height | |
| outputs[:tilesheet].primitives << { x: 0, y: 0, w: width, h: height, path: :pixel, r: 0, g: 0, b: 0 } | |
| outputs[:tilesheet].primitives << @tilesheet_buttons | |
| end | |
| def mouse | |
| inputs.mouse | |
| end | |
| def tile_path row, col, cols | |
| file_name = (row * cols + col).to_s.rjust(4, "0") | |
| "sprites/1-bit-platformer/#{file_name}.png" | |
| end | |
| def save_terrain terrain | |
| contents = terrain.uniq.map do |terrain_element| | |
| [ | |
| terrain_element.x.to_i, | |
| terrain_element.y.to_i, | |
| terrain_element.w.to_i, | |
| terrain_element.h.to_i, | |
| terrain_element.path, | |
| terrain_element.has_collision | |
| ].join(",") | |
| end | |
| File.write "data/terrain.txt", contents.join("\n") | |
| end | |
| def load_terrain | |
| contents = File.read("data/terrain.txt") | |
| return [] if !contents | |
| contents.lines.map do |line| | |
| l = line.strip | |
| if l.empty? | |
| nil | |
| else | |
| x, y, w, h, path, has_collision = l.split "," | |
| parsed_collision = if has_collision.nil? | |
| true | |
| else | |
| has_collision == "true" | |
| end | |
| { x: x.to_f, | |
| y: y.to_f, | |
| w: w.to_f, | |
| h: h.to_f, | |
| path: path, | |
| has_collision: parsed_collision } | |
| end | |
| end.compact.uniq | |
| end | |
| end | |
| DR.reset |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| require 'app/level_editor.rb' | |
| require 'app/camera.rb' | |
| module Main | |
| attr :level_editor, :player | |
| def start | |
| @level_editor = LevelEditor.new args | |
| @player ||= { | |
| x: 0, | |
| y: 750, | |
| w: 16, | |
| h: 16, | |
| dy: 0, | |
| dx: 0, | |
| facing: 1, | |
| on_ground: false, | |
| path: "sprites/1-bit-platformer/0280.png" | |
| } | |
| @gravity = 0.25 | |
| @camera = { | |
| x: 0, | |
| y: 0, | |
| target_x: 0, | |
| target_y: 0, | |
| target_scale: 2, | |
| scale: 1 | |
| } | |
| @terrain = @level_editor.load_terrain | |
| end | |
| def tick | |
| calc_player | |
| calc_camera | |
| calc_level_editor | |
| render | |
| end | |
| def calc_player | |
| if inputs.keyboard.left | |
| @player.dx = -3 | |
| @player.facing = -1 | |
| elsif inputs.keyboard.right | |
| @player.dx = 3 | |
| @player.facing = 1 | |
| end | |
| if (inputs.keyboard.key_down.space || inputs.key_down.up) && @player.on_ground | |
| @player.dy = 10 | |
| @player.on_ground = false | |
| end | |
| @player.x += @player.dx | |
| collision = @terrain.find do |t| | |
| Geometry.intersect_rect?(t, @player) && t.has_collision | |
| end | |
| if collision | |
| if @player.dx > 0 | |
| @player.x = collision.x - @player.w | |
| else | |
| @player.x = collision.x + collision.w | |
| end | |
| @player.dx = 0 | |
| end | |
| @player.dx *= 0.8 | |
| @player.dx = 0 if @player.dx.abs < 0.5 | |
| @player.y += @player.dy | |
| @player.on_ground = false | |
| collision = @terrain.find do |t| | |
| t.intersect_rect?(@player) && t.has_collision | |
| end | |
| if collision | |
| if @player.dy > 0 | |
| @player.y = collision.y - @player.h | |
| else | |
| @player.y = collision.y + collision.h | |
| @player.on_ground = true | |
| end | |
| @player.dy = 0 | |
| end | |
| @player.dy -= @gravity | |
| if (@player.y + @player.h) < -750 | |
| @player.y = 750 | |
| @player.dy = 0 | |
| end | |
| end | |
| def calc_camera | |
| if inputs.keyboard.key_down.equal_sign || inputs.keyboard.key_down.plus | |
| @camera.target_scale += 0.25 | |
| elsif inputs.keyboard.key_down.minus | |
| @camera.target_scale -= 0.25 | |
| @camera.target_scale = 0.25 if @camera.target_scale < 0.25 | |
| elsif inputs.keyboard.zero | |
| @camera.target_scale = 1 | |
| end | |
| ease = 0.1 | |
| @camera.scale += ((@camera.target_scale + -@player.dy.abs * 0.05 * @camera.scale) - @camera.scale) * ease | |
| @camera.target_x = @player.x + @player.dx * 8 * @camera.scale | |
| @camera.target_y = @player.y + @player.dy * 8 * @camera.scale | |
| @camera.x += (@camera.target_x - @camera.x) * ease | |
| @camera.y += (@camera.target_y - @camera.y) * ease | |
| end | |
| def calc_level_editor | |
| @level_editor.args = args | |
| @level_editor.tick @camera, @terrain | |
| end | |
| def render | |
| outputs.background_color = [0, 0, 0] | |
| outputs[:scene].set w: Camera.viewport_w, | |
| h: Camera.viewport_h, | |
| background_color: [30, 30, 30, 30] | |
| outputs[:scene].primitives << Camera.primitives(@camera, | |
| @terrain, | |
| player_primitives, | |
| @level_editor.scene_primitives) | |
| outputs.primitives << { **Camera.viewport, path: :scene } | |
| outputs.primitives << @level_editor.overlay_primitives | |
| outputs.labels << { x: 640, | |
| y: 30.from_top, | |
| anchor_x: 0.5, | |
| text: "WASD: move around / jump. +/-: Zoom in and out. MOUSE: select tile/edit map (select very TOP LEFT blank tile to DELETE).", | |
| r: 255, | |
| g: 255, | |
| b: 255 } | |
| end | |
| def player_primitives | |
| path = if !@player.on_ground | |
| # falling | |
| "sprites/1-bit-platformer/0284.png" | |
| elsif @player.dx != 0 | |
| # running | |
| frame_index = Numeric.frame_index start_at: 0, | |
| frame_count: 3, | |
| hold_for: 5, | |
| repeat: true | |
| "sprites/1-bit-platformer/028#{frame_index + 1}.png" | |
| else | |
| #standing | |
| "sprites/1-bit-platformer/0280.png" | |
| end | |
| { | |
| **@player, | |
| path: path, | |
| flip_horizontally: @player.facing < 0, | |
| r: 200, g: 255, b: 200 | |
| } | |
| end | |
| end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment