Created
April 19, 2026 18:17
-
-
Save amirrajan/129e690a9e858b37f21899f22c4999b0 to your computer and use it in GitHub Desktop.
DragonRuby Game Toolkit - Reference implementation WIP
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 | |
| attr :origin, | |
| :viewport_w, | |
| :viewport_h, | |
| :viewport_offset_x, | |
| :viewport_offset_y, | |
| :scale, | |
| :x, | |
| :y, | |
| :target_scale, | |
| :target_x, | |
| :target_y | |
| def initialize(origin:, viewport_w:, viewport_h:, viewport_offset_x: 0, viewport_offset_y: 0, | |
| scale: 1, x: 0, y: 0) | |
| @scale = scale | |
| @x = x | |
| @y = y | |
| @target_scale = scale | |
| @target_x = x | |
| @target_y = y | |
| @origin = origin | |
| @viewport_w = viewport_w | |
| @viewport_h = viewport_h | |
| @viewport_offset_x = viewport_offset_x | |
| @viewport_offset_y = viewport_offset_y | |
| end | |
| def origin_center? | |
| @origin == :center | |
| end | |
| def origin_bottom_left? | |
| @origin == :bottom_left | |
| end | |
| def viewport_h_half | |
| if origin_center? | |
| 0 | |
| else | |
| @viewport_h.fdiv(2).ceil | |
| end | |
| end | |
| def viewport_w_half | |
| if origin_center? | |
| 0 | |
| else | |
| @viewport_w.fdiv(2).ceil | |
| end | |
| end | |
| def __to_world_space__ rect | |
| return nil if !rect | |
| x = (rect.x - viewport_w_half + @x * @scale - @viewport_offset_x) / @scale | |
| y = (rect.y - viewport_h_half + @y * @scale - @viewport_offset_y) / @scale | |
| if rect.w | |
| w = rect.w / @scale | |
| h = rect.h / @scale | |
| { **rect, x: x, y: y, w: w, h: h } | |
| else | |
| { **rect, x: x, y: y } | |
| end | |
| end | |
| def to_world_space rect | |
| if rect.is_a? Array | |
| rect.map { |r| to_world_space r } | |
| else | |
| __to_world_space__ rect | |
| end | |
| end | |
| def __to_screen_space__ rect | |
| return nil if !rect | |
| x = rect.x * @scale - @x * @scale + viewport_w_half | |
| y = rect.y * @scale - @y * @scale + viewport_h_half | |
| if rect.w | |
| w = rect.w * @scale | |
| h = rect.h * @scale | |
| { **rect, x: x, y: y, w: w, h: h } | |
| else | |
| { **rect, x: x, y: y } | |
| end | |
| end | |
| def to_screen_space rect | |
| if rect.is_a? Array | |
| rect.map { |r| to_screen_space r } | |
| else | |
| __to_screen_space__ rect | |
| end | |
| end | |
| def viewport | |
| if 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 | |
| to_world_space viewport | |
| end | |
| def find_all_intersect_viewport os | |
| Geometry.find_all_intersect_rect os | |
| end | |
| def tick | |
| @x = @x.lerp(@target_x, 0.1) | |
| @y = @y.lerp(@target_y, 0.1) | |
| @scale = @scale.lerp(@target_scale, 0.1) | |
| end | |
| end | |
| class Enemy | |
| attr :x, :y, :w, :h, :angle | |
| def initialize(x:, y:, w:, h:) | |
| @x = x | |
| @y = y | |
| @w = w | |
| @h = h | |
| @action = :idle | |
| @action_at = Kernel.tick_count | |
| end | |
| def center | |
| { x: @x + @w / 2, y: @y + @h / 2 } | |
| end | |
| def frame | |
| Numeric.frame(start_at: @action_at, | |
| count: 6, | |
| hold_for: 4, | |
| repeat: true) | |
| end | |
| def rect | |
| Geometry.rect(x: @x, y: @y, w: @w, h: @h) | |
| end | |
| def center | |
| rect.center | |
| end | |
| def primitives | |
| { | |
| x: @x, | |
| y: @y, | |
| w: @w, | |
| h: @h, | |
| path: "sprites/human/#{frame.frame_index}.png" | |
| } | |
| end | |
| end | |
| class PlayerMenu | |
| attr :player, :buy_miner_rect | |
| def initialize(game:, player:) | |
| @game = game | |
| @player = player | |
| @upgrade_signal_radius_rect = Layout.rect(row: 0, col: 18, w: 2, h: 2) | |
| @buy_miner_rect = Layout.rect(row: 0, col: 20, w: 2, h: 2) | |
| @buy_artillery_rect = Layout.rect(row: 0, col: 22, w: 2, h: 2) | |
| end | |
| def can_mine_mineral_field? | |
| @game.entities.find do |e| | |
| e.type == :mineral_field && Geometry.intersect_rect?(@player.interaction_rect, e) | |
| end | |
| end | |
| def button(button_rect, text, subtext) | |
| [ | |
| button_rect.merge(path: :solid, | |
| r: 128, | |
| g: 128, | |
| b: 128), | |
| button_rect.center.merge(text: text, | |
| anchor_x: 0.5, | |
| anchor_y: 0.0, | |
| size_px: 16, | |
| r: 255, | |
| g: 255, | |
| b: 255), | |
| button_rect.center.merge(text: subtext, | |
| anchor_x: 0.5, | |
| anchor_y: 1.0, | |
| size_px: 16, | |
| r: 255, | |
| g: 255, | |
| b: 255) | |
| ] | |
| end | |
| def buy_miner_button | |
| button(@buy_miner_rect, "Miner", "$#{@player.miner_cost}") | |
| end | |
| def buy_artillery_button | |
| button(@buy_artillery_rect, "Artillery", "$#{@player.artillery_cost}") | |
| end | |
| def upgrade_signal_radius_button | |
| button(@upgrade_signal_radius_rect, "Boost Signal", "$#{@player.signal_radius_upgrade_cost}") | |
| end | |
| def primitives | |
| [ | |
| Layout.rect(row: 0, col: 12, w: 0, h: 0.25) | |
| .merge(text: "Sieze the Means of Production", | |
| anchor_x: 0.5, | |
| anchor_y: -0.5, | |
| size_px: 32, | |
| r: 255, | |
| g: 255, | |
| b: 255), | |
| Layout.rect(row: 0, col: 18, w: 6, h: 0) | |
| .center | |
| .merge(text: "Credits: #{@player.money}", | |
| size_px: 32, | |
| anchor_x: 0.5, | |
| anchor_y: 0, | |
| r: 255, | |
| g: 255, | |
| b: 255), | |
| upgrade_signal_radius_button, | |
| buy_miner_button, | |
| buy_artillery_button, | |
| ] | |
| end | |
| def tick inputs | |
| if inputs.mouse.key_up.left | |
| if Geometry.intersect_rect?(inputs.mouse, @buy_miner_rect) | |
| if @player.money >= @player.miner_cost | |
| @player.money -= @player.miner_cost | |
| @player.build_miner! | |
| end | |
| elsif Geometry.intersect_rect?(inputs.mouse, @buy_artillery_rect) | |
| if @player.money >= @player.artillery_cost | |
| @player.money -= @player.artillery_cost | |
| @player.components << Artilery.new(x: @player.x, y: @player.y, w: 8, h: 8, angle: 0, game: @game, slot: @player.components.length) | |
| end | |
| elsif Geometry.intersect_rect?(inputs.mouse, @upgrade_signal_radius_rect) | |
| if @player.money >= @player.signal_radius_upgrade_cost | |
| @player.money -= @player.signal_radius_upgrade_cost | |
| @player.signal_radius_upgrade_count += 1 | |
| end | |
| end | |
| end | |
| end | |
| end | |
| class Bot | |
| attr :x, :y, :w, :h, :angle, :action, :action_at | |
| FOLLOW_SPREAD_ANGLES = [0, 25, -25, 50, -50, 75, -75, 89, -89] | |
| FOLLOW_OFFSET_RADII = [60, 80, 80, 45, 45, 100, 100] | |
| def initialize(x:, y:, w:, h:, angle:, game:, slot: 0) | |
| @game = game | |
| @x = x | |
| @y = y | |
| @w = w | |
| @h = h | |
| @angle = angle | |
| @action = :decide | |
| @action_at = Kernel.tick_count | |
| @follow_spread_angle = FOLLOW_SPREAD_ANGLES[slot % FOLLOW_SPREAD_ANGLES.length] | |
| @follow_offset_radius = FOLLOW_OFFSET_RADII[slot % FOLLOW_OFFSET_RADII.length] | |
| @message = nil | |
| @message_at = Kernel.tick_count | |
| @slot = slot | |
| created_messages | |
| message! created_messages[slot % created_messages.length] | |
| end | |
| def created_messages | |
| ["Hi!", "Let's go!", "Woo!", "Yay!"] | |
| end | |
| def rect | |
| Geometry.rect(x: @x, y: @y, w: @w, h: @h) | |
| end | |
| def center | |
| rect.center | |
| end | |
| def action! value | |
| return if @action == value | |
| @action = value | |
| @action_at = Kernel.tick_count | |
| end | |
| def player | |
| @game.player | |
| end | |
| def tick | |
| puts "implement tick in subclass: #{self.class}" | |
| end | |
| def message_expired? | |
| return true if !@message | |
| return true if @message_at.elapsed_time > 120 | |
| false | |
| end | |
| def message_primitives | |
| return nil if message_expired? | |
| [ | |
| { | |
| x: @x + @w / 2, | |
| y: @y + @h + 8, | |
| text: @message, | |
| size_px: 16, | |
| anchor_x: 0.5, | |
| anchor_y: 0, | |
| r: 255, g: 255, b: 255, a: 255 | |
| } | |
| ] | |
| end | |
| def message! value | |
| return if !message_expired? | |
| @message = value | |
| @message_at = Kernel.tick_count | |
| end | |
| def tick_follow | |
| behind_angle = @game.player.angle + 180 + @follow_spread_angle | |
| target = { | |
| x: @game.player.center.x + behind_angle.to_vector.x * @follow_offset_radius, | |
| y: @game.player.center.y + behind_angle.to_vector.y * @follow_offset_radius | |
| } | |
| dist = Geometry.distance(center, target) | |
| if dist.round > 4 | |
| angle_to_target = Geometry.angle(center, target) | |
| delta = Geometry.angle_delta(@angle, angle_to_target) | |
| @angle += delta * 0.1 | |
| speed = dist.clamp(0, 48).fdiv(48) * 1.5 | |
| @x += @angle.to_vector.x * speed | |
| @y += @angle.to_vector.y * speed | |
| else | |
| action! :decide | |
| end | |
| end | |
| def tick_push | |
| @game.player.components.each do |other| | |
| next if other.equal?(self) | |
| sep_dist = Geometry.distance(center, other.center) | |
| if sep_dist < 16 && sep_dist > 0 | |
| @angle += 0.25 * Geometry.angle_delta(@angle, Geometry.angle(center, @game.player.center) + 180) | |
| push_angle = Geometry.angle(other.center, center) | |
| push_strength = (16 - sep_dist) / 16.0 | |
| @x += push_angle.to_vector.x * push_strength | |
| @y += push_angle.to_vector.y * push_strength | |
| end | |
| end | |
| player_dist = Geometry.distance(center, @game.player.center) | |
| repulsion_radius = (@game.player.w + @w) / 2 + 8 | |
| if player_dist < repulsion_radius && player_dist > 0 | |
| @angle += 0.25 * Geometry.angle_delta(@angle, Geometry.angle(center, @game.player.center) + 180) | |
| push_angle = Geometry.angle(@game.player.center, center) | |
| push_strength = (repulsion_radius - player_dist) / repulsion_radius.to_f | |
| @x += push_angle.to_vector.x * push_strength | |
| @y += push_angle.to_vector.y * push_strength | |
| end | |
| end | |
| end | |
| class Artilery < Bot | |
| def bullet_primitives | |
| return nil if !@bullet | |
| { | |
| x: @bullet.x, | |
| y: @bullet.y, | |
| w: @bullet.w, | |
| h: @bullet.h, | |
| path: :solid, | |
| r: 255, g: 128, b: 0, | |
| anchor_x: @bullet.anchor_x, | |
| anchor_y: @bullet.anchor_y, | |
| } | |
| end | |
| def created_messages | |
| ["Ready to roll out!", "Artilery online!", "For Aiur!", "Let's get 'em!"] | |
| end | |
| def primitives | |
| [ | |
| { | |
| x: @x, | |
| y: @y, | |
| w: @w, | |
| h: @h, | |
| angle: @angle, | |
| path: :solid, | |
| r: 200, g: 100, b: 100 | |
| }, | |
| message_primitives, | |
| bullet_primitives | |
| ] | |
| end | |
| def tick | |
| distance_to_player = Geometry.distance(center, @game.player.center) | |
| if distance_to_player > player.signal_radius && Kernel.tick_count.zmod?(@slot + 1) | |
| @mineral_field = nil | |
| if @action == :attack | |
| selected_message = ["Abort attack!", "Run! Run!", "Tis only a flesh wound!", "Until next time!"][@slot % 4] | |
| message! selected_message | |
| else | |
| selected_message = ["Mom! Come back!", "Mommy!", "I wanna be with you!", "Don't leave me!"][@slot % 4] | |
| message! selected_message | |
| end | |
| action! :follow | |
| end | |
| if @action == :decide | |
| tick_decide | |
| elsif @action == :follow | |
| tick_follow | |
| elsif @action == :attack | |
| tick_attack | |
| end | |
| tick_bullet | |
| tick_push | |
| end | |
| def tick_attack | |
| return if !@enemy | |
| angle_to_target = Geometry.angle(center, @enemy.center) | |
| distance_to_target = Geometry.distance(center, @enemy.center) | |
| $args.outputs.watch "Enemy distance: #{distance_to_target}" | |
| delta = Geometry.angle_delta(@angle, angle_to_target) | |
| @angle += delta * 0.1 | |
| if distance_to_target > 64 | |
| @x += @angle.to_vector.x * 1.5 | |
| @y += @angle.to_vector.y * 1.5 | |
| end | |
| if delta.round == 0 && !@bullet | |
| if !player.enemies_within_range.include?(@enemy) | |
| @enemy = nil | |
| selected_message = ["Abort attack!", "Run! Run!", "Tis only a flesh wound!", "Until next time!"].sample | |
| message! selected_message | |
| action! :decide | |
| else | |
| random_tragectory = 45.rand(:sign) | |
| @bullet ||= { | |
| created_at: Kernel.tick_count, | |
| x: center.x, | |
| y: center.y, | |
| w: 16, | |
| h: 16, | |
| dx: (@angle + random_tragectory).to_vector.x * 8, | |
| dy: (@angle + random_tragectory).to_vector.y * 8, | |
| anchor_x: 0.5, | |
| anchor_y: 0.5 | |
| } | |
| end | |
| end | |
| end | |
| def tick_bullet | |
| return if !@bullet | |
| @bullet.x += @bullet.dx | |
| @bullet.y += @bullet.dy | |
| angle = Geometry.angle(@bullet, @enemy.center) | |
| @bullet.dx += angle.to_vector.x | |
| @bullet.dy += angle.to_vector.y | |
| @bullet.dx *= 0.9 | |
| @bullet.dy *= 0.9 | |
| @bullet.w *= 0.95 | |
| @bullet.h *= 0.95 | |
| @bullet.w = 4 if @bullet.w < 4 | |
| @bullet.h = 4 if @bullet.h < 4 | |
| if Geometry.intersect_rect?({ x: @bullet.x, y: @bullet.y, w: 1, h: 1 }, @enemy.rect) | |
| @bullet = nil | |
| end | |
| end | |
| def tick_decide | |
| enemy = player.enemies_within_range.sort_by do |e| | |
| Geometry.distance(center, e.center) | |
| end.first | |
| if enemy | |
| @enemy = enemy | |
| action! :attack | |
| selected_message = ["My life for Aiur!", "Take this!", "Engage!"][@slot % 3] | |
| message! selected_message | |
| else | |
| action! :follow | |
| end | |
| end | |
| end | |
| class Miner < Bot | |
| def primitives | |
| [ | |
| { | |
| x: @x, | |
| y: @y, | |
| w: @w, | |
| h: @h, | |
| angle: @angle, | |
| path: :solid, | |
| r: 200, g: 200, b: 200 | |
| }, | |
| message_primitives | |
| ] | |
| end | |
| def tick_moving_to_mine | |
| angle_to_mine = Geometry.angle(center, { x: @mineral_field.x + @mineral_field.w / 2, y: @mineral_field.y + @mineral_field.h / 2 }) | |
| delta = Geometry.angle_delta(@angle, angle_to_mine) | |
| @angle += delta * 0.1 | |
| @x += angle.to_vector.x * 1.5 | |
| @y += angle.to_vector.y * 1.5 | |
| if Geometry.intersect_rect?(rect, @mineral_field) | |
| action! :mining | |
| end | |
| end | |
| def tick_mining | |
| if @action_at.elapsed_time.zmod?(10) | |
| if !Geometry.intersect_rect?({ x: @x, y: @y, w: @w, h: @h }, @mineral_field) | |
| action! :moving_to_mine | |
| return | |
| end | |
| @mineral_field.quantity -= 1 | |
| @game.player.money += 1 | |
| @game.player.queue_particle!(x: @mineral_field.x + @mineral_field.w / 2, | |
| y: @mineral_field.y + @mineral_field.h / 2, | |
| r: 209, g: 229, b: 105) | |
| if @mineral_field.quantity <= 0 | |
| @mineral_field = nil | |
| action! :decide | |
| end | |
| end | |
| end | |
| def tick_decide | |
| mineral_field = @game.player | |
| .mineral_fields_within_range | |
| .sort_by do |mf| | |
| Geometry.distance(center, Geometry.center(mf)) | |
| end | |
| .take(5) | |
| .sample | |
| if mineral_field | |
| @mineral_field = mineral_field | |
| action! :moving_to_mine | |
| selected_message = ["Time to mine!", "Oooo! Shiny!", "Let's goooo!", "Must consume."][@slot % 4] | |
| message! selected_message | |
| else | |
| action! :follow | |
| end | |
| end | |
| def tick | |
| distance_to_player = Geometry.distance(center, @game.player.center) | |
| if distance_to_player > player.signal_radius && Kernel.tick_count.zmod?(@slot + 1) | |
| @mineral_field = nil | |
| action! :follow | |
| selected_message = ["Mom! Come back!", "Mommy!", "I wanna be with you!", "Don't leave me!"][@slot % 4] | |
| message! selected_message | |
| end | |
| if @action == :decide | |
| tick_decide | |
| elsif @action == :follow | |
| tick_follow | |
| elsif @action == :moving_to_mine | |
| tick_moving_to_mine | |
| elsif @action == :mining | |
| tick_mining | |
| end | |
| tick_push | |
| end | |
| end | |
| class Player | |
| attr :x, :y, :w, :h, :angle, :action, :action_at, :particles, :money, :components, | |
| :signal_radius_upgrade_count | |
| def initialize(x:, y:, w:, h:, angle:, game:) | |
| @x = x | |
| @y = y | |
| @w = w | |
| @h = h | |
| @angle = angle | |
| @action = :idle | |
| @signal_radius_upgrade_count = 0 | |
| @action_at = Kernel.tick_count | |
| @particles = [] | |
| @game = game | |
| @money = 0 | |
| @components = [] | |
| @components << Miner.new(x: @x, y: @y, w: 8, h: 8, angle: 0, game: @game, slot: @components.length) | |
| @components << Miner.new(x: @x, y: @y, w: 8, h: 8, angle: 0, game: @game, slot: @components.length) | |
| @components << Artilery.new(x: @x, y: @y, w: 8, h: 8, angle: 0, game: @game, slot: @components.length) | |
| end | |
| def signal_radius | |
| 128 + @signal_radius_upgrade_count * 32 | |
| end | |
| def signal_radius_upgrade_cost | |
| (25 + @signal_radius_upgrade_count * 1.25 * 50).to_i | |
| end | |
| def center | |
| rect.center | |
| end | |
| def rect | |
| Geometry.rect(x: @x, y: @y, w: @w, h: @h) | |
| end | |
| def build_miner! | |
| @components << Miner.new(x: @x, y: @y, w: 8, h: 8, angle: 0, game: @game, slot: @components.length) | |
| end | |
| def action! value | |
| return if @action == value | |
| @action = value | |
| @action_at = Kernel.tick_count | |
| end | |
| def primitives | |
| [ | |
| { | |
| **center, | |
| w: signal_radius * 2, | |
| h: signal_radius * 2, | |
| path: "sprites/circle/solid.png", | |
| r: 255, g: 255, b: 255, a: 8, | |
| anchor_x: 0.5, anchor_y: 0.5 | |
| }, | |
| { | |
| x: @x - @w / 2, | |
| y: @y - @h / 2, | |
| w: @w, | |
| h: @h, | |
| angle: @angle, | |
| path: :solid | |
| }, | |
| @particles, | |
| @components.map(&:primitives), | |
| ] | |
| end | |
| def mineral_fields_within_range | |
| @game.entities.find_all do |e| | |
| e.type == :mineral_field && | |
| e.quantity > 0 && | |
| Geometry.distance(e, center) < signal_radius | |
| end | |
| end | |
| def enemies_within_range | |
| @game.enemies.find_all do |e| | |
| Geometry.distance(e, center) < signal_radius | |
| end | |
| end | |
| def interaction_rect | |
| Geometry.zoom_rect(rect: { | |
| x: (center.x - 16) + @angle.to_vector.x * 16, | |
| y: (center.y - 16) + @angle.to_vector.y * 16, | |
| w: 32, | |
| h: 32, | |
| }, ratio: 0.5) | |
| end | |
| def queue_particle!(x:, y:, r:, g:, b:) | |
| @particles << { | |
| x: x, | |
| y: y, | |
| w: 26, h: 26, | |
| a: 128, | |
| anchor_x: 0.5, anchor_y: 0.5, | |
| dx: (@angle + Numeric.rand(-45..45)).to_vector.x * 20, | |
| dy: (@angle + Numeric.rand(-45..45)).to_vector.y * 20, | |
| path: :solid, | |
| r: r, g: g, b: b | |
| } | |
| end | |
| def tick | |
| if @action == :mining | |
| if @action_at.elapsed_time.zmod?(10) | |
| @mineral_field.quantity -= 2 | |
| @money += 2 | |
| queue_particle!(x: @mineral_field.x + @mineral_field.w / 2, | |
| y: @mineral_field.y + @mineral_field.h / 2, | |
| r: 238, g: 210, b: 133) | |
| if @mineral_field.quantity <= 0 | |
| @mineral_field = nil | |
| action! :idle | |
| end | |
| end | |
| end | |
| @components.each(&:tick) | |
| @particles.each do |particle| | |
| particle.x += particle.dx | |
| particle.y += particle.dy | |
| angle = Geometry.angle(particle, center) | |
| particle.dx += angle.to_vector.x | |
| particle.dy += angle.to_vector.y | |
| particle.dx *= 0.9 | |
| particle.dy *= 0.9 | |
| particle.w *= 0.95 | |
| particle.h *= 0.95 | |
| particle.w = 4 if particle.w < 4 | |
| particle.h = 4 if particle.h < 4 | |
| end | |
| @particles.reject! do |particle| | |
| Geometry.distance(particle, center) < 4 | |
| end | |
| end | |
| def mine! mineral_field | |
| @mineral_field = mineral_field | |
| action! :mining | |
| end | |
| def miner_cost | |
| 10 + miner_count * 50 | |
| end | |
| def artillery_cost | |
| 20 + artillery_count * 50 | |
| end | |
| def miner_count | |
| @components.count { |c| c.is_a?(Miner) } | |
| end | |
| def artillery_count | |
| @components.count { |c| c.is_a?(Artilery) } | |
| end | |
| end | |
| class Game | |
| attr_gtk | |
| attr :entities, :player_menu, :enemies, :player | |
| def initialize | |
| @player = Player.new(x: 0, | |
| y: 0, | |
| w: 16, | |
| h: 16, | |
| angle: 0, | |
| game: self) | |
| @player_menu = PlayerMenu.new(game: self, player: @player) | |
| @particles = [] | |
| @grass_background = { | |
| x: -640, | |
| y: -640, | |
| w: 1280, | |
| h: 1280, | |
| path: :grass_background | |
| } | |
| @entities = load_entities | |
| @enemies = [ | |
| Enemy.new(x: 200, y: 200, w: 8, h: 16), | |
| ] | |
| @camera = Camera.new origin: :bottom_left, | |
| viewport_w: 620, | |
| viewport_h: 620, | |
| scale: 3.0 - @player.signal_radius.fdiv(512), | |
| x: @player.x, | |
| y: @player.y | |
| end | |
| def mouse_in_world_space | |
| scene_x = inputs.mouse.x - (640 - @camera.viewport_w / 2) | |
| scene_y = inputs.mouse.y - (360 - @camera.viewport_h / 2) | |
| @camera.to_world_space({ x: scene_x, y: scene_y }) | |
| end | |
| def mouse_rect_in_world_space_grid_aligned | |
| mouse = mouse_in_world_space | |
| { | |
| x: mouse.x.ifloor(32), | |
| y: mouse.y.ifloor(32), | |
| w: 32, | |
| h: 32 | |
| } | |
| end | |
| def load_entities | |
| contents = GTK.read_file("data/entities.txt") || "" | |
| contents.each_line | |
| .map | |
| .with_index do |l, i| | |
| type, x, y, w, h = l.strip.split(",") | |
| type = type.to_sym | |
| x = x.to_i | |
| y = y.to_i | |
| w = w.to_i | |
| h = h.to_i | |
| if type == :mineral_field | |
| { | |
| index: i, | |
| type: :mineral_field, | |
| x: x, y: y, | |
| w: w, h: h, | |
| quantity: 100, | |
| path: "sprites/mineral_fields/#{i % 4}.png" | |
| } | |
| end | |
| end | |
| end | |
| def save_entities entities | |
| contents = entities.map do |e| | |
| "#{e.type},#{e.x},#{e.y},#{e.w},#{e.h}" | |
| end.join("\n") | |
| GTK.write_file("data/entities.txt", contents) | |
| end | |
| def move_player | |
| if inputs.directional_angle | |
| @player.angle += 0.1 * Geometry.angle_delta(@player.angle, inputs.directional_angle) | |
| @player.action! :moving | |
| end | |
| if inputs.directional_vector | |
| @player.x += @player.angle.to_vector.x * 2 | |
| @player.y += @player.angle.to_vector.y * 2 | |
| @player.action! :moving | |
| end | |
| if !inputs.directional_vector && !inputs.directional_angle | |
| @player.action! :idle if @player.action == :moving | |
| end | |
| # outputs.watch "#{@player.action}" | |
| # outputs.watch "#{@player.money}" | |
| @camera.target_x = @player.center.x | |
| @camera.target_y = @player.center.y | |
| @camera.tick | |
| end | |
| def action_player | |
| if inputs.controller_one.key_down.a || inputs.keyboard.key_down.m | |
| first = @entities.find do |e| | |
| Geometry.intersect_rect?(@player.interaction_rect, e) | |
| end | |
| if first && first.type == :mineral_field | |
| @player.mine! first | |
| end | |
| elsif inputs.keyboard.key_down.b | |
| @player.build_miner! | |
| end | |
| end | |
| def viewport_rect | |
| { | |
| x: 640, | |
| y: 360, | |
| w: @camera.viewport_w, | |
| h: @camera.viewport_h, | |
| anchor_x: 0.5, | |
| anchor_y: 0.5 | |
| } | |
| end | |
| def tick_edit_map | |
| if inputs.mouse.key_down.left && Geometry.inside_rect?(inputs.mouse.rect, viewport_rect) | |
| existing_entity = @entities.find do |e| | |
| Geometry.intersect_rect?(mouse_rect_in_world_space_grid_aligned, e) | |
| end | |
| if existing_entity | |
| @entities.reject! { |e| e.equal?(existing_entity) } | |
| save_entities @entities | |
| @entities = load_entities | |
| else | |
| @entities << { | |
| type: :mineral_field, | |
| x: mouse_rect_in_world_space_grid_aligned.x, | |
| y: mouse_rect_in_world_space_grid_aligned.y, | |
| w: 32, h: 32, | |
| quantity: 100, | |
| } | |
| save_entities @entities | |
| @entities = load_entities | |
| end | |
| end | |
| end | |
| def tick | |
| if Kernel.tick_count == 0 | |
| outputs[:grass_background].set w: 1280, h: 1280 | |
| 1280.idiv(32).times do |x| | |
| 1280.idiv(32).times do |y| | |
| outputs[:grass_background].primitives << { | |
| x: x * 32, y: y * 32, w: 32, h: 32, path: "sprites/grass/#{Numeric.rand(20)}.png" | |
| } | |
| end | |
| end | |
| end | |
| move_player | |
| action_player | |
| @player.tick | |
| @player_menu.tick(inputs) | |
| tick_edit_map | |
| map_size = 1280 | |
| signal_radius = @player.signal_radius * 2.1 | |
| desired_signal_radius_px = 640 | |
| zoom = desired_signal_radius_px.to_f / signal_radius | |
| @camera.target_scale = zoom | |
| @entities.find_all { |e| e.type == :mineral_field && e.quantity <= 0 }.each do |depleted_field| | |
| depleted_field.path = "sprites/mineral_fields_empty/#{depleted_field.index % 4}.png" | |
| end | |
| render | |
| end | |
| def render | |
| # outputs.watch "#{mouse_in_world_space}" | |
| outputs[:scene].set w: 620, h: 620, background_color: [48, 64, 57] | |
| outputs[:scene].primitives << @camera.to_screen_space(@grass_background) | |
| outputs[:scene].primitives << @camera.to_screen_space(@entities) | |
| outputs[:scene].primitives << @camera.to_screen_space(@player.primitives) | |
| outputs[:scene].primitives << @camera.to_screen_space(@enemies.map(&:primitives)) | |
| # outputs[:scene].primitives << @camera.to_screen_space(@player.interaction_rect.merge(path: :solid, r: 255, g: 0, b: 0, a: 128)) | |
| # outputs[:scene].primitives << @camera.to_screen_space(mouse_rect_in_world_space_grid_aligned.merge(path: :solid, r: 255, g: 0, b: 0, a: 128)) | |
| outputs.background_color = [30, 30, 30] | |
| outputs.primitives << { x: 640, | |
| y: 360, | |
| w: 620, | |
| h: 620, | |
| anchor_x: 0.5, | |
| anchor_y: 0.5, | |
| path: :scene } | |
| outputs.primitives << @player_menu.primitives | |
| # outputs.primitives << Layout.debug_primitives(invert_colors: true, a: 64) | |
| end | |
| end | |
| module Main | |
| attr :game | |
| def boot args | |
| args.state = {} | |
| end | |
| def tick args | |
| @game ||= Game.new | |
| @game.args = args | |
| @game.tick | |
| end | |
| def did_reset args | |
| @game = nil | |
| end | |
| end | |
| # GTK.reset |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment