|
require "hokusai" |
|
require "hokusai/backends/raylib" |
|
|
|
# Just a wrapper for the game state |
|
# We will provide this to descendant blocks |
|
# using Hokusai::Block::provide |
|
class Game |
|
attr_accessor :score, :active, :best, :pristine |
|
|
|
def initialize |
|
@score = 0 |
|
@active = false |
|
@best = 0 |
|
@pristine = true |
|
end |
|
end |
|
|
|
# Just a wrapper for the Square state |
|
class Square |
|
attr_accessor :x, :y, :target_x, :target_y, |
|
:started, :started_at, :type, :size |
|
|
|
def self.random(size, type, canvas) |
|
x = canvas.x + rand(canvas.x + canvas.width) |
|
y = canvas.y - size |
|
target_x = canvas.x + rand(canvas.x + canvas.width) |
|
target_y = canvas.y + canvas.height + size |
|
|
|
new(size, type, [x, y], [target_x, target_y]) |
|
end |
|
|
|
def initialize(size, type, origin, target) |
|
@x = origin[0] |
|
@y = origin[1] |
|
@target_x = target[0] |
|
@target_y = target[1] |
|
@size = size |
|
@type = type |
|
@started = false |
|
@started_at = nil |
|
@rotation = 0 |
|
end |
|
|
|
def start |
|
self.started = true |
|
self.started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) |
|
end |
|
|
|
def duration |
|
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - started_at |
|
end |
|
|
|
# moves the position toward target position |
|
def move(amount) |
|
self.x += target_x / amount |
|
self.y += target_y / amount |
|
|
|
if x >= target_x |
|
self.x = target_x |
|
end |
|
end |
|
|
|
def finished(canvas) |
|
y > target_y |
|
end |
|
|
|
def collided(player) |
|
(x >= player[0] && x <= player[0] + player[2] && y >= player[1] && y <= player[1] + player[3]) || |
|
(x + size >= player[0] && x + size <= player[0] + player[2] && y + size > player[1] && y + size <= player[1] + player[3]) |
|
end |
|
end |
|
|
|
# Helper block to center slotted content |
|
class CenterLayout < Hokusai::Block |
|
template <<~EOF |
|
[template] |
|
dynamic { @size_updated="set_size } |
|
slot |
|
EOF |
|
|
|
uses(dynamic: Hokusai::Blocks::Dynamic) |
|
|
|
attr_accessor :content_width, :content_height |
|
|
|
def set_size(width, height) |
|
self.content_width = width |
|
self.content_height = height |
|
end |
|
|
|
def render(canvas) |
|
canvas.x = (canvas.width - content_width) / 2.0 |
|
canvas.y = (canvas.height - content_height) / 2.0 |
|
|
|
canvas.width = content_width |
|
canvas.height = content_height |
|
yield canvas |
|
end |
|
end |
|
|
|
# The actual Game UI |
|
# Utilizes the drawing API |
|
class Squares < Hokusai::Block |
|
template <<~EOF |
|
[template] |
|
empty { |
|
cursor="pointer" |
|
@click="bounce" |
|
} |
|
EOF |
|
|
|
FPS = 60 |
|
|
|
computed :speed, default: 0.3, convert: proc(&:to_f) |
|
computed :player_size, default: 40.0, convert: proc(&:to_f) |
|
|
|
inject :game |
|
|
|
uses( |
|
empty: Hokusai::Blocks::Empty |
|
) |
|
|
|
attr_accessor :current_x, :direction, :squares, :counter |
|
|
|
def initialize(**args) |
|
super |
|
|
|
@counter = 0 |
|
@squares = [] |
|
@direction = :right |
|
@current_x = nil |
|
end |
|
|
|
def right_boundary(canvas) |
|
canvas.x + canvas.width - player_size |
|
end |
|
|
|
def find_player_x(canvas) |
|
self.current_x = canvas.x if current_x.nil? |
|
|
|
if current_x <= canvas.x |
|
self.direction = :right |
|
elsif current_x >= right_boundary(canvas) |
|
self.current_x = right_boundary(canvas) |
|
self.direction = :left |
|
end |
|
|
|
case direction |
|
when :right |
|
self.current_x += (30.0 * speed) |
|
when :left |
|
self.current_x -= (30.0 * speed) |
|
end |
|
|
|
current_x |
|
end |
|
|
|
# click handler for game |
|
# switches the direction of our player |
|
def bounce(event) |
|
case direction |
|
when :left |
|
self.direction = :right |
|
when :right |
|
self.direction = :left |
|
end |
|
end |
|
|
|
def compute_squares(canvas, player) |
|
squares.reject! { |square| square.finished(canvas) } |
|
|
|
if squares.size < 6 && (squares.size.zero? || squares.last.duration > 1000) |
|
type = (counter % 5).zero? ? :good : :bad |
|
square = Square.random(player_size, type, canvas) |
|
square.start |
|
squares << square |
|
self.counter += 1 |
|
end |
|
|
|
squares.each do |square| |
|
if square.collided(player) |
|
case square.type |
|
when :good |
|
# points! |
|
game.score += 1 |
|
squares.delete(square) |
|
|
|
else |
|
# end game |
|
game.active = false |
|
end |
|
end |
|
|
|
square.move(200.0) |
|
end |
|
end |
|
|
|
def render(canvas) |
|
|
|
# carve out space for the moving square |
|
draw do |
|
player_y = canvas.y + canvas.height - player_size |
|
player_x = find_player_x(canvas) |
|
compute_squares(canvas, [player_x, player_y, player_size, player_size]) |
|
|
|
# draw score in the center of screen |
|
fsize = 60 |
|
centerx = (canvas.width - fsize) / 2 + canvas.x |
|
centery = (canvas.height - fsize) / 2 + canvas.y |
|
text(game.score.to_s, centerx, centery) do |command| |
|
command.size = fsize |
|
command.color = Hokusai::Color.new(100, 100, 100) |
|
end |
|
|
|
# draw player rect |
|
rect(canvas.x, player_y, canvas.width, player_size) do |command| |
|
command.color = Hokusai::Color.new(222, 222, 222) |
|
end |
|
|
|
# draw player |
|
rect(player_x, player_y, player_size, player_size) do |command| |
|
command.color = Hokusai::Color.new(222, 32, 84) |
|
end |
|
|
|
# draw falling squares |
|
squares.each do |square| |
|
rect(square.x, square.y, square.size, square.size) do |command| |
|
command.color = (square.type == :good) ? Hokusai::Color.new(222, 32, 84) : Hokusai::Color.new(33, 33, 33) |
|
end |
|
end |
|
end |
|
|
|
yield canvas |
|
end |
|
end |
|
|
|
# Our load/reload screen |
|
class Load < Hokusai::Block |
|
style <<~EOF |
|
[style] |
|
loadTextStyle { |
|
size: 40; |
|
color: rgb(33, 33, 33); |
|
content: "Squares!"; |
|
} |
|
buttonStyle { |
|
cursor: "pointer"; |
|
} |
|
EOF |
|
template <<~EOF |
|
[template] |
|
vblock |
|
[if="pristine"] |
|
vblock |
|
text { |
|
...loadTextStyle |
|
} |
|
[else] |
|
vblock |
|
text { |
|
...loadTextStyle |
|
:content="game_over_text" |
|
} |
|
vblock |
|
button { |
|
...buttonStyle |
|
:content="button_text" |
|
@clicked="start" |
|
} |
|
EOF |
|
|
|
inject :game |
|
|
|
uses( |
|
vblock: Hokusai::Blocks::Vblock, |
|
text: Hokusai::Blocks::Text, |
|
button: Hokusai::Blocks::Button |
|
) |
|
|
|
def start(_event) |
|
game.score = 0 |
|
game.active = true |
|
game.pristine = false |
|
end |
|
|
|
def pristine |
|
game.pristine |
|
end |
|
|
|
def game_over_text |
|
"Game Over!\nScore #{game.score}" |
|
end |
|
|
|
def button_text |
|
game.pristine ? "Start" : "Replay" |
|
end |
|
end |
|
|
|
# The block that will be rendered to the window |
|
class GameApp < Hokusai::Block |
|
template <<~EOF |
|
[template] |
|
vblock |
|
[if="game_active"] |
|
squares |
|
[else] |
|
center |
|
load { :width="300.0", :height="300.0" } |
|
EOF |
|
|
|
provide :game, :game |
|
|
|
uses( |
|
vblock: Hokusai::Blocks::Vblock, |
|
center: CenterLayout, |
|
squares: Squares, |
|
load: Load, |
|
) |
|
|
|
def game_active |
|
game.active |
|
end |
|
|
|
def game |
|
@game ||= Game.new |
|
end |
|
end |
|
|
|
Hokusai::Backends::RaylibBackend.run(GameApp) do |config| |
|
config.fps = 60 |
|
config.width = 700 |
|
config.height = 500 |
|
end |