Created
January 1, 2024 04:31
-
-
Save vaiorabbit/995d4d2b098beca2146a99b696fc3798 to your computer and use it in GitHub Desktop.
This file contains 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 'raylib' | |
include Raylib | |
#################################################################################################### | |
class Game | |
attr_reader :high_score, :config, :current_score, :state_timer | |
STATES = [:Ready, :Playing, :GameOver] | |
StageConfig = Struct.new(:screen_width, :screen_height, keyword_init: true) | |
STATE_READY_DURATION = 2.0 | |
def initialize | |
@config = StageConfig.new(screen_width: 480, screen_height: 720) | |
@high_score = 0 | |
reset | |
end | |
def reset(keep_high_score: true) | |
@state = :Ready | |
@current_score = 0 | |
@high_score = 0 unless :keep_high_score | |
@state_timer = STATE_READY_DURATION | |
end | |
def update(dt) | |
case @state | |
when :Ready | |
@state_timer -= dt | |
if @state_timer < 0 && IsKeyDown(KEY_SPACE) | |
@state_timer = 0.0 | |
@state = :Playing | |
end | |
end | |
end | |
def set_state(new_state) | |
raise ArgumentError unless STATES.include? new_state | |
@state = new_state | |
end | |
private :set_state | |
def finish = set_state(:GameOver) | |
def ready? = @state == :Ready | |
def game_over? = @state == :GameOver | |
def current_score=(new_score) | |
@current_score = new_score | |
@high_score = @current_score if @current_score > @high_score | |
end | |
end | |
#################################################################################################### | |
class Dot | |
attr_accessor :pos | |
attr_reader :radius, :large | |
SCORE_NORMAL = 10 | |
SCORE_LARGE = 50 | |
RADIUS_NORMAL = 8.0 | |
RADIUS_LARGE = 24.0 | |
def initialize(pos_x, pos_y, large_on:) | |
@pos = Vector2.create(pos_x, pos_y) | |
@size = Vector2.create | |
reset(large_on:) | |
end | |
def reset(large_on: false) | |
@large = large_on | |
@radius = large_on ? RADIUS_LARGE : RADIUS_NORMAL | |
@size.set(@radius, @radius) | |
@active = true | |
end | |
def hit?(offset, circle_center, circle_radius) | |
hit = CheckCollisionCircles(Vector2.create(@pos.x + offset, @pos.y), @radius, circle_center, circle_radius) | |
return hit, hit ? score() : 0 | |
end | |
def eaten? = !@active | |
def hide = @active = false | |
def score = @large ? SCORE_LARGE : SCORE_NORMAL | |
def render(offset_x) | |
return unless @active | |
if @large | |
DrawCircle(offset_x + @pos.x + @radius * 0.5 - RADIUS_NORMAL, @pos.y - @radius * 0.25 + RADIUS_NORMAL, @radius, ORANGE) | |
else | |
DrawRectangle(offset_x + @pos.x, @pos.y, @size.x, @size.y, ORANGE) | |
end | |
end | |
end | |
#################################################################################################### | |
class Obstacle | |
attr_reader :width, :top, :bottom, :pos_x | |
def initialize(width, top, bottom, stage_height, pos_x) | |
stage_height = 0.0 if stage_height < 0.0 | |
top = 0.0 if top < 0.0 | |
bottom = stage_height if bottom > stage_height | |
bottom = top if bottom < top | |
@width = width | |
@top = top | |
@bottom = bottom | |
@stage_height = stage_height | |
@pos_x = pos_x | |
end | |
def gap_center_y = (@top + @bottom) * 0.5 | |
def hit?(offset, circle_center, circle_radius) | |
check_height = 1000000.0 | |
rect_top = Rectangle.create(offset + 0.0, -check_height, @width, check_height + @top) | |
return true if CheckCollisionCircleRec(circle_center, circle_radius, rect_top) | |
rect_bottom = Rectangle.create(offset + 0.0, @bottom, @width, @stage_height - @bottom) | |
return true if CheckCollisionCircleRec(circle_center, circle_radius, rect_bottom) | |
return false | |
end | |
def render(offset) | |
DrawRectangle(offset + @pos_x + 0.0, 0.0, @width, @top, BLUE) | |
DrawRectangle(offset + @pos_x + 2.0, 0.0, @width - 2.0 * 2.0, @top - 2.0, BLACK) | |
DrawRectangleLinesEx(Rectangle.create(offset + @pos_x + 6.0, -2.0, @width - 2.0 * 6.0, @top - 4.0), 2.0, BLUE) | |
DrawRectangle(offset + @pos_x + 0.0, @bottom, @width, @stage_height - @bottom + 1.0, BLUE) | |
DrawRectangle(offset + @pos_x + 2.0, @bottom + 2.0, @width - 2.0 * 2.0, @stage_height - @bottom, BLACK) | |
DrawRectangleLinesEx(Rectangle.create(offset + @pos_x + 6.0, @bottom + 6.0, @width - 2.0 * 6.0, @stage_height - @bottom - 8.0), 2.0, BLUE) | |
end | |
end | |
class Area | |
attr_reader :stage_width | |
def initialize(stage_width, stage_height, ground_height, obstacle_width, obstacle_interval, gap_height) | |
@stage_width = stage_width | |
@stage_height = stage_height | |
@ground_height = ground_height | |
@obstacle_width = obstacle_width | |
@obstacle_interval = obstacle_interval | |
@gap_height = gap_height | |
@obstacles = [] | |
@dots = [] | |
end | |
def clear | |
@obstacles.clear | |
@dots.clear | |
end | |
def make_obstacles | |
obstacle_min_height = 5.0 | |
top_min = obstacle_min_height | |
top_max = @stage_height - @ground_height - @gap_height - obstacle_min_height | |
obstacles_conut = (@stage_width / (@obstacle_width + @obstacle_interval)).to_i | |
current_pos_x = 0.0 | |
obstacles_conut.times do |i| | |
top = (rand() * (@stage_height - @gap_height)).clamp(top_min, top_max) | |
bottom = top + @gap_height | |
@obstacles << Obstacle.new(@obstacle_width, top, bottom, @stage_height - @ground_height, current_pos_x) | |
current_pos_x += (@obstacle_width + @obstacle_interval) | |
end | |
end | |
private :make_obstacles | |
def make_dots | |
@obstacles.each do |obstacle| | |
@dots << Dot.new(obstacle.pos_x + obstacle.width * 0.5, obstacle.gap_center_y, large_on: true) | |
end | |
obstacles_conut = (@stage_width / (@obstacle_width + @obstacle_interval)).to_i | |
current_pos_x = @obstacle_width + @obstacle_interval * 1.0 | |
current_pos_y = Dot::RADIUS_LARGE + Dot::RADIUS_NORMAL | |
@dots << Dot.new(current_pos_x, current_pos_y, large_on: true) | |
y_interval = Dot::RADIUS_NORMAL * 4.0 | |
current_pos_y += Dot::RADIUS_LARGE + y_interval | |
16.times do |i| | |
@dots << Dot.new(current_pos_x, current_pos_y, large_on: false) | |
current_pos_y += y_interval | |
end | |
current_pos_y += Dot::RADIUS_LARGE | |
@dots << Dot.new(current_pos_x, current_pos_y, large_on: true) | |
end | |
private :make_dots | |
def make | |
@obstacles.clear | |
make_obstacles() | |
@dots.clear | |
make_dots() | |
end | |
def hit?(offset, circle_center, circle_radius) | |
score_total = 0 | |
circle_offset_center = Vector2.create(circle_center.x + offset, circle_center.y) | |
@dots.each do |dot| | |
unless dot.eaten? | |
hit, score = dot.hit?(offset, circle_center, circle_radius) | |
if hit | |
score_total += score | |
dot.hide | |
end | |
end | |
end | |
@obstacles.each do |obstacle| | |
hit = obstacle.hit?(offset, circle_center, circle_radius) | |
return hit, score_total if hit | |
end | |
return false, score_total | |
end | |
def render(offset) | |
@obstacles.each do |obstacle| | |
obstacle.render(offset) | |
end | |
@dots.each do |dot| | |
dot.render(offset) | |
end | |
end | |
end | |
#################################################################################################### | |
class Stage | |
attr_reader :width, :height, :center | |
BACKGROUND_COLOR_0 = Color.from_u8(10, 20, 120, 255) | |
BACKGROUND_COLOR_1 = Color.from_u8(235, 250, 220, 255) | |
BORDER_COLOR = Color.from_u8(80, 60, 170, 255) | |
LAWN_COLOR_0 = Color.from_u8(50, 90, 230, 255) | |
LAWN_COLOR_1 = Color.from_u8(10, 50, 200, 255) | |
DIRT_COLOR = Color.from_u8(215, 215, 215, 255) | |
BUILDING_COLOR_00 = Color.from_u8(200, 225, 225, 255) | |
BUILDING_COLOR_10 = Color.from_u8(160, 170, 210, 255) | |
BUILDING_COLOR_11 = Color.from_u8(180, 190, 230, 255) | |
def initialize(width, height) | |
@width = width | |
@height = height | |
@center = Vector2.create(@width * 0.5, @height * 0.5) | |
@building_scroll_speed = 1.0 | |
@building_pattern_offset = 0.0 | |
@building_pattern_width = 130 | |
@ground_height = 60.0 | |
@lawn_height = 20.0 | |
@border_height = 2.0 | |
@lawn_scroll_speed = 2.0 | |
@lawn_pattern_offset = 0.0 | |
@lawn_pattern_width = @lawn_height | |
@areas_scroll_speed = 2.0 | |
@areas_pattern_offset = 0.0 | |
@areas_pattern_width = @width | |
@obstacle_width = 150.0 | |
@obstacle_interval = 150.0 | |
@gap_height = 300.0 | |
@areas = [ | |
Area.new(@width, @height, @ground_height, @obstacle_width, @obstacle_interval, @gap_height), | |
Area.new(@width, @height, @ground_height, @obstacle_width, @obstacle_interval, @gap_height), | |
] | |
@building_pattern = lambda {|offset_x| | |
# Layer0 | |
DrawRectangle(offset_x + 5.0, 560.0, 25.0, @height, BUILDING_COLOR_00) | |
DrawRectangle(offset_x + 35.0, 580.0, 25.0, @height, BUILDING_COLOR_00) | |
DrawRectangle(offset_x + 85.0, 600.0, 25.0, @height, BUILDING_COLOR_00) | |
DrawRectangle(offset_x + 115.0, 590.0, 25.0, @height, BUILDING_COLOR_00) | |
# Layer1-1 | |
DrawRectangle(offset_x + 20.0, 600.0, 20.0, @height, BUILDING_COLOR_10) | |
DrawRectangle(offset_x + 20.0 + 2.0, 600.0 + 2.0, 20.0 - 2.0 * 2.0, @height, BUILDING_COLOR_11) | |
DrawRectangle(offset_x + 65.0, 570.0, 30.0, @height, BUILDING_COLOR_10) | |
DrawRectangle(offset_x + 65.0 + 2.0, 570.0 + 2.0, 30.0 - 2.0 * 2.0, @height, BUILDING_COLOR_11) | |
# Layer1-2 | |
DrawRectangle(offset_x + 50.0, 550.0, 30.0, @height, BUILDING_COLOR_10) | |
DrawRectangle(offset_x + 50.0 + 2.0, 550.0 + 2.0, 30.0 - 2.0 * 2.0, @height, BUILDING_COLOR_11) | |
DrawRectangle(offset_x + 100.0, 580.0, 30.0, @height, BUILDING_COLOR_10) | |
DrawRectangle(offset_x + 100.0 + 2.0, 580.0 + 2.0, 30.0 - 2.0 * 2.0, @height, BUILDING_COLOR_11) | |
# Layer1-3 | |
DrawRectangle(offset_x + 35.0, 620.0, 20.0, @height, BUILDING_COLOR_10) | |
DrawRectangle(offset_x + 35.0 + 2.0, 620.0 + 2.0, 20.0 - 2.0 * 2.0, @height, BUILDING_COLOR_11) | |
DrawRectangle(offset_x + 75.0, 610.0, 30.0, @height, BUILDING_COLOR_10) | |
DrawRectangle(offset_x + 75.0 + 2.0, 610.0 + 2.0, 30.0 - 2.0 * 2.0, @height, BUILDING_COLOR_11) | |
} | |
@lawn_pattern = lambda {|offset_x, base_y| | |
len = @lawn_pattern_width | |
repeat = 1 + (@width / (2 * len)).to_i | |
repeat.times do |i| | |
DrawRectangle(offset_x + i * len * 2, base_y, len, len, LAWN_COLOR_1) | |
end | |
} | |
reset() | |
end | |
def reset | |
@building_pattern_offset = 0.0 | |
@lawn_pattern_offset = 0.0 | |
@areas_pattern_offset = 0.0 | |
@areas[0].clear | |
@areas[1].make | |
end | |
def update(dt) | |
@building_pattern_offset -= @building_scroll_speed | |
@building_pattern_offset = 0.0 if @building_pattern_offset.abs >= @building_pattern_width | |
@lawn_pattern_offset -= @lawn_scroll_speed | |
@lawn_pattern_offset = 0.0 if @lawn_pattern_offset.abs >= (2 * @lawn_pattern_width) | |
@areas_pattern_offset -= @areas_scroll_speed | |
if @areas_pattern_offset.abs >= @areas_pattern_width | |
@areas_pattern_offset = 0.0 | |
@areas[0] = @areas[1] | |
@areas[1] = Area.new(@width, @height, @ground_height, @obstacle_width, @obstacle_interval, @gap_height) | |
@areas[1].make | |
end | |
end | |
def ground_hit?(circle_center, circle_radius) | |
base_y = @height - @ground_height | |
rect_ground = Rectangle.create(0.0, base_y, @width, @ground_height) | |
if CheckCollisionCircleRec(circle_center, circle_radius, rect_ground) | |
return true | |
end | |
return false | |
end | |
private :ground_hit? | |
def hit?(circle_center, circle_radius) | |
hit_any = false | |
score_total = 0 | |
@areas.length.times do |i| | |
offset = @areas_pattern_offset + i * @areas[i].stage_width | |
hit, score = @areas[i].hit?(offset, circle_center, circle_radius) | |
hit_any = true if hit | |
score_total += score | |
end | |
unless hit_any | |
hit_any = ground_hit?(circle_center, circle_radius) | |
end | |
return hit_any, score_total | |
end | |
def render | |
# Sky | |
DrawRectangleGradientV(0.0, 0.0, @width, @height, BACKGROUND_COLOR_0, BACKGROUND_COLOR_1) | |
# Background | |
5.times do |i| | |
@building_pattern.call(@building_pattern_offset + i * @building_pattern_width) | |
end | |
# Ground | |
base_y = @height - @ground_height | |
DrawRectangle(0.0, base_y, @width, @ground_height, DIRT_COLOR) | |
DrawRectangle(0.0, base_y, @width, @border_height, BORDER_COLOR) | |
DrawRectangle(0.0, base_y + @border_height, @width, @lawn_height, LAWN_COLOR_0) | |
@lawn_pattern.call(@lawn_pattern_offset, base_y + @border_height) | |
@areas.length.times do |i| | |
offset = @areas_pattern_offset + i * @areas[i].stage_width | |
@areas[i].render(offset) | |
end | |
end | |
end | |
#################################################################################################### | |
class Player | |
attr_reader :hit_radius | |
attr_accessor :pos | |
STATES = [:Alive, :Failed] | |
DRAW_RADIUS = 50.0 | |
HIT_RADIUS = 40.0 | |
GRAVITY = 9.8 * 6 | |
VEL_BOOST = 9.8 * 1.5 | |
ANGLE_OFFSET_LIMIT = 45.0 | |
def initialize | |
@pos = Vector2.create | |
reset | |
end | |
def reset | |
@pos.set(0.0, 0.0) | |
@draw_radius = DRAW_RADIUS | |
@hit_radius = HIT_RADIUS | |
@anim_mouse_timer = 0.0 | |
@anim_mouse_open = true | |
@anim_failed_timer = 0.0 | |
@anim_failed_scale = 1.0 | |
@state = :Alive | |
@vel_y = 0 | |
@angle_offset = 0 | |
end | |
def set_state(new_state) | |
raise ArgumentError unless STATES.include? new_state | |
@state = new_state | |
end | |
private :set_state | |
def finish = set_state(:Failed) | |
def failed? = @state == :Failed | |
def update(dt) | |
if not failed? and (IsKeyPressed(KEY_SPACE) || IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) | |
@vel_y = -VEL_BOOST | |
end | |
if @state == :Alive | |
@vel_y = @vel_y + GRAVITY * dt | |
@pos.y = @pos.y + @vel_y | |
end | |
angle_rot_rate = @vel_y.clamp(-VEL_BOOST, VEL_BOOST) / VEL_BOOST | |
@angle_offset = ANGLE_OFFSET_LIMIT * angle_rot_rate | |
case @state | |
when :Alive | |
@anim_mouse_open = @anim_mouse_timer <= ((1.0 / 60.0) * 4) | |
@anim_mouse_timer += dt | |
@anim_mouse_timer = 0.0 if @anim_mouse_timer >= ((1.0 / 60.0) * 8) | |
when :Failed | |
@anim_failed_scale = 1.0 - @anim_failed_timer | |
@anim_failed_scale = 0.0 if @anim_failed_scale < 0.0 | |
@anim_failed_timer += dt | |
end | |
end | |
def render | |
radius = @draw_radius | |
radius *= @anim_failed_scale if failed? | |
body_color = failed? ? Fade(YELLOW, @anim_failed_scale) : YELLOW | |
if @anim_mouse_open | |
DrawCircleSector(@pos, radius, 30 + @angle_offset, 330 + @angle_offset, 36, body_color) | |
else | |
DrawCircle(@pos.x, @pos.y, radius, body_color) | |
end | |
end | |
end | |
#################################################################################################### | |
if __FILE__ == $PROGRAM_NAME | |
# Load raylib | |
shared_lib_path = Gem::Specification.find_by_name('raylib-bindings').full_gem_path + '/lib/' | |
case RUBY_PLATFORM | |
when /mswin|msys|mingw/ # Windows | |
Raylib.load_lib(shared_lib_path + 'libraylib.dll') | |
when /darwin/ # macOS | |
arch = RUBY_PLATFORM.split('-')[0] | |
Raylib.load_lib(shared_lib_path + "libraylib.#{arch}.dylib") | |
when /linux/ # Ubuntu Linux (x86_64 or aarch64) | |
arch = RUBY_PLATFORM.split('-')[0] | |
Raylib.load_lib(shared_lib_path + "libraylib.#{arch}.so") | |
else | |
raise RuntimeError, "Unknown system: #{RUBY_PLATFORM}" | |
end | |
game = Game.new | |
screen_width, screen_height = game.config.screen_width, game.config.screen_height | |
# Start raylib | |
SetTraceLogLevel(LOG_ERROR) | |
InitWindow(screen_width, screen_height, 'Yet Another Ruby-raylib bindings : flapper') | |
SetTargetFPS(60) | |
# Initialize objects | |
stage = Stage.new(screen_width, screen_height) | |
player = Player.new | |
reset_game = lambda { | |
game.reset | |
stage.reset | |
player.reset | |
player.pos.set(stage.center.x, screen_height * 0.5) | |
} | |
reset_game.call | |
until WindowShouldClose() | |
# Press R to restart | |
reset_game.call if IsKeyPressed(KEY_R) | |
dt = GetFrameTime() | |
# Update objects | |
game.update(dt) | |
unless game.ready? | |
stage.update(dt) unless game.game_over? | |
player.update(dt) | |
# Check collision : player vs stage | |
hit, score = stage.hit?(player.pos, player.hit_radius) | |
if hit | |
game.finish | |
player.finish | |
end | |
game.current_score += score | |
end | |
# Render scene | |
BeginDrawing() | |
ClearBackground(Stage::BACKGROUND_COLOR_0) | |
# Render objects | |
stage.render | |
player.render | |
# Render UI | |
# Event message | |
msg_font_size = 35 | |
if game.ready? | |
text_width = MeasureText('READY?', msg_font_size) | |
q = game.state_timer.divmod(0.1)[0] | |
DrawText('READY?', 0.5 * screen_width - text_width * 0.5, 70, msg_font_size, RED) if q % 2 == 0 | |
elsif game.game_over? | |
text_widths = [ | |
MeasureText('GAME OVER', msg_font_size), | |
MeasureText('Press R to restart', msg_font_size) | |
] | |
DrawText('GAME OVER', 0.5 * screen_width - text_widths[0] * 0.5, 0.5 * screen_height - 30, msg_font_size, RED) | |
DrawText('Press R to restart', 0.5 * screen_width - text_widths[1] * 0.5, 0.5 * screen_height + 30, msg_font_size, RED) | |
end | |
# Scores | |
DrawText('1UP', 20, 10, 25, RED) | |
DrawText("#{game.current_score}", 20, 35, 25, WHITE) | |
score_font_size = 25 | |
hiscore_header = 'HIGH SCORE' | |
hiscore_header_width = MeasureText(hiscore_header, score_font_size) | |
hiscore_value = "%10d" % game.high_score | |
hiscore_value_width = MeasureText(hiscore_value, score_font_size) | |
hiscore_value_offset = (hiscore_header_width - hiscore_value_width).abs | |
hiscore_header_x = 0.5 * screen_width - hiscore_header_width * 0.5 | |
hiscore_value_x = hiscore_header_x + hiscore_value_offset | |
DrawText(hiscore_header, hiscore_header_x, 10, score_font_size, RED) | |
DrawText(hiscore_value, hiscore_value_x, 35, score_font_size, WHITE) | |
# Help message | |
if game.ready? | |
help_base_x = screen_width - 220 | |
help_base_y = screen_height - 100 | |
help_msg_x = help_base_x + 10 | |
help_msg_base_y = help_base_y + 10 | |
DrawRectangle(help_base_x, help_base_y, 205, 80, Fade(MAROON, 0.8)) | |
DrawRectangleLines(help_base_x, help_base_y, 205, 80, GRAY) | |
DrawText('Space/Click : jump', help_msg_x, help_msg_base_y + 0, 20, WHITE) | |
DrawText('R : restart game', help_msg_x, help_msg_base_y + 20, 20, WHITE) | |
DrawText('ESC : exit', help_msg_x, help_msg_base_y + 40, 20, WHITE) | |
end | |
EndDrawing() | |
end | |
CloseWindow() | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment